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.3 threshold inside classify — 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