Stop Detection & Dwell-Time Modelling for Synthetic Traces

Almost nothing downstream consumes a raw sequence of coordinates. Trip chaining, demand estimation, dwell analytics, occupancy forecasting and most commercial mobility products operate on stops, which means a synthetic trace is judged on a derived quantity rather than on the coordinates it actually contains. This page is part of Trajectory & Movement Simulation, and it covers the stage that produces that quantity: where an agent stops, for how long, and whether a detector run over the result recovers what the generator intended.

The framing that matters is that a stop is not something a trace has. It is something a detector finds, and the same trace yields different stops under different detector parameters. A generator that does not know which detector its consumers use is generating for an unknown grader.

Problem Framing: The Round Trip Nobody Closes

Every other stage in this area can be validated against what it produced. Stops cannot, because the thing consumers see is the output of a detector the generator does not own.

That makes the correct validation a round trip: generate a set of intended stops, render them into a trace with realistic noise and sampling, run a standard detector over the result, and compare the recovered stops against the intended ones. The comparison has two error rates, and both matter.

  • Missed stops — an intended dwell the detector did not find, usually because it was shorter than the detector’s minimum duration or because the noise during the dwell exceeded its radius.
  • Phantom stops — a stop the detector found where the generator intended movement, usually at a signalised junction, in congestion, or wherever the noise model happened to produce a stationary-looking cluster.
Stop recall and phantom rate against the detector's minimum-duration parameter The horizontal axis is the minimum dwell duration the detector requires before it will call a cluster a stop, from thirty seconds to fifteen minutes. Two curves share a percentage axis. Recall — the share of the generator's intended stops the detector recovered — starts high and falls steadily as the minimum rises, because each increase excludes another band of short dwells, and the network-caused stops are excluded first because they are the shortest. The phantom rate — the share of detected stops that correspond to no intended stop at all — also falls, because a longer minimum stops brief stationary-looking windows in moving segments from qualifying. Both falling together is the awkward part: there is no crossing point to sit at, and the parameter that maximises recall is the one that maximises phantoms. Shaded bands mark a ninety-two per cent recall floor and a ten per cent phantom ceiling, and their overlap is the usable window. The note underneath draws the conclusion the chart exists for: neither error rate is a property of the trace or of the detector alone, so the generator has to place its dwells such that a reasonable detector parameter lands inside that window, and the release has to say which parameter it was validated against. The usable window is narrow — widening it is the generator's job 60 180 300 600 900 0% 25% 50% 75% 100% detector minimum dwell duration (s) rate (%) usable: 60–180 s 92% recall floor 10% phantom ceiling stop recall phantom rate 900 simulated journeys, 9 m correlated noise, 10 s reporting interval, fixed seed. Neither rate belongs to the trace or the detector alone: the generator has to place its dwells so a reasonable parameter lands inside the window, and the release has to name the parameter it was validated against.
A round trip over 4,000 simulated journeys: the two error rates move in opposite directions as the detector's minimum-duration parameter changes, and the generator controls where the curves sit.

The chart makes the interaction concrete. Neither error rate is a property of the trace alone or the detector alone: they are a property of the pair, and the generator’s job is to place its stops so that a reasonable detector recovers them under reasonable parameters.

Prerequisites & Toolchain

numpy==1.26.4
geopandas==0.14.4
scikit-learn==1.5.0      # DBSCAN, for the density-based detector family

Two decisions belong in the data contract before any code is written. The dwell distribution — what durations stops are drawn from, per activity type — and the detector contract: which detector, with which parameters, the release is expected to be graded by. The second is unusual and it is the important one. A release that does not name a reference detector cannot be validated on the quantity its consumers actually use.

Core Concept: Dwell Duration Is Bimodal, and the Modes Have Different Causes

Fitting a single distribution to observed dwell times produces a generator that is wrong in a specific, consequential way. Real dwell durations are at least bimodal:

  • A short mode of seconds to a couple of minutes, produced by the network rather than by intent — traffic signals, congestion, giving way, a passenger boarding.
  • A long mode of minutes to hours, produced by activities — work, shopping, a delivery, a meal.

These have different generators, different spatial distributions and different consequences if got wrong. Short stops occur at junctions and are largely a function of the network; long stops occur at activity locations and are a function of the agent’s schedule. A single fitted log-normal reproduces neither, and the mixture it produces places activity-length dwells at traffic signals.

python
import math


