Joining Mobile Visit Data to Store Trade Areas
This page solves one exact task: taking an aggregated origin-destination visit table from a mobility vendor, matching its places to your stores, and turning the origin shares into an observed trade-area polygon that can be compared against the modelled drive-time catchment.
The comparison is the point. A modelled catchment says where customers could come from; an observed one says where they did. Where the two disagree, one of them is telling you something — usually that a barrier, a competitor or a habit is shaping the market in a way the network model cannot see.
Prerequisites
- An aggregated visit table: place identifier, origin zone, period, visit count, with cells below the vendor’s threshold withheld.
- A place crosswalk or the means to build one, matching vendor places to your store identifiers.
- Origin-zone geometry — census block groups or tracts — in a projected CRS.
- Python packages:
geopandas,pandas,shapely. Install withpip install geopandas pandas shapely. - The parent context. Mobility and foot traffic data integration covers the panel properties that make ratios trustworthy and levels not.
Configuration and execution parameters
| Parameter | Value for this task | Notes |
|---|---|---|
match_strategy |
identifier, then spatial, then name | In that order; each is a fallback for the last |
spatial_match_radius_m |
60 | Vendor place points sit anywhere within a building |
min_period_months |
3 | Fewer months is noise at store level |
share_basis |
visits per origin, over total store visits | The robust quantity |
trade_area_threshold |
70% cumulative | The zones making up the primary trade area |
metric_crs |
EPSG:5070 | For area and distance |
suppressed_cells |
kept as null | Never zero-filled |
The share_basis row is the whole method in one line. Visit counts carry the panel’s coverage; visit shares by origin do not, because the coverage factor appears in both numerator and denominator. Every derived quantity here is built from shares for that reason.
Annotated implementation
The join runs in three parts: match places, compute shares, and dissolve the qualifying origin zones into a trade-area polygon.
from __future__ import annotations
import geopandas as gpd
import pandas as pd
from pyproj import CRS
METRIC_CRS = CRS.from_epsg(5070)
SPATIAL_RADIUS_M = 60.0
TRADE_AREA_THRESHOLD = 0.70
def match_places(stores: gpd.GeoDataFrame, places: gpd.GeoDataFrame,
crosswalk: pd.DataFrame | None = None) -> pd.DataFrame:
"""Identifier match first; spatial proximity only as a fallback."""
if crosswalk is not None and len(crosswalk):
matched = crosswalk[["store_id", "place_id"]].copy()
unmatched = stores.loc[~stores["store_id"].isin(matched["store_id"])]
else:
matched, unmatched = pd.DataFrame(columns=["store_id", "place_id"]), stores
if len(unmatched):
s = unmatched.to_crs(METRIC_CRS)
p = places.to_crs(METRIC_CRS)
near = gpd.sjoin_nearest(s[["store_id", "geometry"]],
p[["place_id", "geometry"]],
max_distance=SPATIAL_RADIUS_M, how="inner")
matched = pd.concat([matched, near[["store_id", "place_id"]]])
# A store matching several places, or a place matching several stores,
# is a real situation (shopping centres) and must be resolved explicitly.
dupes = matched["store_id"].duplicated(keep=False)
matched.loc[dupes, "needs_review"] = True
return matched.reset_index(drop=True)
def origin_shares(visits: pd.DataFrame, matched: pd.DataFrame) -> pd.DataFrame:
"""Share of a store's visits coming from each origin zone.
Shares are robust to panel coverage; counts are not, so everything
downstream is built from this rather than from visit totals."""
v = visits.merge(matched[["store_id", "place_id"]], on="place_id", how="inner")
v = v.dropna(subset=["visits"]) # suppressed cells stay out
totals = v.groupby("store_id")["visits"].transform("sum")
v["share"] = v["visits"] / totals
return v.sort_values(["store_id", "share"], ascending=[True, False])
def trade_area(shares: pd.DataFrame, zones: gpd.GeoDataFrame,
store_id: str) -> gpd.GeoDataFrame:
"""Dissolve the origin zones that make up the primary trade area."""
s = shares.loc[shares["store_id"] == store_id].copy()
s["cumulative"] = s["share"].cumsum()
keep = s.loc[s["cumulative"] <= TRADE_AREA_THRESHOLD, "origin_zone"]
sel = zones.loc[zones["zone_id"].isin(keep)].to_crs(METRIC_CRS)
dissolved = sel.dissolve().reset_index(drop=True)
dissolved["store_id"] = store_id
dissolved["zones"] = len(sel)
return dissolved
Marking multi-match cases for review rather than resolving them automatically is deliberate. A shopping centre containing two of your stores genuinely maps to one vendor place, and the right resolution — split the visits, attribute to one, exclude both — is a business decision that differs by case and should not be made by a default.
Failure modes and debugging
A place that is the whole centre. The vendor’s polygon covers a mall, so the visit count includes every tenant. Using it as your store’s footfall overstates by an order of magnitude, and the tell is a visit count wildly out of line with transactions. Where the vendor offers tenant-level places, use them; where it does not, treat the centre’s visits as a centre-level signal only.
Suppressed cells treated as zero. The single most damaging mistake in this pipeline. A rural origin zone whose cell is withheld becomes a zone contributing nothing, so the observed trade area shrinks toward the urban core and every downstream conclusion inherits the bias. Keeping suppressed cells as null and reporting the suppressed share alongside the result is the only honest handling.
Shares computed over an inconsistent denominator. If some origin zones are suppressed, the surviving shares sum to less than one, and normalising them to sum to one silently redistributes the missing visits to the zones that happened to survive. Report the shares as they are, with the unaccounted remainder visible.
A place that changed identity. Vendors retire and reissue place identifiers when a unit is refitted or renamed, which appears in the data as a store that closed and a new one that opened. Matching on identifier alone therefore loses history; a periodic spatial re-match against the crosswalk catches it.
Verification
- Compare the observed and modelled trade areas for a sample of stores, and read the disagreements. Systematic disagreements — always in one direction, always at a barrier — are findings; scattered ones are noise.
- Check the shares sum sensibly. Accounted share plus suppressed share should approach one; a large unexplained remainder means cells are being dropped somewhere in the join.
- Confirm the primary trade area contains the store. A trade area that does not include the store’s own zone is a place-match error.
- Re-run with a different threshold. A trade area that changes shape dramatically between a 60 and 80 per cent cumulative cut is dominated by a few zones, which is worth knowing before it is used.
Frequently Asked Questions
What threshold defines the primary trade area?
Seventy per cent of visits is the common convention and the number matters less than stating it. What matters more is reporting the shape of the curve: a store where seventy per cent of visits come from four zones is a very different business from one where the same share needs forty zones, and the cumulative curve says which. Carrying both the threshold polygon and the underlying shares means a later question about a different threshold is a filter rather than a rerun.
Should the observed trade area replace the modelled one in scoring?
For existing stores, it can and often should; for candidates, it cannot exist. That asymmetry is the reason both are kept: the model provides comparability across all candidates, and the observed areas provide the calibration that keeps the model honest. Substituting observed areas for existing stores while modelling candidates produces a ranking where the two groups are not measured the same way.
How do multi-tenant places affect competitor analysis?
More than they affect own-store analysis, because there is no transaction data to catch the error. A competitor inside a shopping centre may be indistinguishable from the centre itself in the vendor’s places, so its apparent footfall includes everyone. Flagging competitor places by type — standalone versus in-centre — before using their visit counts is the minimum guard, and treating in-centre competitors as a centre-level signal is usually the honest fallback.
What if the vendor’s origin zones do not match the census geography?
Convert once, deliberately, with an area or population-weighted crosswalk, and keep the conversion versioned. The temptation is to let each analysis do its own conversion on the fly, which produces slightly different answers from the same data and no way to reconcile them. One crosswalk, dated and stored, makes every downstream number comparable.
How stable is an observed trade area month to month?
Stable in shape and noisier in extent than most people expect. The zones contributing the largest shares are consistent — they are where the customers live — while the long tail of zones contributing under one per cent each turns over substantially between months, because a handful of visits moves a zone in and out of the threshold. Reporting the trade area from a rolling three-month window rather than a single month removes most of that churn without hiding a genuine seasonal shift.
What should be done with the zones that fall outside the threshold?
Keep them and report them as the secondary trade area rather than discarding them. Together they often account for a quarter of visits, and they are where growth and competitive leakage show up first — a store whose secondary area is expanding into a rival’s primary area is a story the primary polygon alone will never tell. Storing the full share table and treating any polygon as a view over it keeps both available.
Related
- Mobility & Foot Traffic Data Integration — panel properties and the pipeline this join sits in.
- Correcting Panel Bias in Mobility Datasets — turning shares into population-scaled estimates.
- Performing Point-in-Polygon Joins for Store Catchments — the spatial join discipline this inherits.
← Back to Mobility & Foot Traffic Data Integration