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.

Location typing data flow from stop centroids and a POI database to a typed stop Stop centroids and a POI database feed a spatial index, which drives a radius join; the join output is scored by combined dwell and fuzzy confidence, producing a typed stop record. Stop centroids (lat, lon, dwell_s) POI database name, category, geom Spatial index STRtree (sindex) Radius join buffer + intersects Confidence dwell + fuzzy Typed stop location_type

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.

Nearest centroid picks the wrong site A stop centroid in a shared retail park. The nearest point-of-interest centroid belongs to a fuel station 34 metres away, but the vehicle was at the supermarket's goods entrance 51 metres away on the far side of the building. Matching to the building footprint and its service entrance, rather than to a centroid, resolves it. supermarket footprint POI centroid goods entrance fuel station POI centroid stop centroid 34 m — nearest 51 m — correct Nearest-centroid matching is a distance query pretending to be a semantic one, and retail parks break it several times a day. Match against footprints where you have them, weight by dwell duration and vehicle class, and keep the runner-up for review. A forty-minute dwell by an articulated truck is not a fuel stop, whatever the metres say.

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.

Rebuilding the index without pausing lookups A weekly job pulls a fresh point-of-interest extract, normalizes categories, builds an R-tree, and writes it as a new immutable snapshot. Lookups continue against the previous snapshot until the new one passes a fitness check, at which point a pointer swap makes it live and the old snapshot is retained for rollback. Weekly rebuild, zero-downtime swap POI extract OSM + commercial normalize categories, names build R-tree snapshot 2026-08-03 fitness check match rate on last week pass → swap pointer live snapshot pointer previous kept for rollback Gate the swap on a real metric: a snapshot whose match rate drops five points has lost a data source, not gained fresh data. Immutable snapshots also make a stop's location type reproducible months later, which any billing dispute will ask for.

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