Detecting Harsh Braking Events from Speed Profiles

This page extends the speed profiling cluster with the differentiation step most fleet safety programs need next: turning a clean speed_kmh_smoothed series into discrete, debounced harsh-braking and harsh-acceleration events for driver scoring. The distinction from ordinary velocity analysis matters because acceleration is the second derivative of position — every noise-amplification problem that affects instantaneous speed is squared again when computing its derivative, which makes smoothing order and sampling-rate awareness far less optional here than upstream in the pipeline.


Harsh Braking Event Detection Top panel shows a smoothed speed curve dipping sharply around one point in time. Bottom panel shows the corresponding acceleration series crossing below a dashed deceleration threshold line during that dip, with the crossing region highlighted as a flagged event. SPEED PROFILE (smoothed) braking window ACCELERATION (dv/dt) decel_threshold_ms2 flagged event

Compatibility & Configuration Requirements

Requirement Minimum version / value Notes
Python 3.10
numpy 1.24 vectorized differencing and rolling statistics
pandas 2.0 .diff() / .rolling() semantics used throughout
Input columns timestamp (UTC, tz-aware), speed_ms must be the smoothed output of the speed profiling pipeline, not raw haversine-derived speed
Sampling rate ≥ 1 Hz recommended below 1 Hz, short braking events are averaged out — see pitfalls
Units metres, seconds, m/s² convert speed_kmh_smoothed to m/s before use: speed_ms = speed_kmh / 3.6

Production-Ready Implementation

The class below computes acceleration from an already-smoothed speed series, flags samples past a deceleration threshold, and debounces consecutive flags into discrete events with a severity grade.

A real brake and a fake one look the same to a threshold A speed trace containing a genuine deceleration from 52 to 14 kilometres per hour over four seconds, and a single-fix drop caused by a position spike. Both cross the minus three metres per second squared threshold. Only the real event has a matching recovery profile and a sustained deceleration over more than one sample. Speed over 40 s, one real event and one artefact real braking −3.4 m/s² over 4 s artefact −18 m/s² for one sample, then back 52 14 0 Require the deceleration to hold across at least two consecutive intervals — a physical brake cannot act inside one sample. Also reject events whose magnitude exceeds what the vehicle can physically produce; −18 m/s² is not a driving behaviour. These events reach driver scorecards, so a false positive is not a metric error — it is an argument with a named person.
import numpy as np
import pandas as pd
from dataclasses import dataclass
from typing import List


@dataclass
class HarshEvent:
    event_type: str          # 'harsh_braking' or 'harsh_acceleration'
    start_time: pd.Timestamp
    end_time: pd.Timestamp
    peak_accel_ms2: float    # most negative (braking) or most positive value
    severity: str            # 'moderate' or 'severe'
    n_samples: int


