1. Build a Baseline Occupant Kinematics Dataset for Airbag Deployment Prediction

Why a Baseline Dataset Comes First

Every prediction model in this course — deployment classifiers, injury-risk regressors, sequence models — consumes the same underlying object: a table of occupant kinematics signals aligned to a crash pulse, with a deployment outcome attached. If that table is inconsistent (mixed units, unaligned time bases, ambiguous labels), no amount of model tuning will recover the lost signal. This case builds that table once, correctly, and freezes its schema so later cases can extend it without renegotiating conventions.

The dataset has three logical layers: crash pulse (vehicle-level deceleration), occupant kinematics (derived acceleration, velocity, displacement of the occupant reference point), and deployment label (the outcome we will later predict). The critical engineering decision is that velocity and displacement are integrated from acceleration, not measured independently — so the integration convention (initial conditions, filtering, drift handling) is part of the dataset definition, not an afterthought.

Signal Chain and Integration Convention

The figure below shows the processing chain from raw accelerometer channel to the three stored kinematics signals, and where the deployment label attaches. Note that the label is attached at the record level, not per-sample: one crash event yields one label, while the signals are time series.

Raw channel a(t) [g] fs = 10 kHz Conditioning bias removal CFC 60 low-pass t0 = pulse onset Integration v(t)=∫a dt, v(0)=0 d(t)=∫v dt, d(0)=0 drift check Record event_id a, v, d arrays label Deployment label deployed / not from test record Stored time base (per record) t=0 t=200 ms uniform 0.1 ms grid, 2001 samples a(t) v(t) d(t)

Two conventions must be fixed before any data is written. First, sign: we store acceleration in the vehicle-forward direction as positive, so a frontal crash produces a negative-going pulse; velocity and displacement inherit that sign. Second, time origin: t = 0 is the pulse onset (first sample exceeding 0.5 g after bias removal), and every record is resampled onto a uniform 0.1 ms grid covering 0–200 ms. Fixing both means a record from one test rig is directly comparable to another.

Building the Dataset in Code

The following script is self-contained: it generates a small synthetic set of crash pulses (so the example runs without proprietary test data), conditions them, integrates to velocity and displacement, attaches deployment labels, and writes a single Parquet file plus a JSON schema. The synthetic pulses are clearly labeled as such — they are for pipeline validation, not for training a production model.

import numpy as np
import pandas as pd
import json

FS = 10_000            # Hz, raw sample rate
DT = 1.0 / FS
T_END = 0.200          # s, stored window
N = int(T_END / DT) + 1
T = np.arange(N) * DT

def synth_pulse(delta_v, peak_g, duration, seed):
    """Half-sine crash pulse, negative-going (frontal convention)."""
    rng = np.random.default_rng(seed)
    t = T.copy()
    pulse = np.zeros_like(t)
    mask = t <= duration
    pulse[mask] = -peak_g * np.sin(np.pi * t[mask] / duration)
    noise = rng.normal(0, 0.02, size=t.size)
    return pulse + noise

def condition(a_raw):
    """Remove bias, apply simple 2nd-order low-pass (CFC-60 style)."""
    a = a_raw - np.mean(a_raw[:50])          # bias from pre-trigger window
    # 2nd-order Butterworth, fc = 60 Hz, applied via biquad coefficients
    fc = 60.0
    w0 = 2 * np.pi * fc
    alpha = np.sin(w0 * DT / 2) / np.sqrt(2)
    b0 = (1 - np.cos(w0 * DT)) / 2
    b1 = 1 - np.cos(w0 * DT)
    b2 = b0
    a0 = 1 + alpha
    a1 = -2 * np.cos(w0 * DT)
    a2 = 1 - alpha
    y = np.zeros_like(a)
    x1 = x2 = y1 = y2 = 0.0
    for i, x in enumerate(a):
        y[i] = (b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) / a0
        x2, x1 = x1, x
        y2, y1 = y1, y[i]
    return y