def draw_dwell(rng, activity: str) -> float:
    """Seconds. Two components with different causes, drawn separately."""
    if activity == "network":
        # signal, give-way, congestion: an exponential tail, capped at a cycle
        return min(120.0, rng.exponential(1 / 0.06))
    params = {                     # (median seconds, log-sd)
        "delivery": (240, 0.55),
        "errand": (900, 0.70),
        "work": (7 * 3600, 0.35),
        "meal": (2700, 0.45),
    }[activity]
    median, sigma = params
    return math.exp(math.log(median) + rng.normal(0, sigma))

The separation also makes the generator honest about something the single-distribution version hides: the short mode is not a modelling choice at all. It is a consequence of the network and the traffic state, and if the simulation has those, the short stops should emerge rather than be drawn.

Two dwell components against a single fitted distribution, on a log-duration axis Dwell duration runs along a logarithmic horizontal axis from one second to about four hours. Two filled histograms show the components. The network-caused component, in the accent colour, occupies seconds to about two minutes and is what traffic signals, giving way and congestion produce; it is a function of the network rather than of anybody's intent. The activity-caused component, in the primary colour, occupies minutes to hours and comes from the agent's schedule. Between them is a gap where real dwells are comparatively rare. An outline shows a single log-normal fitted to the combination of the two: it reproduces the overall spread reasonably and places substantial mass squarely in the gap, which is the problem. A generator drawing from the fitted distribution produces dwells of five or ten minutes and, having no notion of which component they belong to, places them at whichever location comes next — which is how work-length dwells end up at traffic signals. The note underneath adds the consequence that follows: because the short component is caused by the network, a simulation that models signals and congestion should let it emerge rather than drawing it at all. A single fitted distribution fills the gap between two different causes 1 s 10 s 2 min 17 min 3 h 1 10 100 1,000 10,000 dwell duration (seconds, log scale) dwells (log scale) the fitted curve fills this network-caused (signals, give-way, congestion) activity-caused (from the schedule) one log-normal fitted to both 12,000 dwells, fixed seed. The short component is caused by the network, not chosen — so a simulation that models signals and congestion should let it emerge rather than drawing it, and a generator that draws from the fitted curve places work-length dwells at traffic signals.
The two components, drawn separately and then combined: a single fitted distribution reproduces the aggregate histogram and places work-length dwells at traffic signals.

Step-by-Step Implementation

Step 1 — Place activity stops from the schedule, not from the path

python
def schedule_stops(rng, anchors: list, day_start: float) -> list[dict]:
    """Stops come from the agent's plan; the path is what connects them."""
    t = day_start
    out = []
    for anchor in anchors:
        dwell = draw_dwell(rng, anchor["activity"])
        out.append({
            "location": anchor["location"],
            "activity": anchor["activity"],
            "arrive": t,
            "depart": t + dwell,
            "intended": True,
        })
        t += dwell + anchor["travel_to_next"]
    return out

Generating stops from the schedule and routing between them, rather than generating a path and inserting stops along it, is what keeps the stop set consistent with the trip-chaining structure a consumer will reconstruct. The reverse order produces agents whose stop sequence does not correspond to any plausible day.

Step 2 — Render each dwell as fixes, not as a gap

python
def render_dwell(rng, stop: dict, interval: float, sigma: float) -> list[dict]:
    """A stationary agent still emits fixes, and they still carry error."""
    fixes = []
    t = stop["arrive"]
    x, y = stop["location"]
    while t < stop["depart"]:
        fixes.append({"t": t,
                      "x": x + rng.normal(0, sigma),
                      "y": y + rng.normal(0, sigma)})
        t += interval
    return fixes

Rendering a dwell as a gap in the trace — nothing between arrival and departure — is a common shortcut and it destroys the round trip: a density-based detector has no cluster to find, and the stop is reported as missing. It also produces a trace no receiver would emit, since a stationary device keeps reporting.

Step 3 — Make the noise during a dwell match the noise in motion

The noise applied during a dwell has to come from the same model as the noise applied to the moving segments, and specifically it has to carry the same correlation structure — see noise injection and stochastic drift. Independent per-fix noise during a stop produces a cluster whose radius is the noise sigma; correlated drift produces a cluster that wanders, and it is the wandering one that a detector actually has to cope with.

python
def render_dwell_correlated(rng, stop, interval, sigma, rho=0.94):
    fixes, t = [], stop["arrive"]
    ex = ey = 0.0
    x, y = stop["location"]
    while t < stop["depart"]:
        ex = rho * ex + math.sqrt(1 - rho ** 2) * rng.normal(0, sigma)
        ey = rho * ey + math.sqrt(1 - rho ** 2) * rng.normal(0, sigma)
        fixes.append({"t": t, "x": x + ex, "y": y + ey})
        t += interval
    return fixes

