# US coffee expansion screen: reproducible companion

## tl;dr

This notebook describes an illustrative residential-tract screen, not profitable-site recommendations. This companion accompanies the 14 October 2026 Locus study and its frozen source data. The current analytical baseline excludes one exact-address issuer-confirmed OCF closure via `issuer-overrides.json` and follows the locked category rule (coffee_shop primary/hierarchy/alternate; cafe primary/hierarchy, not alternate alone). Raw inputs and both pre-correction computed histories are preserved. Headline retention did not change: 123/332 radius replacements and 61/332 category replacements. Read the approved article, business/category QA and validation receipts for current checks and unresolved real-world coverage.

## Context & Methods

Eight purposively selected counties; all eligible tract anchors. Overture release 2026-09-23.1; 2020–2024 ACS five-year estimates with 90% margins of error; aligned 2024 Gazetteer land areas/internal points. Baseline weights: residential density 50%, household income 20%, inverse nearby mapped coffee-shop count 30%. Within-county percentile ranks; top 10% rounded up. Radius 1 km versus 3 km and coffee-shop-only versus coffee/cafe are separate scenarios.

### Key assumptions

An anchor is not a candidate premise. Tract demographic estimates are not circular catchment demand. Coffee/cafe relevance depends on concept and occasion. Existence confidence is not open-status verification. Straight-line distances are not travel times. County screens are not nationally representative, and sparse commercial/large suburban tracts are excluded. Uniform within-MOE perturbations are stress tests, not confidence intervals.

## Data

The source files, full SQL and manifests are in `data/us-retail-expansion-study/2026-10-14/`. Public source identities: Overture official STAC/Places guide, Census official table-based ACS Summary File and Gazetteer. No API credentials or provider calls are required to rerun the saved-data calculations.
Calculation
from pathlib import Path
import json
import csv
import math
import pandas as pd

root = Path.cwd()
while not (root / 'data/us-retail-expansion-study/2026-10-14').exists():
    if root.parent == root: raise RuntimeError('Open notebook within the Locus checkout')
    root = root.parent
data = root / 'data/us-retail-expansion-study/2026-10-14'
summary = json.loads((data/'analysis-summary.json').read_text(encoding='utf-8'))
retention = pd.read_csv(data/'shortlist-retention.csv')
tracts = pd.read_csv(data/'tract-scenarios.csv', dtype={'geoid':str})
print(f"Eligible tract anchors: {summary['anchors']:,}; source food/drink records: {summary['food_drink_rows']:,}")
print(retention.loc[retention.scenario=='baseline',['market','tracts','shortlist_size']].to_string(index=False))
Eligible tract anchors: 3,277; source food/drink records: 131,972
                 market  tracts  shortlist_size
        Austin / Travis     179              18
                 Denver     163              17
       Atlanta / Fulton     198              20
Charlotte / Mecklenburg     202              21
         Chicago / Cook    1064             107
     Phoenix / Maricopa     763              77
         Seattle / King     396              40
           Philadelphia     312              32
## Results

### 1. Re-derive shortlisted retention from the complete scenario table

Counts are distinct tract identities. No averaging of percentages is used for the pooled retention denominator.
Calculation
independent = []
for scenario in summary['models']:
    kept = total = 0
    for market in tracts.market.unique():
        base = set(tracts.loc[(tracts.market==market)&(tracts.scenario=='baseline')&tracts.shortlisted,'geoid'])
        compared = set(tracts.loc[(tracts.market==market)&(tracts.scenario==scenario)&tracts.shortlisted,'geoid'])
        kept += len(base & compared)
        total += len(base)
    expected = next(r for r in summary['pooled'] if r['scenario']==scenario)
    assert kept==expected['retained'] and total==expected['baseline_shortlist_total']
    independent.append({'scenario':scenario,'retained':kept,'baseline_total':total,'retention_percent':100*kept/total})
print(pd.DataFrame(independent).to_string(index=False))
             scenario  retained  baseline_total  retention_percent
             baseline       332             332         100.000000
           radius_3km       209             332          62.951807
    expanded_category       271             332          81.626506
        confidence_07       315             332          94.879518
        confidence_09       319             332          96.084337
           demand_led       209             332          62.951807
         headroom_led       187             332          56.325301
