Mapping Sprouts' white space in Denver
This example shows how to run the brand white-space variant of void analysis for Sprouts Farmers Market in the Denver metro: find the areas that match the profile of the brand's existing catchments, where a competitor already proves the demand, and where the brand itself is absent.
For this we use our US Places and Neighborhood metrics, queried from Python, with the Neighborhood metrics at Census Block Group (CBG) grain. The helper below is all the setup the example needs:
import os
import requests
import pandas as pd
API = "https://api.pine59.com/v3/datasets"
HEADERS = {"Authorization": f"Bearer {os.environ['PINE59_API_KEY']}"}
def run_query(dataset, body):
r = requests.post(f"{API}/{dataset}:runQuery", json=body, headers=HEADERS)
r.raise_for_status()
return [rec["fields"] for rec in r.json().get("records", [])]
1. Store networks
The first step is the three store lists in one query: the brand and its conventional-grocery competitors in the metro, with each store's coordinates and CBG code (census_code). The CBG code is what ties a store to the neighborhood metrics later, so area assignment is a filter, not a spatial join.
stores = pd.DataFrame(run_query("visitation_usa_places.places", {
"fields": [
{"name": "location_id"}, {"name": "brands"}, {"name": "census_code"},
{"name": "latitude"}, {"name": "longitude"},
{"name": "street_address"}, {"name": "city"},
],
"filters": [
{"fieldName": "brands", "operator": "in",
"values": ["Sprouts Farmers Market", "King Soopers", "Safeway"]},
{"fieldName": "us_cbsa", "operator": "==",
"value": "Denver-Aurora-Lakewood, CO Metro Area"},
],
"options": {"orderBy": {"fieldName": "location_id", "direction": "ASC"}},
"pageSize": 1000,
})).astype({"latitude": float, "longitude": float})
print(stores.brands.value_counts())
sprouts = stores[stores.brands == "Sprouts Farmers Market"]
competitors = stores[stores.brands != "Sprouts Farmers Market"]
Result: 84 King Soopers, 42 Safeway, 23 Sprouts. Brand values must match the catalog exactly (check with searchFieldValues first), and near-misses such as "Safeway Fuel Station" are separate brands.
Counts here come from the places directory. A traffic window for a given quarter can differ by a store or two, which is why the grocery penetration example on the same geography reports 83, 42 and 21.
2. The brand's catchment profile
Let the brand's own stores define what a Sprouts area looks like: no assumptions, just the measured profile of the block groups its 23 stores sit in. We use two dimensions from the metro's CBG data, visit volume over 2025 and the high-income visitor share (people_fraction_income_125k_and_above), each converted to a metro percentile.
Monthly CBG visits are about 2,000 rows per month for the metro, so we pull one month per request:
METRO = {"fieldName": "us_cbsa", "operator": "==",
"value": "Denver-Aurora-Lakewood, CO Metro Area"}
def pull(dataset, fields, dates):
rows = []
for d in dates:
rows += run_query(dataset, {
"fields": [{"name": "location_id"}] + fields,
"filters": [METRO, {"fieldName": "observation_start_date", "operator": "==", "value": d}],
"groupBy": [{"fieldName": "location_id"}],
"pageSize": 10000,
})
return pd.DataFrame(rows)
months = [f"2025-{m:02d}-01" for m in range(1, 13)]
quarters = ["2025-01-01", "2025-04-01", "2025-07-01", "2025-10-01"]
visits = pull("neighborhood_visitation.foot_traffic_month",
[{"name": "visits_sum", "aggregation": "SUM"}], months)
income = pull("neighborhood_visitation.census_visitor_demographics",
[{"name": "people_fraction_income_125k_and_above", "aggregation": "AVG"}], quarters)
cbg = pd.DataFrame({
"volume": visits.groupby("location_id")["visits_sum_SUM"].apply(lambda s: s.astype(float).sum()),
"income_hi": income.groupby("location_id")["people_fraction_income_125k_and_above_AVG"]
.apply(lambda s: s.astype(float).mean()),
}).dropna()
pct = cbg.rank(pct=True)
profile = pct.loc[pct.index.intersection(sprouts.census_code)]
v_floor = profile.volume.quantile(0.25)
i_floor = profile.income_hi.quantile(0.25)
print(f"floor: volume >= {v_floor:.0%} pct, high-income share >= {i_floor:.0%} pct")
Result (2025): 2,048 block groups with both measures. The matching rule is a floor: an area qualifies when it reaches at least the network's 25th percentile on both dimensions, which for Sprouts' 23 store CBGs means the 72nd metro percentile on volume and the 29th on high-income share. A floor rather than a band, because a catchment stronger than Sprouts' own is not a reason to exclude it.
3. Candidate areas
White space is the intersection of three conditions:
from math import radians, sin, cos, asin, sqrt
def miles(lat1, lon1, lat2, lon2):
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
a = sin((lat2 - lat1) / 2) ** 2 + cos(lat1) * cos(lat2) * sin((lon2 - lon1) / 2) ** 2
return 3958.8 * 2 * asin(sqrt(a))
# 1. the area matches the brand's catchment profile (the floor from step 2)
band = pct[pct.volume.ge(v_floor) & pct.income_hi.ge(i_floor)]
# 2. a competitor is present: demand for the category is proven, not assumed
in_band = competitors[competitors.census_code.isin(band.index)].copy()
# 3. the brand itself is absent: no own store within reach
in_band["dist"] = in_band.apply(lambda r: min(
miles(r.latitude, r.longitude, s.latitude, s.longitude)
for s in sprouts.itertuples()), axis=1)
cand = in_band[in_band.dist > 3] # miles, a parameter, not a truth
print(len(competitors), "competitor stores ->", len(in_band), "in profile areas ->", len(cand), "candidates")
Each surviving competitor store anchors a candidate area: the demand is validated by an operating grocer, the profile matches, and the brand is not there. The funnel on 2025 data: 126 competitor stores, 50 in profile-matching areas, 16 beyond three miles of any Sprouts.
4. Results
The candidates ranked by their area's visit volume:
cand["visits"] = cand.census_code.map(cbg.volume)
cand["income_hi"] = cand.census_code.map(cbg.income_hi)
print(cand.sort_values("visits", ascending=False)
[["brands", "street_address", "city", "dist", "visits", "income_hi"]]
.to_string(index=False))
| Anchored by | City | Miles to nearest Sprouts | 2025 CBG visits | High-income share |
|---|---|---|---|---|
| King Soopers, E Bromley Ln | Brighton | 9.1 | 9.1M | 27% |
| King Soopers, S Parker Rd | Aurora | 3.1 | 6.3M | 40% |
| Safeway, W Ken Caryl Ave | Littleton | 3.2 | 6.3M | 30% |
| Safeway, Jackson St | Golden | 4.8 | 4.5M | 30% |
| King Soopers and Safeway, W Alameda Pkwy | Lakewood | 3.1 | 4.2M | 26% |
| King Soopers, S Sheridan Blvd | Denver | 3.0 | 4.0M | 23% |
| King Soopers, Bergen Pkwy | Evergreen | 13.0 | 4.0M | 41% |
| King Soopers, E 104th Ave | Commerce City | 6.4 | 3.6M | 34% |
| Safeway, E Hampden Ave | Aurora | 3.2 | 3.4M | 30% |
| King Soopers, Wildcat Reserve Pkwy | Highlands Ranch | 3.3 | 3.1M | 49% |
How to read it: the top row is the pattern in one line. A King Soopers in Brighton anchors a block group with 9.1M visits in 2025, the largest catchment on the list, with a high-income share near the low end of it, 9 miles from the nearest Sprouts: a big, ordinary-income market the brand does not serve. Distance then separates the reads. The 3-mile candidates (Aurora Parker Rd, Littleton, Lakewood, Sheridan) are infill next to the existing network; Brighton and Evergreen at 9 and 13 miles are new-territory bets; Highlands Ranch, the highest-income candidate at 49%, sits last on volume. Two anchors in one Lakewood block group is one candidate, not two. Cross-check against the brand's own pipeline; a screen cannot see it.
5. Validate with measured leakage
The strongest evidence that a candidate is real is that the area's people already travel for the missing thing. visitor_journey shows it directly: for the anchoring competitor store, the share of its visitors' journeys that also touch a Sprouts elsewhere.
journeys = run_query("visitation_usa_places.visitor_journey", {
"fields": [
{"name": "location_id"},
{"name": "total_journey_fraction", "aggregation": "AVG"},
],
"filters": [
{"fieldName": "location_id", "operator": "in", "values": list(cand.location_id)},
{"fieldName": "related_brands", "operator": "==", "value": "Sprouts Farmers Market"},
{"fieldName": "observation_start_date", "operator": ">=", "value": "2025-10-01"},
{"fieldName": "observation_start_date", "operator": "<", "value": "2026-01-01"},
],
"groupBy": [{"fieldName": "location_id"}],
})
share = {r["location_id"]: float(r["total_journey_fraction_AVG"]) for r in journeys}
cand["sprouts_share"] = cand.location_id.map(share).fillna(0)
print(cand.sort_values("sprouts_share", ascending=False)
[["brands", "street_address", "city", "sprouts_share"]].to_string(index=False))
Rank the candidates by that share: the anchors whose visitors already combine trips with a Sprouts are the ones to lead with. Result, Q4 2025:
| Candidate anchor | Share of journeys touching a Sprouts |
|---|---|
| King Soopers, 25701 E Smoky Hill Rd, Aurora | 0.3% |
| The other 15 candidates | no measured journey to a Sprouts |
How to read it: this is the check doing its job. The signal is not missing from the market: on the same window, 220 places in the metro's grocery category, Sprouts' own stores included, show visitors combining trips with a Sprouts, most of them within a few miles of one. At 15 of the 16 candidates, chosen to be more than 3 miles from any Sprouts, that travel does not exist yet. The profile match says these areas look like the places Sprouts already works; the journeys say their people are not currently traveling for it. That makes the shortlist profile-matched but not demand-validated, and the one anchor with measured leakage, however small, is the one to lead with.
6. Reading the candidates
- Competitor anchoring inherits competitor siting. It validates demand but can only surface areas a conventional grocer has already chosen. For white space nobody serves yet, run the same profile match on all qualifying areas and lead with the ones with no grocer at all. The grocery penetration example runs that market-void variant on the same Denver geography and ranks by headroom rather than profile match, which is why its top areas differ from this list.
- The radius and the floor are parameters. Three miles suits a suburban specialty grocer; an urban small format wants one mile, a rural one ten. Set it from your own stores' measured trade areas. The floor, defined from the existing network, excludes by construction the candidates a strategy shift would target. Lower it deliberately.