Step 4 — Run the reference detector and record what it found

python
from sklearn.cluster import DBSCAN
import numpy as np


def detect_stops(fixes: list[dict], eps_m: float = 35.0, min_seconds: float = 180.0,
                 interval: float = 10.0) -> list[dict]:
    """A standard density-based detector, parameterised as the contract declares."""
    xy = np.array([[f["x"], f["y"]] for f in fixes])
    min_samples = max(2, int(min_seconds / interval))
    labels = DBSCAN(eps=eps_m, min_samples=min_samples).fit_predict(xy)
    out = []
    for label in sorted(set(labels) - {-1}):
        idx = np.flatnonzero(labels == label)
        out.append({
            "arrive": fixes[idx[0]]["t"],
            "depart": fixes[idx[-1]]["t"],
            "location": (float(xy[idx, 0].mean()), float(xy[idx, 1].mean())),
        })
    return out

Recording the detector and its parameters alongside the release is the part that makes this a contract rather than a convention. A consumer using different parameters will recover different stops, and the release note should say what it was validated against.

Validation & Testing

python
def match_stops(intended: list[dict], recovered: list[dict],
                radius_m: float = 60.0, seconds: float = 300.0) -> dict:
    """Greedy match on space and time, then count both error kinds."""
    unmatched = list(recovered)
    hits = 0
    for want in intended:
        best = None
        for got in unmatched:
            d = math.dist(want["location"], got["location"])
            dt = abs(got["arrive"] - want["arrive"])
            if d <= radius_m and dt <= seconds:
                best = got
                break
        if best:
            unmatched.remove(best)
            hits += 1
    return {
        "recall": hits / max(len(intended), 1),
        "phantom_rate": len(unmatched) / max(len(recovered), 1),
        "missed": len(intended) - hits,
        "phantom": len(unmatched),
    }


def test_stop_roundtrip(traces, contract):
    agg = [match_stops(t["intended"], detect_stops(t["fixes"], **contract)) for t in traces]
    recall = sum(a["recall"] for a in agg) / len(agg)
    phantom = sum(a["phantom_rate"] for a in agg) / len(agg)
    assert recall >= 0.92, f"stop recall {recall:.1%}"
    assert phantom <= 0.10, f"phantom rate {phantom:.1%}"
Seven stop-contract clauses and what each one's absence makes uncheckable Seven rows. The dwell distribution per activity type decides what durations exist; without it there is no expectation to validate recovered stops against. The activity anchor set decides where stops are placed; without it, stop locations cannot be checked for plausibility. The reporting interval decides how many fixes a dwell contains; without it, the detector's minimum-samples parameter cannot be derived. The noise sigma and the noise correlation together decide how far a stationary agent's fixes wander; without either, the detector radius cannot be sized and dwells split unpredictably. The reference detector, its radius and its minimum duration are the grading contract; without them the release has been validated against an unstated standard and every consumer using different parameters gets a different answer. A closing note records the unusual thing about this list: five of the seven are ordinary generator parameters, and the last two are a declaration about somebody else's software — which is what makes a stop release different from every other kind. Five generator parameters and two declarations about somebody else's software dwell distribution per activity generator without it: no expectation to validate against activity anchor set generator without it: stop locations cannot be checked reporting interval generator without it: min-samples cannot be derived noise sigma generator without it: the detector radius cannot be sized noise correlation ρ generator without it: dwells split unpredictably reference detector + radius declaration without it: validated against an unstated standard reference minimum duration declaration without it: every consumer gets a different answer The last two rows are what make a stop release unusual: the contract has to name the software the release expects to be graded by, because the quantity consumers see is that software's output rather than the trace itself.
Five generator parameters and two declarations about somebody else's software — which is what makes a stop release unusual.

Two thresholds rather than one, because a generator can trivially maximise either alone: making every dwell an hour long drives recall to one, and making the trace never stationary drives the phantom rate to zero.

Performance & Scale Considerations

Detection is the expensive part, and it is quadratic in the fixes per track for a naive density scan. Two things keep it affordable. Run the detector per track rather than over the pooled fix set — stops are a within-track property and pooling produces cross-agent clusters that mean nothing. And exploit the time ordering: a stop is a contiguous run of fixes, so a linear scan with a rolling radius test finds candidate windows in one pass and the density scan only has to run inside them.

Rendering dwells also multiplies the trace size. A day with eight hours of work dwell at a ten-second interval is nearly three thousand fixes for one stop, which will dominate the artifact. Where consumers do not need full-rate stationary data, declare a reduced dwell interval in the contract and apply it consistently — but reduce it, rather than removing the fixes, so the detector still has a cluster.

