# 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


### 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.