Dwell-time detection for stationary assets
“How long was the vehicle at the depot?” is a question about absence of movement, and it is considerably harder to answer than “did the vehicle enter the depot?”. A stationary receiver still produces a moving position; a vehicle idling at a gate is stopped for operational purposes and moving for the geometry; and an asset parked half in and half out of a zone produces a dwell that starts and ends every few seconds. This guide builds a dwell detector that survives all three, inside threshold-based event mapping and the Local Spatial Processing Patterns guide.
Defining a dwell before detecting one
A dwell has three parameters and every deployment argues about them until they are written down.
The stationary radius is how far the asset may move and still be considered stopped. It has to exceed the receiver’s noise — a parked vehicle’s reported position wanders several metres — and it has to be smaller than the smallest movement that means something. For a yard, 15 m; for a parking bay, 8 m; for a container in a stack, 3 m with a corrected receiver.
The minimum duration is how long the asset must remain inside that radius before the dwell is real. This filters traffic lights out of “stopped at a customer” and is the parameter that most changes the resulting counts: at 30 seconds a delivery route produces hundreds of dwells, at 5 minutes it produces the dozen that correspond to actual stops.
The break tolerance is how far and how long the asset may leave before the dwell is considered ended rather than interrupted. A vehicle repositioning at a loading bay is still dwelling; one that leaves for ten minutes is not.
The detector
# dwell.py — stationary-period detector with break tolerance.
# Constant memory: one anchor, one candidate window. No history buffer.
# Consumes smoothed positions (see the trajectory guide) — raw jitter would
# defeat the radius test on any tight setting.
import math
from dataclasses import dataclass
@dataclass
class DwellConfig:
radius_m: float = 15.0
min_duration_s: float = 300.0
break_tolerance_s: float = 90.0
break_radius_m: float = 60.0 # how far it may stray during a break
@dataclass
class Dwell:
started_s: float
ended_s: float
centre_east: float
centre_north: float
samples: int
@property
def duration_s(self) -> float:
return self.ended_s - self.started_s
class DwellDetector:
"""Emits a Dwell when a stationary period ends and qualified.
States: MOVING → CANDIDATE → DWELLING → (BREAK) → DWELLING | end."""
__slots__ = ("cfg", "anchor", "start_s", "last_s", "n",
"break_started_s", "sum_e", "sum_n")
def __init__(self, cfg: DwellConfig):
self.cfg = cfg
self.anchor = None # (east, north) of the current cluster
self.start_s = self.last_s = 0.0
self.n = 0
self.break_started_s = None
self.sum_e = self.sum_n = 0.0
def update(self, east: float, north: float, t: float) -> Dwell | None:
cfg = self.cfg
if self.anchor is None:
self._begin(east, north, t)
return None
d = math.hypot(east - self.anchor[0], north - self.anchor[1])
if d <= cfg.radius_m:
# Inside the cluster: extend, and clear any break in progress.
self.break_started_s = None
self.last_s = t
self.n += 1
self.sum_e += east
self.sum_n += north
# Slow-drift the anchor toward the cluster centroid so a gradual
# settle does not eventually leave the radius behind.
self.anchor = (self.sum_e / self.n, self.sum_n / self.n)
return None
if d <= cfg.break_radius_m:
# Outside the cluster but nearby: a break, not necessarily an end.
if self.break_started_s is None:
self.break_started_s = t
elif t - self.break_started_s <= cfg.break_tolerance_s:
return None # still tolerable
else:
return self._close_and_restart(east, north, t)
return None
# Clearly gone.
return self._close_and_restart(east, north, t)
def _begin(self, east, north, t):
self.anchor = (east, north)
self.start_s = self.last_s = t
self.n = 1
self.sum_e, self.sum_n = east, north
self.break_started_s = None
def _close_and_restart(self, east, north, t) -> Dwell | None:
duration = self.last_s - self.start_s
emitted = None
if duration >= self.cfg.min_duration_s:
emitted = Dwell(self.start_s, self.last_s,
self.sum_e / self.n, self.sum_n / self.n, self.n)
self._begin(east, north, t)
return emitted
Two design choices carry most of the behaviour. The anchor drifts toward the centroid rather than staying at the first position, so an asset that settles gradually — a vehicle that rolls forward two metres over a minute — is not eventually pushed outside its own radius. And the break state distinguishes “moved nearby” from “left”, which is what absorbs repositioning without absorbing departure.
Constraint validation
| Constraint | Expected impact | Mitigation built into the code |
|---|---|---|
| RAM | A detector holding a window of positions scales with duration | Constant memory: one anchor, two sums, three timestamps |
| CPU | Runs on every fix | One distance calculation and a few comparisons, well under a microsecond |
| Receiver noise | Jitter defeats a tight radius | Consumes smoothed positions; the radius is set above the smoothed residual |
| Duty cycling | A node sampling every 15 minutes cannot resolve a 5-minute dwell | Minimum duration must exceed several sample intervals; otherwise report presence, not dwell |
| Gaps | An outage inside a dwell must not split it | A gap shorter than the break tolerance is treated as a break, not an end |
Gotchas and edge cases
- Dwell is not the same as being inside a zone. A vehicle can dwell in a car park across the road from the depot. If the question is “time at the depot”, intersect the dwell’s centroid with the zone; if it is “time stopped”, do not. Reporting one as the other is the most common misinterpretation of this data.
- A gap is not movement. When fixes stop arriving, the asset has not left — the device stopped observing. Treat a gap under the break tolerance as a break, a longer one as an end with the end time set to the last observed fix, and flag the dwell as gap-terminated so a consumer knows its duration is a lower bound.
- The anchor drift can walk. An asset moving slowly and steadily — a vessel drifting on a mooring, a vehicle in stop-start traffic — can keep every step inside the radius while the anchor migrates hundreds of metres. Cap the total anchor displacement since the dwell began, and close the dwell when the cap is exceeded.
- Duty-cycled nodes cannot dwell. At one fix per fifteen minutes there is no evidence about what happened between samples. Report “present at these times” rather than a dwell with a duration, because the duration would be a fabrication.
- Emit on close, not on qualification. A dwell that has met its minimum but not ended is still in progress, and emitting it early means either duplicating it later or reporting a duration that is not final. Emit once, on close, with the option of a separate “dwell in progress” state for a live display.
Integration
Run the detector after the smoothing stage and alongside the geofence tests, from the same position stream, and let both emit independently. A dwell and a zone crossing are different observations about the same interval, and correlating them upstream is easier and more auditable than deriving one from the other on the device.
Feed the emitted dwells into the event partition of the spool described in store-and-forward buffering, not the position partition. A dwell is a low-rate, high-value record: it survives the outage that discards positions, and it summarises hours of them in a few dozen bytes.
Deriving the parameters from recorded data
The three parameters are best derived from a week of recorded tracks rather than chosen. The exercise is short and it produces numbers the operations team can defend.
The radius comes from the stationary scatter. Take every period the vehicle’s own speed signal reports zero for more than two minutes, compute the 95th-percentile displacement from the period’s centroid, and set the radius to about 1.5 times that. On a typical uncorrected receiver in a yard that lands between 12 and 18 metres.
The minimum duration comes from the distribution of stop lengths. Plot a histogram of the durations produced with a very low minimum, and there is almost always a visible valley between the short stops — lights, junctions, queueing — and the operational ones. Set the minimum in that valley. For urban delivery it is usually between three and six minutes.
The break tolerance comes from repositioning behaviour. Measure how long vehicles spend moving between the arrival position and the final working position at a stop; the 90th percentile of that is the tolerance. Under a minute for a car park, two to three minutes for a loading bay with manoeuvring.
Re-run the exercise when the operation changes rather than on a schedule. A fleet that moves from urban delivery to trunking has a completely different stop distribution, and parameters tuned for the old pattern will produce a dwell count that looks plausible and means nothing.
Related
- Threshold-Based Event Mapping — the event framework this detector emits into.
- Configuring spatial thresholds for sensor event triggers — the hysteresis engine that shares this page’s tuning problem.
- Kalman filtering noisy GPS fixes on a gateway — the smoothing this detector assumes upstream.