Stops Are Where the Privacy Risk Concentrates

Everything above is about utility. There is a second reason stops deserve a stage of their own, and for some releases it is the more important one.

Movement traces are re-identifiable primarily through their anchors. A trajectory’s shape is shared with thousands of other people travelling the same corridors; its stops are not. A small number of long dwells — a home, a workplace — identifies an individual with high probability, and the identification survives coordinate perturbation, because the anchor is recoverable from a cluster of fixes rather than from any single one. Adding noise to every fix moves the cluster’s members and barely moves its centre.

That has three consequences for how the stage is built.

The first is that stop locations need a different privacy treatment from the path between them. Perturbing the whole trace uniformly spends budget on the segments that carry little risk and applies too little to the ones that carry nearly all of it. Spatial generalisation applied to anchors specifically — snapping long dwells to a declared cell size, with a k-anonymity floor on how many agents share a cell — is far more effective per unit of utility lost.

The second is that dwell duration is itself identifying. An agent with an eight-hour dwell starting at 08:15 and a second at 18:40 has a schedule, and schedules are close to unique over a few weeks. Where a release covers multiple days for the same agents, the anchor-and-schedule combination is the disclosure risk to model, not the coordinates.

The third is that the ground-truth stop layer is the most sensitive artifact the pipeline produces. It is exactly the thing an attacker would want, and shipping it alongside the trace — useful as it is for validation — means shipping the anchors in their most usable form. Ship it to internal validation, and think carefully before including it in an external release.

Failure Modes & Troubleshooting

  • Recall is high and the phantom rate is too. The dwell radius and the noise sigma are close enough that moving segments produce clusters. Reduce the noise during motion, or raise the detector’s radius and re-check recall.
  • Short stops are all missed. The dwell durations are below the detector’s minimum duration. This is the contract’s problem, not the generator’s: either the release should not claim short stops, or the reference detector’s parameters are wrong for it.
  • Stops are recovered in the right place at the wrong time. The dwell rendering starts at the arrival timestamp but the routing put the agent there later. Generate stops from the schedule and derive travel times from routing, rather than the reverse.
  • Phantom stops cluster at junctions. Expected, and the reason the short dwell mode exists. If the simulation models signals, the phantoms are not phantoms; label them as network stops and score them separately.
  • Every dwell has an identical cluster radius. Independent per-fix noise. Use the correlated model, or a detector will separate synthetic from real traces on this alone.
What if consumers use different detectors from each other?

They will. The contract should name one reference detector and its parameters, and the release should report its scores against that reference — but the more useful thing to publish alongside is a small sensitivity table: recall and unexplained rate at two or three nearby parameter settings. A consumer whose own parameters sit inside that range can read off roughly what to expect, and one whose parameters sit far outside it can see that they are outside it, which is the more important signal.

Do stops need to be reproducible byte-for-byte?

The generated stops do, like everything else — same seed, same schedule, same dwells. The detected stops do not, and expecting them to is a mistake: detection is somebody else’s software and its version will move. What should be reproducible is the score, given a pinned detector version, and that means the detector version belongs in the validation record alongside its parameters.

Frequently Asked Questions

Should the release contain the intended stops as an attribute?

As a separate, clearly labelled layer — yes, and it is valuable. As an attribute on the fixes — no. Consumers who have it on the fixes will use it instead of detecting, which means the release is never exercised the way real data would be, and the first time it is used with a real detector nobody knows how it behaves. Ship the ground truth beside the trace, and validate against detection rather than against the label.

How many stops should a synthetic day contain?

Take the distribution from the population being modelled, and take the per-agent distribution rather than the aggregate. Stop counts are strongly bimodal in most populations — a commuting mode with two or three anchors, an errand-running mode with many more — and a generator drawing from a single unimodal distribution reproduces the total while getting every individual day wrong.

Should dwells be generated before or after routing?

Before. The schedule decides where the agent needs to be and for how long; routing then fills in the travel between anchors and returns a duration, which the schedule uses to place the next arrival. Generating a path first and inserting dwells along it produces agents whose stop sequence corresponds to no plausible day, and it makes the trip-chaining structure a consumer reconstructs disagree with the one the generator intended.

Is a stop with no fixes during it ever acceptable?

Only when the device genuinely stopped reporting, which does happen — a power saver, a tunnel, a deliberate duty cycle. Model it as an outage with its own flag rather than as a dwell, because a detector cannot recover a stop from an absence and a consumer cannot tell the two apart.