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.

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))
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