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 with pip 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.

Where the observed trade area disagrees with the model The modelled fifteen-minute catchment is roughly symmetric around the store. The observed trade area, built from origin visit shares, extends further along one corridor and stops abruptly at a river with a single crossing — a barrier the drive-time model already knows about but underweights, because crossing it is possible and unpopular. The model says reachable; the panel says whether they come river · one crossing store modelled 15-min catchment observed trade area · 70% of visits North of the river the model claims reach and the panel records almost no visitors: the crossing is passable and nobody uses it for a weekly shop, which is a fact only observation provides.

Annotated implementation

The join runs in three parts: match places, compute shares, and dissolve the qualifying origin zones into a trade-area polygon.

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

How an estate matches to vendor places Of 4,610 stores, 3,918 match a vendor place by identifier, 402 match spatially within sixty metres, 156 map to a shopping-centre place shared with other tenants and need a decision, 84 match nothing, and 50 match more than one place. Only the first two categories can be used without a judgement call. Six per cent of an estate needs a human decision before it can be used identifier match 3,918 spatial match within 60 m 402 shared centre place 156 — decide how to attribute no match 84 — usually very new or very small stores multiple matches 50 — adjacent units, or a re-issued identifier Recording the match type per store lets every downstream analysis filter to the stores whose data it can actually trust, instead of discovering the shared-centre cases in a residual plot.

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.
Where the model and the panel agree, by format Overlap between the modelled fifteen-minute catchment and the observed seventy-per-cent trade area is 0.81 for suburban supermarkets, 0.74 for retail parks, 0.62 for high-street convenience and 0.48 for city-centre stores — where transit and pedestrian access make a drive-time model the wrong shape entirely. The drive-time model fits some formats far better than others suburban supermarket 0.81 retail park 0.74 high-street convenience 0.62 city centre 0.48 — model the wrong mode The city-centre figure is not a data-quality problem: it says those customers arrive on foot and by transit, so a car isochrone describes a population that is not the one shopping there. Overlap is intersection over union of the two polygons, in an equal-area projection.

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.

← Back to Mobility & Foot Traffic Data Integration