class HarshEventDetector:
    """
    Detect discrete harsh braking / harsh acceleration events from a
    smoothed GPS speed profile.

    Parameters
    ----------
    decel_threshold_ms2 : float
        Deceleration magnitude (negative, m/s^2) at or below which a
        sample is a harsh-braking candidate. -3.0 m/s^2 (~0.3g) is a
        common fleet-safety starting point.
    accel_threshold_ms2 : float
        Acceleration magnitude (positive, m/s^2) at or above which a
        sample is a harsh-acceleration candidate. 2.5 m/s^2 is a
        reasonable default for loaded commercial vehicles.
    severe_decel_ms2 : float
        Deceleration magnitude beyond which an event is graded 'severe'
        rather than 'moderate'. -5.0 m/s^2 (~0.5g) approaches emergency
        braking for a laden vehicle.
    smoothing_window : int
        Rolling-mean window (samples) applied to speed_ms before
        differentiation. 3 is the minimum that meaningfully suppresses
        GPS jitter without eroding genuine short braking events.
    min_event_gap_s : float
        Minimum gap between flagged samples before they are treated as
        two separate events rather than one continuous braking action.
        2.0 s absorbs the natural multi-sample tail of a single hard
        stop without merging genuinely distinct events.
    """

    def __init__(
        self,
        decel_threshold_ms2: float = -3.0,
        accel_threshold_ms2: float = 2.5,
        severe_decel_ms2: float = -5.0,
        smoothing_window: int = 3,
        min_event_gap_s: float = 2.0,
    ):
        self.decel_threshold_ms2 = decel_threshold_ms2
        self.accel_threshold_ms2 = accel_threshold_ms2
        self.severe_decel_ms2 = severe_decel_ms2
        self.smoothing_window = smoothing_window
        self.min_event_gap_s = min_event_gap_s

    def _smooth_speed(self, df: pd.DataFrame) -> pd.Series:
        """Rolling-mean smoothing applied before differentiation, never after."""
        return (
            df["speed_ms"]
            .rolling(window=self.smoothing_window, center=True, min_periods=1)
            .mean()
        )

    def compute_acceleration(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Return a copy of df with 'speed_smoothed_ms' and 'accel_ms2' added.

        Expected output shape: same row count; first row has NaN
        acceleration (no predecessor).
        """
        df = df.sort_values("timestamp").copy()
        df["speed_smoothed_ms"] = self._smooth_speed(df)

        dt_s = df["timestamp"].diff().dt.total_seconds()
        dv_ms = df["speed_smoothed_ms"].diff()

        df["accel_ms2"] = dv_ms / dt_s.replace(0, np.nan)
        return df

    def detect_events(self, df: pd.DataFrame) -> List[HarshEvent]:
        """
        Full pipeline: smooth, differentiate, threshold, debounce, grade.

        Parameters
        ----------
        df : DataFrame with 'timestamp' (UTC) and 'speed_ms' for a single
             device / single trip.

        Returns
        -------
        List of HarshEvent, one per debounced braking or acceleration
        incident, ordered by start_time.
        """
        df = self.compute_acceleration(df)

        braking_flag = df["accel_ms2"] <= self.decel_threshold_ms2
        accel_flag = df["accel_ms2"] >= self.accel_threshold_ms2

        events: List[HarshEvent] = []
        events.extend(self._debounce(df, braking_flag, "harsh_braking"))
        events.extend(self._debounce(df, accel_flag, "harsh_acceleration"))
        events.sort(key=lambda e: e.start_time)
        return events

    def _debounce(
        self, df: pd.DataFrame, flag: pd.Series, event_type: str
    ) -> List[HarshEvent]:
        """
        Merge consecutive flagged rows separated by less than
        min_event_gap_s into a single event.
        """
        flagged = df[flag].copy()
        if flagged.empty:
            return []

        gap = flagged["timestamp"].diff().dt.total_seconds()
        new_event = (gap.isna()) | (gap > self.min_event_gap_s)
        flagged["_event_id"] = new_event.cumsum()

        results: List[HarshEvent] = []
        for _, group in flagged.groupby("_event_id"):
            peak = (
                group["accel_ms2"].min()
                if event_type == "harsh_braking"
                else group["accel_ms2"].max()
            )
            severity = (
                "severe"
                if event_type == "harsh_braking" and peak <= self.severe_decel_ms2
                else "moderate"
            )
            results.append(
                HarshEvent(
                    event_type=event_type,
                    start_time=group["timestamp"].iloc[0],
                    end_time=group["timestamp"].iloc[-1],
                    peak_accel_ms2=float(peak),
                    severity=severity,
                    n_samples=len(group),
                )
            )
        return results

Execution & Tuning Guidelines

detector = HarshEventDetector(
    decel_threshold_ms2=-3.0,
    accel_threshold_ms2=2.5,
    smoothing_window=3,
    min_event_gap_s=2.0,
)
events = detector.detect_events(trip_df)
for e in events:
    print(e.event_type, e.severity, e.start_time, round(e.peak_accel_ms2, 2))
One threshold cannot serve three vehicle classes Events per hundred kilometres against the deceleration threshold for a cargo bike, a van and a laden articulated truck. At minus three metres per second squared the bike records forty events per hundred kilometres and the articulated truck records two, because the truck physically cannot decelerate that hard when laden. Events per 100 km against the deceleration threshold 60 30 0 cargo bike van articulated, laden −3.0 m/s² −2.0 −3.0 −4.0 A fleet-wide threshold makes cyclists look reckless and articulated drivers look flawless, and neither reflects how they drive. Set the threshold per class so the baseline event rate is comparable, then compare drivers within a class.
Parameter Default Effect of raising magnitude Effect of lowering magnitude
decel_threshold_ms2 -3.0 m/s² Fewer, harsher events flagged; misses moderate braking More events flagged, including ordinary traffic deceleration
smoothing_window 3 samples Flatter acceleration curve; short, genuine hard-stops can be averaged below threshold Preserves sharp transients; more susceptible to GPS-noise false positives
min_event_gap_s 2.0 s Merges more nearby flagged samples into one event; may combine two distinct stops Reports more discrete events for a single continuous braking action
severe_decel_ms2 -5.0 m/s² Fewer events graded severe More events graded severe, including moderate braking

Feed events output into a driver-scoring aggregation keyed by vehicle_id and shift, and cross-reference peak timestamps against heading synchronization output — a harsh-braking event that coincides with a sharp heading change is far more likely to indicate a genuine evasive manoeuvre than one on a straight road segment.

Common Pitfalls

GPS noise produces false-positive events

Differentiating an unsmoothed or under-smoothed speed series turns ordinary positional jitter into apparent deceleration spikes well past -3 m/s². The fix is strict pipeline ordering: smooth first, differentiate second, threshold third. compute_acceleration above enforces this order internally, but if you are feeding in your own speed_ms column, verify it has already passed through the speed profiling Savitzky-Golay stage — raw haversine-derived speed at 1 Hz commonly produces implied decelerations of 5–10 m/s² from GPS jitter alone at rest.

Detection sensitivity depends on sampling rate

Numerical differentiation averages a physical event across the time step it is computed over. A genuinely hard 0.5-second brake application registers a much larger peak deceleration at 5 Hz sampling than the same event does at 1 Hz, where the same energy is smeared across a full second. Fleets with mixed device sampling rates need per-device threshold calibration, or a minimum sampling-rate floor (1 Hz recommended) enforced upstream before events are compared across the fleet.

Downhill coasting is indistinguishable from braking

Speed alone cannot separate gravity-assisted deceleration on a downgrade from active brake application — both produce a negative accel_ms2 value that can exceed threshold. This detector will flag both identically. Where road grade or elevation data is available, filter flagged events against a grade threshold before scoring; where an OBD-II brake-pedal signal is available, use it as ground truth and treat this detector’s output as a GPS-only fallback for vehicles without CAN bus access, not a hard signal in isolation.


Up: Speed Profiling from Raw GPS Coordinates | Trajectory Analysis & Map Matching Techniques