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.
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.
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.
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.
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
defdraw_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 cyclereturnmin(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.
The two components, drawn separately and then combined: a single fitted distribution reproduces the aggregate histogram and places work-length dwells at traffic signals.
defschedule_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.
defrender_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.
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
defrender_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
from sklearn.cluster import DBSCAN
import numpy as np
defdetect_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 insorted(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.
defmatch_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 =0for want in intended:
best =Nonefor 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
breakif best:
unmatched.remove(best)
hits +=1return{"recall": hits /max(len(intended),1),"phantom_rate":len(unmatched)/max(len(recovered),1),"missed":len(intended)- hits,"phantom":len(unmatched),}deftest_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%}"
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.
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.
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.
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.
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.