Detecting harsh braking and acceleration events
A fixed acceleration threshold on a GPS-derived speed series produces an event list dominated by measurement noise and by whichever vehicles happened to drive in cities. The three corrections that make the output defensible: make the threshold a function of speed, require the event to be sustained rather than instantaneous, and always report events per 100 km rather than as a count.
Why this happens
Acceleration is the second derivative of position, so it amplifies noise twice. A 3 m positional error at 1 Hz becomes a 3 m/s speed error and can become a 3 m/s² acceleration error — which is itself above most harsh-braking thresholds. Without smoothing and a duration requirement, the detector is largely reporting urban multipath.
The second problem is physical. Achievable deceleration falls with speed for real vehicles and rises for GPS noise, so a flat 3.5 m/s² threshold is unreachable at 90 km/h and trivially crossed by noise at 5 km/h in a car park. The envelope discussion in speed and acceleration profiling is the reason the threshold has to be speed-dependent.
Core pipeline
- Smooth the speed series before differentiating, or the detector measures the receiver rather than the driver.
- Apply a speed-dependent threshold derived from the achievable envelope for the vehicle class.
- Require the event to be sustained for a minimum duration, which removes the remaining impulsive noise.
- Normalise by exposure — events per 100 km, per vehicle, and ideally per road class.
Production-ready Python implementation
import numpy as np
import pandas as pd
from scipy.signal import savgol_filter
def detect_harsh_events(
df: pd.DataFrame,
time_col: str = "t",
speed_col: str = "speed_ms",
smooth_window: int = 5,
min_duration_s: float = 0.8,
a_ref: float = 3.6,
v_ref: float = 8.0,
) -> pd.DataFrame:
"""
Detect sustained harsh braking and acceleration events.
The threshold is a_ref at v_ref and falls with speed as
a_thresh(v) = a_ref * v_ref / max(v, v_ref), which approximates the
achievable envelope of a passenger vehicle and, just as importantly,
rises above the low-speed noise floor.
Parameters
----------
df : pd.DataFrame
One vehicle's fixes, tz-aware time_col, speed in m/s from PROJECTED
coordinates.
min_duration_s : float
An event must be sustained for this long. Single-sample spikes are
measurement artefacts, not driving.
Returns
-------
pd.DataFrame
One row per event: start, end, duration, peak acceleration, mean
speed, and the threshold it crossed.
Raises
------
ValueError
On missing columns, an empty frame, or an even smoothing window.
"""
required = {time_col, speed_col}
missing = required - set(df.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
if df.empty:
raise ValueError("Input DataFrame is empty.")
if smooth_window % 2 == 0:
raise ValueError("smooth_window must be odd.")
d = df.sort_values(time_col).reset_index(drop=True).copy()
t = d[time_col].to_numpy()
dt = np.r_[np.nan, np.diff(t).astype("timedelta64[ns]").astype(float) / 1e9]
if np.nanmedian(dt) <= 0:
raise ValueError("Non-positive sampling interval — fix the clock first.")
v = d[speed_col].to_numpy(dtype=float)
if len(v) >= smooth_window:
# Smooth the SPEED, not the acceleration: differentiating a smoothed
# signal preserves the event edge, smoothing a differentiated one
# spreads a spike across the whole window.
v = savgol_filter(v, smooth_window, 2, mode="interp")
a = np.gradient(v, np.nanmedian(dt))
thresh = a_ref * v_ref / np.maximum(v, v_ref)
over = np.abs(a) > thresh
d["accel_ms2"] = a
d["threshold_ms2"] = thresh
# ── group consecutive over-threshold samples into events ──────────
grp = (over != np.r_[False, over[:-1]]).cumsum()
events = []
for _, run in d[over].groupby(grp[over]):
dur = (run[time_col].iloc[-1] - run[time_col].iloc[0]).total_seconds()
if dur < min_duration_s:
continue # impulsive: an artefact, not a manoeuvre
peak = run["accel_ms2"].abs().max()
events.append({
"start": run[time_col].iloc[0],
"end": run[time_col].iloc[-1],
"duration_s": dur,
"kind": "braking" if run["accel_ms2"].mean() < 0 else "acceleration",
"peak_ms2": float(peak),
"mean_speed_ms": float(run[speed_col].mean()),
"threshold_ms2": float(run["threshold_ms2"].mean()),
})
return pd.DataFrame(events)
def events_per_100km(events: pd.DataFrame, distance_m: float) -> dict:
"""
Normalise the count by exposure. A raw count compares nothing: a van that
drove 400 km will out-score one that drove 40 no matter how it was driven.
"""
if distance_m <= 0:
raise ValueError("distance_m must be positive to normalise.")
km = distance_m / 1000.0
out = {"distance_km": km}
for kind in ("braking", "acceleration"):
n = int((events.get("kind", pd.Series(dtype=str)) == kind).sum())
out[f"{kind}_per_100km"] = round(n * 100.0 / km, 2)
return out
Validation block
def validate_events(events: pd.DataFrame, df: pd.DataFrame,
distance_m: float, speed_col: str = "speed_ms") -> None:
"""Assertions that catch a detector measuring noise rather than driving."""
rates = events_per_100km(events, distance_m)
# 1. Plausible rate. Above ~40 per 100 km the detector is firing on noise;
# below ~0.2 it is almost certainly never firing at all.
total = rates["braking_per_100km"] + rates["acceleration_per_100km"]
assert 0.2 < total < 40, f"{total:.1f} events per 100 km is not plausible driving"
# 2. No zero-speed events. Braking at a standstill is a receiver artefact.
if len(events):
assert (events["mean_speed_ms"] > 1.0).all(), (
"events detected below walking pace — the low-speed noise floor "
"is above the threshold; raise v_ref"
)
# 3. Durations must exceed the minimum by construction.
assert (events["duration_s"] >= 0.79).all(), "sub-threshold duration leaked through"
# 4. Speed must actually change across a braking event.
for _, e in events[events["kind"] == "braking"].head(20).iterrows():
win = df[(df["t"] >= e["start"]) & (df["t"] <= e["end"])][speed_col]
assert win.iloc[0] - win.iloc[-1] > 1.0, (
"braking event with no net speed loss — the derivative is noise"
)
print(f"OK — {len(events)} events, {total:.1f} per 100 km")
Common mistakes and gotchas
-
Smoothing the acceleration instead of the speed. Smoothing after differentiating spreads a spike across the window and blunts genuine events. Smooth the speed, then differentiate.
-
A flat threshold. Unreachable at motorway speed, trivially crossed by noise at walking pace. Make it a function of speed.
-
No duration requirement. A single sample above the threshold is almost always an artefact. Requiring 0.8 s typically removes 70–90% of raw crossings.
-
Reporting counts instead of rates. A count ranks drivers by kilometres driven, which nobody intends.
-
Ignoring the road-class mix. Urban driving genuinely produces more braking events. Adjusting for it is what turns the number into something about the driver.
-
Using device-reported speed uncritically. Doppler speed is excellent while moving and often frozen or zero at low speed, which manufactures large accelerations at the moment a vehicle pulls away. Cross-check against the derived speed.
FAQ
What threshold should I start with?
3.5–4.0 m/s² at around 30 km/h for passenger cars, falling with speed as in the code. For HGVs use 2.5–3.0; for cargo bikes 2.0. These are starting points to calibrate against a labelled sample, not values to ship — the achievable envelope depends on the vehicle and the surface.
Do I need accelerometer data?
It helps considerably and is not required. An IMU measures acceleration directly rather than by double differentiation, so the noise floor is far lower and lateral events become detectable too. If it is available, fuse it — see syncing asynchronous sensor timestamps, because a 200 ms misalignment moves an event onto the wrong road.
Should cornering count as a harsh event?
Lateral acceleration is a separate and useful signal, but it cannot be derived reliably from GPS at typical sampling rates — the heading change is too noisy. Detect it from an IMU if you have one, and if you do not, be explicit that the product measures longitudinal events only.
Related
- Speed & Acceleration Profiling — the parent reference and the achievable-envelope diagram.
- Calculating Instantaneous Speed from Discrete GPS Points — where the speed column comes from.
- Speed Percentile Profiles for Corridor Benchmarking — the corridor context these rates need.
- Kalman Filter vs Savitzky-Golay for Real-Time vs Batch GPS Smoothing — choosing the smoother this detector depends on.
- Map-Matching to Road Networks — the source of the road-class mix used for exposure adjustment.