combined_3km_expanded       208             332          62.650602
### 2. County-level radius and category comparisons

County comparisons expose stable as well as unstable screens. Retention is not predictive accuracy or a business success rate.
Calculation
print(retention.loc[retention.scenario.isin(['radius_3km','expanded_category']),
 ['market','scenario','retained','shortlist_size','retention_percent','tie_inclusive_retention_percent']].to_string(index=False))
                 market          scenario  retained  shortlist_size  retention_percent  tie_inclusive_retention_percent
        Austin / Travis        radius_3km        10              18          55.555556                        55.555556
        Austin / Travis expanded_category        12              18          66.666667                        66.666667
                 Denver        radius_3km        13              17          76.470588                        76.470588
                 Denver expanded_category        15              17          88.235294                        88.235294
       Atlanta / Fulton        radius_3km        11              20          55.000000                        55.000000
       Atlanta / Fulton expanded_category        16              20          80.000000                        80.000000
Charlotte / Mecklenburg        radius_3km        11              21          52.380952                        50.000000
Charlotte / Mecklenburg expanded_category        18              21          85.714286                        86.363636
         Chicago / Cook        radius_3km        74             107          69.158879                        69.158879
         Chicago / Cook expanded_category        92             107          85.981308                        85.981308
     Phoenix / Maricopa        radius_3km        45              77          58.441558                        58.441558
     Phoenix / Maricopa expanded_category        62              77          80.519481                        80.519481
         Seattle / King        radius_3km        21              40          52.500000                        52.500000
         Seattle / King expanded_category        31              40          77.500000                        77.500000
           Philadelphia        radius_3km        24              32          75.000000                        75.000000
           Philadelphia expanded_category        25              32          78.125000                        78.125000
### 3. Standalone research figures

Figures use exact county-level counts and a common 0–100% scale. They are portable PNG assets for the draft article and notebook, not publication receipts.
Calculation
from PIL import Image, ImageDraw, ImageFont
import io
import base64

def make_figure(scenario, title, filename):
    selected = retention.loc[retention.scenario==scenario].sort_values('retention_percent')
    image = Image.new('RGB',(1500,1000),'white')
    draw = ImageDraw.Draw(image)
    font_path = Path('C:/Windows/Fonts/segoeui.ttf')
    font = ImageFont.truetype(str(font_path),30) if font_path.exists() else ImageFont.load_default(size=30)
    heading = ImageFont.truetype(str(font_path),42) if font_path.exists() else ImageFont.load_default(size=42)
    small = ImageFont.truetype(str(font_path),24) if font_path.exists() else ImageFont.load_default(size=24)
    draw.text((65,40),title,fill='#20252a',font=heading)
    draw.text((65,104),'Share of baseline top-decile tracts retained — illustrative county screen',fill='#505a63',font=small)
    left,right,top,bottom=500,1200,210,840
    for tick in (0,25,50,75,100):
        x=left+(right-left)*tick/100
        draw.line((x,top-20,x,bottom),fill='#e5e8eb',width=2)
        draw.text((x-18,bottom+15),f'{tick}%',fill='#505a63',font=small)
    for i,row in enumerate(selected.itertuples()):
        y=top+i*77
        label={'Austin / Travis':'Travis, TX','Denver':'Denver, CO','Atlanta / Fulton':'Fulton, GA',
        'Charlotte / Mecklenburg':'Mecklenburg, NC','Chicago / Cook':'Cook, IL','Phoenix / Maricopa':'Maricopa, AZ',
        'Seattle / King':'King, WA','Philadelphia':'Philadelphia, PA'}[row.market]
        draw.text((65,y-6),label,fill='#20252a',font=font)
        x=left+(right-left)*row.retention_percent/100
        draw.rectangle((left,y,x,y+36),fill='#276fa7')
        value=f'{row.retention_percent:.1f}% ({row.retained}/{row.shortlist_size})'
        draw.text((x+12,y-4),value,fill='#20252a',font=small)
    draw.text((65,935),'Overture 23 Sep 2026; ACS 2020–2024. Not sales, profitability or national opportunity rankings.',fill='#505a63',font=small)
    output=root/'research-articles/assets'/filename
    output.parent.mkdir(parents=True,exist_ok=True)
    image.save(output)
    figure_images.append((filename,image))
    print(filename)

