Detecting Engine Idle vs True Stops with Variance Windows
Sliding-window variance stop detection tells you that a vehicle is stationary, but not why. A van halted at a red light and a van parked for a delivery are spatially identical: both collapse the rolling positional variance to the receiver noise floor. Position can never separate them, and treating every intersection idle as a stop inflates stop counts, corrupts dwell analytics, and floods proof-of-delivery reports with phantom events. This page extends the variance detector with one extra channel — an ignition line or an OBD-II RPM reading — and a single copy-paste classifier that labels each stationary window as an engine idle or a true stop.
The rule is a fusion, not a replacement: the positional-variance gate still anchors the decision to genuine stillness, and the engine channel only disambiguates the reason for that stillness. Keeping both guards against the two ways a single signal misleads — a coasting vehicle with ignition off, or a parked vehicle running its cab climate control mid-delivery.
Compatibility and Configuration Requirements
| Requirement | Value / version | Notes |
|---|---|---|
| Python | 3.10+ | Matches the parent variance detector |
| pandas | 2.0+ | Time-based rolling window offsets |
| numpy | 1.24+ | Vectorised window statistics |
| Coordinate format | easting/northing in metres (UTM) | Variance is in m²; project before use |
| Engine channel | ignition bool OR OBD-II RPM (int) | At least one; RPM preferred as it separates idle from off |
| Channel cadence | resampled onto the shared GPS UTC grid | Forward-fill within MAX_GAP; do not bridge long gaps |
| Idle RPM band | ~600-1000 RPM warm idle | Set idle_rpm_max just above it (1100-1300) |
| Time index | monotonic UTC, deduplicated | Same prerequisite as the sliding-window parent |
If your OBD-II integration reports RPM, prefer it: it distinguishes engine off from engine idling from driving, whereas a bare ignition line only separates on from off. When only ignition is available, pass rpm_col=None and the classifier falls back to ignition state.
The Classifier
The class below consumes a GPS grid that already carries easting, northing, and an engine channel (ignition and/or RPM), and returns per-sample labels plus segmented stop events tagged true_stop or idle. Every parameter is commented at its point of use.
import numpy as np
import pandas as pd
class IdleVsStopClassifier:
"""
Separate engine idling from true stops by fusing rolling positional
variance with an ignition / OBD-II RPM channel.
Parameters
----------
window_seconds : int
Length of the rolling window in seconds. Must match the dwell you
want to resolve; shorter reacts faster but estimates variance from
fewer samples. Typical: 60.
variance_threshold_m2 : float
Trace of positional covariance (var_east + var_north) below which the
window counts as spatially still. Raise it in urban canyons where the
GPS noise floor is larger; lower it after Kalman smoothing. Typical: 30.
idle_speed_kmh : float
Rolling mean speed below which motion is considered negligible. Acts as
a cheap secondary gate alongside the variance test. Typical: 3.0.
idle_rpm_max : int
RPM at or below which the engine is treated as idling rather than
driving. Set just above your fleet's warm idle band. Typical: 1200.
min_stop_seconds : int
A stationary run shorter than this is discarded entirely (jitter or a
single signal cycle). A true_stop must also last at least this long.
Typical: 45.
hysteresis_m2 : float
Extra margin added to variance_threshold_m2 to LEAVE the stationary
state. Prevents chattering when variance hovers near the threshold.
Typical: 30 (so exit at threshold + hysteresis).
"""
def __init__(
self,
window_seconds: int = 60,
variance_threshold_m2: float = 30.0,
idle_speed_kmh: float = 3.0,
idle_rpm_max: int = 1200,
min_stop_seconds: int = 45,
hysteresis_m2: float = 30.0,
):
self.window = f"{window_seconds}s"
self.var_enter = variance_threshold_m2
self.var_exit = variance_threshold_m2 + hysteresis_m2
self.idle_speed = idle_speed_kmh
self.idle_rpm_max = idle_rpm_max
self.min_stop = pd.Timedelta(seconds=min_stop_seconds)
def _rolling_signals(self, grid: pd.DataFrame) -> pd.DataFrame:
"""Positional variance, mean speed, and engine-active fraction."""
pos_var = (
grid["easting"].rolling(self.window, min_periods=4).var()
+ grid["northing"].rolling(self.window, min_periods=4).var()
)
step = np.hypot(grid["easting"].diff(), grid["northing"].diff())
dt = grid.index.to_series().diff().dt.total_seconds()
speed_kmh = (step / dt * 3.6).replace([np.inf, -np.inf], np.nan)
mean_speed = speed_kmh.rolling(self.window, min_periods=4).mean()
# engine_active == 1 when the engine is doing more than idling
if "rpm" in grid and grid["rpm"].notna().any():
active = (grid["rpm"] > self.idle_rpm_max).astype(float)
engine_on = (grid["rpm"] > 0).astype(float)
else: # ignition-only fallback
active = grid["ignition"].astype(float)
engine_on = grid["ignition"].astype(float)
active_frac = active.rolling(self.window, min_periods=4).mean()
on_frac = engine_on.rolling(self.window, min_periods=4).mean()
return pd.DataFrame({
"pos_var": pos_var,
"mean_speed": mean_speed,
"active_frac": active_frac, # fraction of window above idle RPM
"on_frac": on_frac, # fraction of window with engine running
}, index=grid.index)
def _stationary_state(self, sig: pd.DataFrame) -> pd.Series:
"""Hysteresis-debounced stationary mask (spatial stillness only)."""
state = np.zeros(len(sig), dtype=bool)
stationary = False
for i in range(len(sig)):
pv, sp = sig["pos_var"].iat[i], sig["mean_speed"].iat[i]
if np.isnan(pv) or np.isnan(sp):
stationary = False # gap breaks the run
elif not stationary and pv < self.var_enter and sp < self.idle_speed:
stationary = True
elif stationary and (pv > self.var_exit or sp > self.idle_speed * 2):
stationary = False
state[i] = stationary
return pd.Series(state, index=sig.index, name="stationary")
def classify(self, grid: pd.DataFrame) -> pd.DataFrame:
"""
Return one row per stationary run, labelled 'true_stop' or 'idle'.
A run is a true_stop when it is spatially still for at least
min_stop_seconds AND the engine was mostly at/below idle (or off).
A run that stays still but keeps the engine above the idle band for a
meaningful share of the window is labelled 'idle'.
"""
sig = self._rolling_signals(grid)
stationary = self._stationary_state(sig)
run_id = (stationary != stationary.shift()).cumsum()
events = []
for _, idx in stationary.groupby(run_id):
if not idx.iloc[0]:
continue
run = sig.loc[idx.index]
arrival, departure = run.index[0], run.index[-1]
dwell = departure - arrival
if dwell < self.min_stop:
continue # too short to be either
# Engine mostly above idle for a real share of the run -> idling
mostly_active = run["active_frac"].mean() > 0.3
label = "idle" if mostly_active else "true_stop"
events.append({
"arrival_time": arrival,
"departure_time": departure,
"dwell_seconds": dwell.total_seconds(),
"centroid_easting": grid.loc[idx.index, "easting"].mean(),
"centroid_northing": grid.loc[idx.index, "northing"].mean(),
"mean_active_frac": round(float(run["active_frac"].mean()), 3),
"engine_on_frac": round(float(run["on_frac"].mean()), 3),
"label": label,
})
return pd.DataFrame(events)
Execution and Tuning
Run it on a resampled, projected grid that carries the engine channel:
clf = IdleVsStopClassifier(
window_seconds=60,
variance_threshold_m2=30.0,
idle_speed_kmh=3.0,
idle_rpm_max=1200,
min_stop_seconds=45,
)
events = clf.classify(grid) # grid has easting, northing, rpm and/or ignition
true_stops = events[events["label"] == "true_stop"]
Each knob has a predictable effect:
window_seconds— raise it to steady the variance estimate and suppress brief false stops, at the cost of merging stops closer together than the window and lagging the arrival timestamp by up to half the window. Lower it to catch short curbside drops.variance_threshold_m2— raise it where the GPS noise floor is high (urban canyons, cheap receivers) so genuine stops are not missed; lower it after Kalman filtering shrinks the noise, which tightens precision without fragmenting stops.idle_speed_kmh— the secondary motion gate. Lower it for couriers who creep slowly; raise it only if legitimate slow parking manoeuvres are being excluded.idle_rpm_max— the single most important knob for the idle/stop split. Set it just above your fleet’s warm idle band. Too low and gentle throttle blips during a delivery read as “active” and mislabel a true stop as idle; too high and pulling away from a light is not registered as engine engagement.min_stop_seconds— raise it above your local traffic-signal cycle so a long red light cannot be promoted to a stop even if the engine briefly cuts (start-stop systems); lower it only if you must capture very brief drops and accept more idle noise.mean_active_frac > 0.3threshold insideclassify— the share of the window the engine must spend above idle to be called idling. Raise toward 0.5 if delivery drivers routinely leave the engine running (refrigerated cargo, cab heating) and you still want those counted as true stops; lower it to be stricter about what qualifies as a stop.
Common Pitfalls
Start-stop engine systems flip RPM to zero at a red light
Modern vehicles cut the engine at idle, so a red light can show RPM = 0 exactly like a parked delivery. Because RPM = 0 reads as “engine off”, the classifier would label the light as a true_stop. Guard against it with min_stop_seconds set above your signal-cycle length, and by checking net centroid displacement after the light: a true stop stays put while an intersection resumes motion within a window or two. Where start-stop is common, prefer an ignition-key or door-event channel over RPM for the final true-stop confirmation.
Refrigerated or climate-controlled vehicles idle through the whole delivery
Reefer units and cab heating keep the engine running above idle for the entire stop, so mean_active_frac climbs and a genuine delivery is mislabelled idle. Raise the mean_active_frac cut toward 0.5, or better, combine the label with the stationary duration: a spatially still run lasting several minutes is a stop regardless of engine state, whereas idling at a light is bounded by the signal cycle. Feeding both the duration and the active fraction into confidence scoring for stop detection is more robust than a single hard cut.
Engine channel is sampled on a different clock than GPS
OBD-II RPM and GPS fixes frequently arrive on separate cadences and clocks. Joining them without aligning to a common UTC grid mismatches engine state to position, so a window’s active_frac no longer describes the same interval as its pos_var. Resample both onto the shared grid and forward-fill the engine channel only within MAX_GAP; never bridge a long gap, and reset the run across it. This is the same timestamp-alignment discipline the sliding-window parent depends on.
Up: Sliding-Window Variance Stop Detection | Stop Detection & Dwell Time Analytics
Related
- Sliding-Window Variance Stop Detection — the parent detector that produces the stationary runs this page labels
- Confidence scoring for stop detection — combine active fraction and duration into a graded stop confidence instead of a hard idle/stop cut
- Time-window-based dwell calculation — compute reportable dwell from the true-stop arrival and departure timestamps
- Kalman filtering for GPS noise reduction — smoothing upstream lets you lower the variance threshold and sharpen the idle/stop boundary