Splitting Trips on Ignition Cycles in Python
Almost everything a fleet reports is per trip: distance, duration, fuel, stops served, average speed. None of it means anything until the continuous stream of fixes has been cut into journeys, and the cut is not obvious. A vehicle that pauses for twenty minutes at a customer has either made one trip with a stop or two trips — and which one is right depends on what the number will be used for.
The ignition signal is the best available answer where it exists, because it records a decision the driver made rather than an inference from position. It is also noisier than it looks. This page covers turning it into stable trip boundaries, the fallback for vehicles that do not report it, and the day boundary that catches everybody once. It supports the dwell arithmetic in time-window based dwell calculation.
Compatibility and Configuration Requirements
| Requirement | Value | Notes |
|---|---|---|
| Ignition channel | Boolean, timestamped | From CAN, OBD-II or a hardwired input |
| Sampling | Event-driven preferred | A polled ignition state can miss a short cycle entirely |
| Debounce window | 90–300 s, by vehicle class | Stop-start systems need the upper end |
| Fallback | Motion-based split | Required for any vehicle without the channel |
| Operating day | Vehicle-local | The boundary rule depends on it |
The Splitter
from __future__ import annotations
import polars as pl
def split_trips(
fixes: pl.DataFrame,
debounce_s: int = 180,
fallback_gap_s: int = 900,
) -> pl.DataFrame:
"""Assign a trip_id to every fix, using ignition where available.
``fixes`` must be sorted by (vehicle_id, ts_utc) and may carry a nullable
``ignition`` boolean. Vehicles with no ignition data fall back to a
motion-and-gap rule and are flagged so the two are never silently mixed.
"""
has_ign = pl.col("ignition").is_not_null()
debounced = (
fixes.with_columns(
# A state change only counts once it has held for `debounce_s`.
pl.col("ignition").rle_id().over("vehicle_id").alias("_state_run")
)
.with_columns(
(
pl.col("ts_utc").max().over(["vehicle_id", "_state_run"])
- pl.col("ts_utc").min().over(["vehicle_id", "_state_run"])
).dt.total_seconds().alias("_state_s")
)
.with_columns(
pl.when(pl.col("_state_s") >= debounce_s)
.then(pl.col("ignition"))
.otherwise(None)
.forward_fill()
.over("vehicle_id")
.alias("ignition_stable")
)
)
return (
debounced.with_columns(
pl.when(has_ign)
# New trip on a confirmed off -> on transition.
.then(
(pl.col("ignition_stable") & ~pl.col("ignition_stable").shift(1).fill_null(False))
.cum_sum()
.over("vehicle_id")
)
# No ignition: cut on a long stationary gap instead.
.otherwise(
(pl.col("ts_utc").diff().dt.total_seconds().fill_null(0) > fallback_gap_s)
.cum_sum()
.over("vehicle_id")
)
.alias("trip_seq"),
pl.when(has_ign).then(pl.lit("ignition")).otherwise(pl.lit("motion")).alias("trip_method"),
)
.with_columns(
(pl.col("vehicle_id") + "-" + pl.col("trip_seq").cast(pl.Utf8)).alias("trip_id")
)
.drop(["_state_run", "_state_s"])
)
The trip_method column is not decoration. Motion-based splitting produces systematically more trips
than ignition-based splitting for the same driving, because a long queue looks like a trip boundary
and an engine left running does not. Any report that aggregates trip counts across both methods is
measuring the sensor mix, and the flag is what makes that visible.
Choosing the Debounce Window
The window has two constraints pulling in opposite directions, and the right value sits between them.
Long enough to absorb stop-start. A modern stop-start system cuts the engine at every red light, so anything under about ninety seconds will treat junctions as trip boundaries in city traffic.
Short enough to keep genuine short trips. A shunt move across a yard is a real trip for some fleets, and a five-minute debounce will merge it into whatever came before.
Derive it from the distribution of ignition-off durations rather than guessing. On almost every fleet that distribution is strongly bimodal — a cluster of very short cuts from stop-start and restarts, and a separate cluster of genuine stops — with a clear valley between them. The valley is the window.
off_durations = (
fixes.filter(~pl.col("ignition"))
.group_by(["vehicle_id", "_state_run"])
.agg((pl.col("ts_utc").max() - pl.col("ts_utc").min()).dt.total_seconds().alias("off_s"))
)
Re-derive it per vehicle class. A cargo bike has no ignition at all, a van with stop-start needs the upper end of the range, and a long-haul tractor without stop-start can use a much shorter window because its short cuts are rare.
The Day Boundary
A trip that starts at 23:40 and ends at 00:25 belongs to one operating day, and which one is a policy decision that has to be made once and applied everywhere. The convention that causes the least trouble is to assign a trip to the operating day it started in, because that matches how a driver and a dispatcher describe it.
trips = fixes.group_by("trip_id").agg(
pl.col("ts_utc").min().alias("started_utc"),
pl.col("ts_utc").max().alias("ended_utc"),
pl.col("operating_date").first().alias("operating_date"), # from the first fix
)
Taking operating_date from the first fix rather than recomputing it per row is what implements the
rule. Recomputing per row and then taking a mode or a maximum produces a trip that changes days
depending on where the majority of its fixes fell, which makes the count of trips per day unstable
between runs when a trip sits near midnight.
The same reasoning applies to the shift boundary if the fleet works to shifts rather than to calendar days. Whichever boundary is used, derive it in the vehicle’s local timezone — a fleet spread across two zones that partitions on UTC will cut a night shift for one of them, as covered in calculating accurate dwell times across timezone shifts.
Validating the Split
Three assertions catch nearly every splitting bug, and all three run on the output rather than the input.
Every fix belongs to exactly one trip. Trivially true if the implementation is a cumulative sum, and worth asserting because a refactor to a windowed join can break it silently.
Trip durations and distances have plausible distributions. A spike at exactly the debounce window means the window is manufacturing boundaries rather than finding them. A long tail of multi-day trips means the ignition-off event is being missed and trips never close.
Trip counts per vehicle-day are stable across reruns. A count that moves when the job is re-run over the same data means a boundary depends on the batch window — usually a trip near midnight being cut by the partition rather than by the vehicle.
assert fixes.select(pl.col("trip_id").is_null().sum()).item() == 0
assert trips.filter(pl.col("duration_s") > 86_400).height == 0, "unclosed trips — check ignition-off"
The second assertion has to be able to fire. Feed the splitter a vehicle whose ignition channel goes permanently high — which happens when a hardwired input fails — and it will, immediately.
Common Pitfalls Specific to This Technique
Splitting on the raw ignition signal. Produces a trip per junction on any modern vehicle and makes every per-trip metric meaningless.
Mixing ignition-split and motion-split trips in one aggregate. The two produce systematically different counts, so a fleet trend that spans a hardware rollout will show a step that is entirely an artefact.
Using UTC midnight as the day boundary. Cuts night shifts in half for any fleet not operating at UTC, and the effect is invisible until somebody compares a report against a depot’s own paperwork.
Treating a missing ignition channel as ignition-off. A null is not a false. Coercing it makes every vehicle without the channel look permanently parked, and the fallback path never runs.
Related
- Time-window based dwell calculation — parent topic, where trips become dwell events
- Detecting unclosed dwell events with timeout rules — the same never-closing failure at the dwell level
- Detecting engine idle versus true stops with variance windows — using the same ignition channel for a different question
- Calculating accurate dwell times across timezone shifts — deriving the operating day this page depends on
- Stop Detection & Dwell Time Analytics — parent section