def integrate(a):
    """Trapezoidal integration with zero initial conditions."""
    v = np.zeros_like(a)
    d = np.zeros_like(a)
    for i in range(1, a.size):
        v[i] = v[i-1] + 0.5 * (a[i] + a[i-1]) * DT
        d[i] = d[i-1] + 0.5 * (v[i] + v[i-1]) * DT
    return v, d

def find_onset(a, thresh_g=0.5):
    idx = np.argmax(np.abs(a) > thresh_g)
    return idx if np.abs(a).max() > thresh_g else 0

records = []
specs = [
    # delta_v m/s, peak_g, duration s, deployed
    (8.0,  22, 0.045, 1),
    (5.5,  15, 0.060, 0),
    (12.0, 35, 0.040, 1),
    (4.0,  10, 0.070, 0),
    (9.5,  28, 0.050, 1),
]

for i, (dv, pk, dur, dep) in enumerate(specs):
    a_raw = synth_pulse(dv, pk, dur, seed=i)
    a = condition(a_raw)
    onset = find_onset(a)
    a = a[onset:onset + N]
    if a.size < N:
        a = np.pad(a, (0, N - a.size))
    v, d = integrate(a)
    records.append({
        "event_id": f"SYN-{i:03d}",
        "delta_v_mps": dv,
        "peak_g": pk,
        "duration_s": dur,
        "deployed": dep,
        "a_g": a.tolist(),
        "v_mps": v.tolist(),
        "d_m": d.tolist(),
    })

df = pd.DataFrame(records)
df.to_parquet("occupant_kinematics_baseline.parquet", index=False)

schema = {
    "fs_hz": FS, "dt_s": DT, "n_samples": N,
    "t_start_s": 0.0, "t_end_s": T_END,
    "sign_convention": "vehicle-forward positive; frontal pulse negative",
    "units": {"a_g": "g", "v_mps": "m/s", "d_m": "m"},
    "label_field": "deployed", "label_values": [0, 1],
}
with open("occupant_kinematics_schema.json", "w") as f:
    json.dump(schema, f, indent=2)

print(df[["event_id", "delta_v_mps", "peak_g", "deployed"]])
print("rows:", len(df), "samples/record:", N)

Running this produces five records, each with 2001 samples per signal, and a schema file that pins the conventions. The printed table is the human-readable index; the Parquet file is what later cases load. Note that delta_v_mps is stored as a scalar summary alongside the full arrays — it is convenient for quick filtering but is derived from the pulse, so it must never be treated as an independent measurement.

Verifying the Dataset Before You Trust It

A baseline dataset is only useful if its internal consistency holds. Run these checks immediately after writing the file; each maps to a specific failure mode.

CheckExpected resultFailure symptomLikely cause
Array lengthAll records = 2001 samplesRagged lengthsOnset trimming not padded
Initial velocityv[0] ≈ 0Non-zero startBias not removed before integration
Final displacementMonotonic sign, no reversalDrift back toward zeroResidual bias integrated twice
Label balanceBoth classes presentAll zeros or all onesLabel join key mismatch
Peak magnitudeMatches peak_g within 5%Large mismatchFilter over-attenuation
Double integration amplifies any residual bias quadratically. If the final displacement drifts by more than a few centimeters over 200 ms, do not "fix" it by subtracting a linear trend — that masks the real problem. Re-check the pre-trigger bias window and the filter cutoff instead.

What This Dataset Enables

The output is deliberately minimal: one row per crash event, three aligned signal arrays, a scalar summary, and a binary label. That is enough to train a sequence classifier directly, and it is also the substrate from which time-domain features will later be extracted. Because the schema file records the sign convention, units, and time base, any downstream case can assert compatibility rather than assume it. Keep the schema alongside the Parquet file — a dataset without its conventions is not reproducible.

When you extend this baseline with real test data, preserve the event_id naming and the 0.1 ms grid. Adding records is safe; changing the grid or sign convention invalidates every model trained on the earlier version.