Location Typing & POI Matching for Stops
A stop detector hands you geometry and duration: a centroid at (lat, lon) and a dwell of, say, eleven minutes. That is enough to count stops, but not to bill a customer, audit a delivery SLA, or flag an unauthorised detour. Location typing is the stage that turns an anonymous coordinate cluster into a named business event — a warehouse dock, a retail delivery point, a fuel stop, or idling on a residential kerb. Get it right and every dwell metric downstream inherits a meaning; get it wrong and the whole analytics stack reports confident nonsense.
The naive approach — snap each stop to its nearest point of interest — fails on contact with real fleet data. A vehicle parked on a motorway hard shoulder will happily match a petrol station 400 m away across the carriageway; a two-minute wait at a barrier gets typed as a customer visit. Robust typing instead treats matching as a filtered, weighted, and disambiguated join: spatial plausibility from a radius constraint, temporal plausibility from dwell duration, and identity plausibility from fuzzy resolution against what you already know about the site. This guide builds that pipeline in Python as part of the broader Stop Detection & Dwell Time Analytics framework.
Why naive matching fails
Three failure modes recur often enough to be worth naming before any code is written, because each maps to a specific defence in the workflow below.
Unconstrained proximity. Nearest-neighbour joins have no concept of “no match.” Every stop is assigned a label, so highway-shoulder stops, red-light pauses, and mis-clustered noise all acquire spurious POIs. The defence is a hard radius filter: candidates outside the buffer simply do not exist, and a stop with no candidate stays unclassified.
Temporal blindness. Space alone cannot separate a legitimate visit from a transient pause. A 45-second stop touching a retail polygon is almost certainly a queue, not a delivery. The defence is dwell weighting: duration is a first-class input to the confidence score, and short stops are gated out before they can be typed.
Identity collision. In dense areas the radius will often contain several POIs — a fuel station abutting a convenience store abutting a fast-food outlet. Choosing the geometrically closest one is a coin flip. The defence is fuzzy and semantic resolution against a manifest or customer list, so the label is driven by what the vehicle was there to do, not by a few metres of GPS jitter.
Prerequisites
Confirm the following before building the pipeline; each gap here produces silent, hard-to-trace errors downstream.
Python environment. Python 3.10+, geopandas ≥ 0.14, shapely ≥ 2.0, pandas ≥ 2.0, pyproj ≥ 3.5, and rapidfuzz ≥ 3.0 for string similarity. Shapely 2.x is required — its vectorised STRtree backs the spatial index that makes fleet-scale joins tractable.
Stop centroids. One representative geometry per detected stop, typically from DBSCAN stop clustering. Weight centroids toward the densest ping cluster rather than a plain arithmetic mean, so a single drifting fix cannot drag the representative point across a property boundary. Each stop must carry a dwell_seconds field from time-window dwell calculation.
POI database. A structured table where every record carries at minimum geometry, name, category (or OSM amenity), and address. Sources include OpenStreetMap extracts, commercial datasets, or a proprietary customer network. For OSM-derived categories, follow the OSM amenity tagging scheme so your category map stays aligned with upstream values.
Consistent CRS. Fleet fixes normally arrive in EPSG:4326 (WGS84). Distance and buffer operations are only valid in a metric projection, so plan to project into a local UTM zone via CRS mapping for fleet data. The pyproj documentation covers axis-order conventions, which are the usual source of silent 90-degree errors.
Pre-smoothed coordinates. Match filtered positions, not raw pings. Urban-canyon multipath jitter of 20-40 m is enough to push a centroid out of a target polygon; apply Kalman filtering upstream so the centroid you type is physically plausible.
Step-by-step workflow
The pipeline is a deterministic sequence. Each step narrows the candidate set and raises the evidence bar, so every match is traceable back through radius, dwell, and similarity evidence.
Step 1 — Normalise and index geometries
Load both datasets into GeoDataFrame objects with explicit CRS metadata, then build the spatial index on the POI table. A mismatched or missing CRS is the single most common cause of silent distance errors, so it is asserted here rather than assumed.
import geopandas as gpd
def load_and_index(stops_path: str, pois_path: str, crs: str = "EPSG:4326"):
"""Load stops and POIs, enforce a shared CRS, and warm the POI index."""
stops_gdf = gpd.read_parquet(stops_path).set_geometry("geometry")
pois_gdf = gpd.read_parquet(pois_path).set_geometry("geometry")
# Enforce a single, shared geographic CRS before any spatial op.
stops_gdf = stops_gdf.to_crs(crs)
pois_gdf = pois_gdf.to_crs(crs)
assert stops_gdf.crs == pois_gdf.crs, "CRS mismatch between stops and POIs"
# Accessing .sindex builds a Shapely 2.x STRtree on the POI table.
_ = pois_gdf.sindex
return stops_gdf, pois_gdf
Building the index on the POI side turns each stop lookup from an O(n) scan into an O(log n) tree descent. Documented behaviour for the geometry engine lives in the Shapely STRtree reference; GeoPandas wires this in automatically the first time .sindex is touched.
Expected output shape: two GeoDataFrame objects sharing one CRS, with a warm spatial index on pois_gdf. No new columns.
Step 2 — Buffer stops and run a radius spatial join
Project to a metric CRS, buffer each centroid by a density-appropriate radius, and run an intersects join. Buffering in EPSG:4326 is a classic error: its units are degrees, so a “100” buffer means 100 degrees, not metres. Project first.
def radius_join(stops_gdf, pois_gdf, radius_m: float = 100.0, metric_crs: str = "EPSG:32633"):
"""Match each stop to POIs within radius_m metres via a buffered sjoin."""
stops_m = stops_gdf.to_crs(metric_crs)
pois_m = pois_gdf.to_crs(metric_crs)
# Buffer in metres, then join POIs that intersect each stop's disc.
stops_buf = stops_m.copy()
stops_buf["geometry"] = stops_m.geometry.buffer(radius_m)
matched = gpd.sjoin(
stops_buf,
pois_m,
how="left",
predicate="intersects",
)
return matched
A 50-150 m radius captures legitimate site entrances while rejecting cross-road noise; tighten to 30-60 m in dense retail and widen to 100-200 m for industrial parks with set-back gatehouses. See the geopandas spatial-join guide for predicate semantics. Stops with no POI inside the buffer retain a null index_right — keep those rows; they are your honest unclassified set.
Expected output shape: one row per stop-POI pair, plus one null-join row for every stop with no candidate. Columns include the stop fields, dwell_seconds, and the joined POI name, category, and index_right.
Step 3 — Weight candidates by dwell duration
Space says could have visited; dwell says plausibly did. Combine a base spatial score with a bounded dwell term, and gate anything below a minimum dwell to zero so brief pauses cannot be typed at all. This is the same confidence philosophy used in confidence scoring for stop detection, applied to the match rather than the stop.
import numpy as np
MIN_DWELL_SECONDS = 180 # 3 minutes: below this, treat as a transient pause
BASE_SPATIAL_SCORE = 0.7 # confidence earned by being inside the radius
MAX_DWELL_BONUS = 0.3 # dwell can lift confidence by at most this much
def dwell_weighted_confidence(matched):
"""Vectorised confidence in [0, 1] from radius membership and dwell time."""
dwell = matched["dwell_seconds"].to_numpy(dtype=float)
has_candidate = matched["index_right"].notna().to_numpy()
# A full hour of dwell saturates the bonus; scale linearly up to the cap.
dwell_bonus = np.clip(dwell / 3600.0, 0.0, MAX_DWELL_BONUS)
score = np.where(has_candidate, BASE_SPATIAL_SCORE + dwell_bonus, 0.0)
# Gate short stops and no-candidate rows to zero confidence.
score = np.where(dwell < MIN_DWELL_SECONDS, 0.0, score)
matched = matched.copy()
matched["confidence"] = score
return matched
Vectorising with NumPy rather than a row-wise .apply keeps this step viable across millions of daily rows. Multi-day stops, overnight parking, and split shifts should have been resolved by time-window dwell calculation before they reach this function, so dwell_seconds already reflects true on-site time.
Expected output shape: the joined frame plus a float confidence column in [0, 1].
Step 4 — Resolve ambiguity with fuzzy and semantic matching
When several POIs share a radius, pick by identity, not distance. Fuzzy-match each candidate name against the address or customer name the manifest expected at this stop, using rapidfuzz token-set scoring so word order and extra tokens do not sink a valid match.
from rapidfuzz import process, fuzz
FUZZY_CUTOFF = 80 # 0-100 token-set ratio required to accept a name
CONFIDENCE_FLOOR = 0.5 # below this, do not attempt resolution
def resolve_candidates(group, expected_name: str):
"""Pick one POI name for a stop's candidate group, or a sentinel label."""
if group["confidence"].max() < CONFIDENCE_FLOOR:
return "unclassified"
names = group["name"].dropna().tolist()
if not names:
return "unclassified"
if len(names) == 1:
return names[0]
# Multiple candidates: disambiguate against the expected manifest name.
best = process.extractOne(expected_name or "", names, scorer=fuzz.token_set_ratio)
if best is not None and best[1] >= FUZZY_CUTOFF:
return best[0]
return "ambiguous"
Group the matched frame by stop id and apply this resolver with the manifest name for each stop. A result of ambiguous is deliberately distinct from unclassified: the former means “we found candidates but could not choose,” which is an operations signal, while the latter means “nothing plausible was here.” For commercial datasets with category weighting, historical visitation priors, and API fallbacks, see matching GPS stops to commercial POI databases in Python.
Expected output shape: one resolved poi_name per stop id, drawn from {a real name, "ambiguous", "unclassified"}.
Step 5 — Emit a deterministic typed-stop schema
The final step standardises output for billing, compliance, and analytics. Map raw categories to a controlled vocabulary, filter to the confidence floor, attach audit metadata, and write a stable schema. Downstream consumers depend on these column names and types, so treat the schema as an interface.
import pandas as pd
CATEGORY_MAP = {
"warehouse": "depot",
"fuel": "fuel_stop",
"supermarket": "retail_delivery",
"restaurant": "food_service",
}
def emit_typed_stops(matched, out_path: str):
"""Filter to the confidence floor and write a stable typed-stop schema."""
typed = matched[matched["confidence"] >= CONFIDENCE_FLOOR].copy()
typed = typed.assign(
location_type=typed["category"].map(CATEGORY_MAP).fillna("other"),
is_verified=typed["confidence"] >= 0.85,
matched_at=pd.Timestamp.now(tz="UTC"),
)
columns = [
"stop_id", "poi_name", "location_type",
"confidence", "is_verified", "dwell_seconds", "matched_at",
]
typed[columns].to_parquet(out_path, index=False)
return typed
Log the confidence distribution per run. If the median falls below 0.6 across a fleet segment, or the unclassified rate climbs, investigate POI staleness, GPS hardware degradation, or a CRS regression before trusting the labels. That distribution is your earliest and cheapest staleness alarm.
Expected output shape: a Parquet file with one row per typed stop and a fixed column set; unclassified and low-confidence stops are excluded from this artifact but should be counted in monitoring.
Engineering considerations for scale
Memory at fleet scale. A national fleet emits millions of stops per day, and a buffered intersects join can multiply rows sharply where POIs are dense. Partition by day or by vehicle_id and stream partitions to disk rather than materialising the whole cross-product. Where single-node geopandas bottlenecks, dask-geopandas distributes the same STRtree join across workers with an identical predicate.
Radius calibration per class. A Class 8 tractor stops differently from a sprinter van — larger vehicles park further from entrances. Parameterise the radius, dwell floor, and fuzzy cutoff in configuration, and calibrate each against ground-truth driver logs per vehicle class rather than hard-coding a single value.
Mixed-use facilities. A single coordinate frequently maps to several categories. Rather than forcing one label, keep a primary_category for billing and a secondary_amenities array for compliance, storing every candidate above the confidence floor. Collapsing that structure too early is irreversible and loses the exact evidence audits later demand.
Operational troubleshooting
POI database staleness silently degrades match rates
Cause: Commercial sites open, close, rebrand, and relocate. A POI table more than a quarter old drifts out of sync with the ground truth your vehicles actually see.
Symptom: The unclassified rate rises over time, median confidence sags, and specific cells that vehicles route through daily record zero POI visits.
Fix: Schedule a quarterly POI refresh. Reconcile matched stops against historical visitation; flag any POI with zero visits over 90 days for review, and any new high-dwell unclassified cluster as a candidate for a missing POI.
Mixed-use facility resolves to the wrong single label
Cause: A truck stop offering fuel, food, and maintenance is collapsed to one category, so a maintenance visit is billed as a fuel stop.
Symptom: Category counts for a known multi-service site look implausibly skewed toward whichever POI sits closest to the parking geometry.
Fix: Retain every candidate above the confidence floor as a JSON array. Expose primary_category for billing and secondary_amenities for compliance instead of a single scalar label, and let each consumer select the relevant entry.
GPS drift pushes a centroid across the radius boundary
Cause: Urban-canyon multipath or foliage shifts fixes by 20-40 m, moving a centroid just outside a target polygon’s buffer so a real visit is missed.
Symptom: A regular customer site intermittently returns unclassified for stops that clearly occurred, with no change to the POI table.
Fix: Match Kalman-filtered coordinates, never raw pings — apply Kalman filtering upstream. Where drift is chronic, widen the radius modestly for that region and lean harder on dwell and fuzzy evidence to keep precision.
CRS mismatch turns the buffer into nonsense
Cause: Buffering while still in EPSG:4326 treats the radius as degrees, so a “100 m” disc becomes continent-sized, or an axis-order swap places points in the wrong hemisphere.
Symptom: Either almost every stop matches almost every POI, or nothing matches at all; match counts are wildly off in one direction.
Fix: Assert a shared CRS after load, project to a metric CRS before buffer, and confirm axis order per the pyproj docs. Add a sanity check that buffered areas fall within an expected metre-scale range.
Fuzzy matcher accepts a false name match
Cause: The similarity cutoff is too low, so "Shell Depot Road" matches "Depot Road Cafe" on shared tokens.
Symptom: Confident labels that contradict the manifest; billing disputes trace back to plausible-looking but wrong POI names.
Fix: Raise FUZZY_CUTOFF toward 85-90 for short, token-poor names, and prefer token_sort_ratio where word order is meaningful. Require agreement between fuzzy score and category prior before accepting, and route disagreements to ambiguous for review.
Row explosion exhausts memory on the buffered join
Cause: A wide radius over a dense POI region produces a many-to-many join whose row count is far larger than the stop count, and the whole frame is held in RAM.
Symptom: Worker OOM kills; peak resident memory scales super-linearly with POI density rather than with stop count.
Fix: Partition the join by day or vehicle_id, stream each partition to disk, and tighten the radius in dense cells. Move to dask-geopandas when a single node cannot hold one partition’s join comfortably.
Deployment checklist
Parent topic: Stop Detection & Dwell Time Analytics
Related
- Matching GPS stops to commercial POI databases in Python — commercial-grade attribution with category weighting, visitation priors, and API fallbacks
- DBSCAN for fleet stop clustering — produces the density-weighted centroids this pipeline types
- Time-window based dwell calculation — supplies the dwell_seconds that drive confidence weighting
- Confidence scoring for stop detection — the scoring philosophy applied here to the match rather than the stop
- Kalman filtering for GPS noise reduction — upstream smoothing that keeps centroids inside their true POI polygons