Back
GuidesVoid AnalysisMapping Sprouts' white space in Denver
Python
USA

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 byCityMiles to nearest Sprouts2025 CBG visitsHigh-income share
King Soopers, E Bromley LnBrighton9.19.1M27%
King Soopers, S Parker RdAurora3.16.3M40%
Safeway, W Ken Caryl AveLittleton3.26.3M30%
Safeway, Jackson StGolden4.84.5M30%
King Soopers and Safeway, W Alameda PkwyLakewood3.14.2M26%
King Soopers, S Sheridan BlvdDenver3.04.0M23%
King Soopers, Bergen PkwyEvergreen13.04.0M41%
King Soopers, E 104th AveCommerce City6.43.6M34%
Safeway, E Hampden AveAurora3.23.4M30%
King Soopers, Wildcat Reserve PkwyHighlands Ranch3.33.1M49%

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 anchorShare of journeys touching a Sprouts
King Soopers, 25701 E Smoky Hill Rd, Aurora0.3%
The other 15 candidatesno 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.
Did you find what you were looking for?