figure_images=[]
make_figure('radius_3km','Changing the competition radius: 1 km to 3 km','2026-10-14-us-coffee-radius-retention.png')
make_figure('expanded_category','Changing the definition: coffee shops to coffee/cafes','2026-10-14-us-coffee-category-retention.png')
2026-10-14-us-coffee-radius-retention.png
2026-10-14-us-coffee-category-retention.png
County-level shortlist retentionCounty-level shortlist retention
### 4. Business/category checks and adverse scenarios

These additional checks were added after seeing the results. They do not adjudicate nearby identities, establish closures, estimate an error rate or bound missing businesses. Both baseline and comparison are recomputed under each treatment; the original eligibility and weights stay fixed. Specialist formats are not automatically errors.
Calculation
qa = json.loads((data/'business-category-qa.json').read_text(encoding='utf-8'))
print(f"Full distance/category count comparisons against the final scenario rows: {qa['full_distance_category_count_checks']:,}")
policy_names = {'final_locked_definition':'Final model','exclude_fixed_unresolved_sample':'Remove unresolved sample',
 'exclude_fixed_format_review':'Remove flagged formats','exclude_specialist_cafe_taxonomy':'Remove specialist cafes',
 'one_per_same_name_nearby_component':'One per same-name group','one_per_all_nearby_component':'One per nearby group',
 'exclude_all_nearby_flagged_records':'Remove all nearby flags','source_open_only':'Source-open records only'}
table = pd.DataFrame(qa['pooled_adverse_scenarios'])
table['Treatment'] = table.policy.map(policy_names)
table['Test'] = table.comparison.map({'radius_3km':'Radius','expanded_category':'Category'})
table['Retained'] = table.retained.astype(str)+'/'+table.denominator.astype(str)
table['Replaced (%)'] = table.replaced_percent.round(1)
print(table[['Treatment','Test','Retained','Replaced (%)']].to_string(index=False))
assert next(r for r in qa['pooled_adverse_scenarios'] if r['policy']=='final_locked_definition' and r['comparison']=='radius_3km')['retained']==209
assert next(r for r in qa['pooled_adverse_scenarios'] if r['policy']=='final_locked_definition' and r['comparison']=='expanded_category')['retained']==271
Full distance/category count comparisons against the final scenario rows: 13,108
               Treatment     Test Retained  Replaced (%)
             Final model   Radius  209/332          37.0
             Final model Category  271/332          18.4
Remove unresolved sample   Radius  209/332          37.0
Remove unresolved sample Category  271/332          18.4
  Remove flagged formats   Radius  210/332          36.7
  Remove flagged formats Category  272/332          18.1
 Remove specialist cafes   Radius  209/332          37.0
 Remove specialist cafes Category  272/332          18.1
 One per same-name group   Radius  209/332          37.0
 One per same-name group Category  271/332          18.4
    One per nearby group   Radius  209/332          37.0
    One per nearby group Category  271/332          18.4
 Remove all nearby flags   Radius  206/332          38.0
 Remove all nearby flags Category  268/332          19.3
Source-open records only   Radius  212/332          36.1
Source-open records only Category  268/332          19.3
## Takeaways

Use sensitivity to decide what to investigate, not to declare that a model is wrong. Radius and category changes alter scope; weighting changes alter priorities. Mapping completeness and current business status require separate validation.

### Validation

`scripts/us-retail-expansion-study/validate.py` independently re-derives every demographic input and score/shortlist row, and spot-checks category/distance counts for three fixed GEOID positions per county. The separate business/category audit rechecks all 13,108 counts in the four main radius/category scenarios using a separate distance-matrix/pandas implementation. Read `validation-receipt.json` and `business-category-qa.json` for completed checks and explicit unverified coverage. The notebook cell runner executes these cells top-to-bottom in standard Python; a Jupyter kernel/UI is not used.