Files
impakt/tests/fixtures/generate_mme.py
2026-04-10 14:37:34 -04:00

307 lines
11 KiB
Python

#!/usr/bin/env python3
"""Generate realistic synthetic MME crash test fixture data.
Creates a proper ISO 13499 MME directory structure simulating a full
frontal crash test (56 km/h rigid barrier, Hybrid III 50th percentile
male driver). Produces ~30 channels covering all body regions needed
for injury criteria computation.
The signals use physically realistic waveforms:
- Half-sine acceleration pulses with appropriate timing and magnitude
- Neck force/moment with realistic tension-flexion patterns
- Chest deflection with viscous loading character
- Femur compressive force profiles
- Tibia axial and bending loads
All signals include pre-trigger baseline, realistic noise, and sensor
characteristics (sample rate, CFC class).
Usage:
python generate_mme.py [output_dir]
If no output_dir is specified, generates into tests/fixtures/sample_mme/
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
# ---------------------------------------------------------------------------
# Parameters
# ---------------------------------------------------------------------------
SAMPLE_RATE = 20000.0 # Hz
DT = 1.0 / SAMPLE_RATE
PRE_TRIGGER_S = 0.010 # 10 ms pre-trigger
EVENT_DURATION_S = 0.200 # 200 ms event
TOTAL_DURATION_S = PRE_TRIGGER_S + EVENT_DURATION_S
NUM_SAMPLES = int(TOTAL_DURATION_S * SAMPLE_RATE)
PRE_TRIGGER_SAMPLES = int(PRE_TRIGGER_S * SAMPLE_RATE)
RNG = np.random.default_rng(2024)
# Time array with t=0 at trigger
TIME = np.arange(NUM_SAMPLES, dtype=np.float64) * DT - PRE_TRIGGER_S
def noise(scale: float = 0.3) -> np.ndarray:
"""Add realistic sensor noise."""
return RNG.normal(0, scale, NUM_SAMPLES)
def half_sine(
t_start: float,
t_duration: float,
amplitude: float,
phase_shift: float = 0.0,
) -> np.ndarray:
"""Generate a half-sine pulse."""
data = np.zeros(NUM_SAMPLES)
mask = (TIME >= t_start) & (TIME <= t_start + t_duration)
t_local = TIME[mask] - t_start
data[mask] = amplitude * np.sin(np.pi * t_local / t_duration + phase_shift)
return data
def crash_pulse(t_start: float, t_rise: float, t_fall: float, amplitude: float) -> np.ndarray:
"""Generate a more realistic crash pulse with fast rise and slower decay."""
data = np.zeros(NUM_SAMPLES)
# Rise phase
mask_rise = (TIME >= t_start) & (TIME < t_start + t_rise)
t_local = TIME[mask_rise] - t_start
data[mask_rise] = amplitude * np.sin(np.pi * t_local / (2 * t_rise))
# Fall phase
mask_fall = (TIME >= t_start + t_rise) & (TIME <= t_start + t_rise + t_fall)
t_local = TIME[mask_fall] - (t_start + t_rise)
data[mask_fall] = amplitude * np.cos(np.pi * t_local / (2 * t_fall))
return data
# ---------------------------------------------------------------------------
# Channel definitions
# ---------------------------------------------------------------------------
# Each channel: (code, description, unit, cfc_class, data_generator)
def gen_channels() -> list[tuple[str, str, str, int, np.ndarray]]:
channels = []
# === HEAD ===
# Head X acceleration: main frontal pulse, ~45g peak
head_ax = crash_pulse(0.0, 0.020, 0.060, 45.0) + noise(0.8)
channels.append(("11HEAD0000ACXA", "Head CG X Acceleration", "g", 1000, head_ax))
# Head Y acceleration: small lateral component
head_ay = half_sine(0.005, 0.070, 8.0) * np.cos(5 * np.pi * (TIME - 0.005) / 0.070) + noise(0.4)
head_ay[TIME < 0.005] = noise(0.4)[TIME < 0.005]
channels.append(("11HEAD0000ACYA", "Head CG Y Acceleration", "g", 1000, head_ay))
# Head Z acceleration: downward component
head_az = crash_pulse(0.003, 0.018, 0.055, 22.0) + noise(0.5)
channels.append(("11HEAD0000ACZA", "Head CG Z Acceleration", "g", 1000, head_az))
# Head angular velocity (for future BrIC)
head_avx = half_sine(0.005, 0.060, 15.0) + noise(0.3)
channels.append(("11HEAD0000AVXA", "Head Angular Velocity X", "rad/s", 600, head_avx))
head_avy = half_sine(0.008, 0.055, 25.0) + noise(0.3)
channels.append(("11HEAD0000AVYA", "Head Angular Velocity Y", "rad/s", 600, head_avy))
head_avz = half_sine(0.006, 0.050, 10.0) + noise(0.2)
channels.append(("11HEAD0000AVZA", "Head Angular Velocity Z", "rad/s", 600, head_avz))
# === NECK (upper) ===
# Neck Fz: tension pulse ~3200 N
neck_fz = crash_pulse(0.005, 0.015, 0.045, 3200.0) + noise(15.0)
channels.append(("11NECKUP00FOZA", "Upper Neck Axial Force", "N", 600, neck_fz))
# Neck Fx: shear force ~800 N
neck_fx = crash_pulse(0.008, 0.012, 0.040, 800.0) + noise(8.0)
channels.append(("11NECKUP00FOXA", "Upper Neck Shear Force X", "N", 600, neck_fx))
# Neck My: flexion moment ~85 Nm
neck_my = crash_pulse(0.010, 0.015, 0.050, 85.0) + noise(1.5)
channels.append(("11NECKUP00MOYA", "Upper Neck Moment Y (Flexion)", "N·m", 600, neck_my))
# Neck Mx: lateral moment ~25 Nm
neck_mx = half_sine(0.012, 0.045, 25.0) + noise(0.8)
channels.append(("11NECKUP00MOXA", "Upper Neck Moment X", "N·m", 600, neck_mx))
# === CHEST ===
# Chest X acceleration
chest_ax = crash_pulse(0.008, 0.020, 0.055, 38.0) + noise(0.6)
channels.append(("11CHST0000ACXA", "Chest T12 X Acceleration", "g", 180, chest_ax))
# Chest Y acceleration
chest_ay = half_sine(0.010, 0.060, 6.0) + noise(0.3)
channels.append(("11CHST0000ACYA", "Chest T12 Y Acceleration", "g", 180, chest_ay))
# Chest Z acceleration
chest_az = crash_pulse(0.009, 0.018, 0.050, 18.0) + noise(0.4)
channels.append(("11CHST0000ACZA", "Chest T12 Z Acceleration", "g", 180, chest_az))
# Chest deflection: ramps up to ~36mm, then recovers
chest_dc = np.zeros(NUM_SAMPLES)
mask_load = (TIME >= 0.010) & (TIME < 0.055)
t_load = TIME[mask_load] - 0.010
chest_dc[mask_load] = 36.0 * np.sin(np.pi * t_load / 0.090)
mask_unload = (TIME >= 0.055) & (TIME < 0.130)
t_unload = TIME[mask_unload] - 0.055
chest_dc[mask_unload] = 36.0 * np.sin(np.pi * 0.045 / 0.090) * np.exp(-t_unload / 0.030)
chest_dc += noise(0.2)
channels.append(("11CHST0000DCXA", "Chest Deflection", "mm", 180, chest_dc))
# === PELVIS ===
pelv_ax = crash_pulse(0.012, 0.022, 0.060, 35.0) + noise(0.5)
channels.append(("11PELV0000ACXA", "Pelvis X Acceleration", "g", 180, pelv_ax))
pelv_ay = half_sine(0.015, 0.055, 5.0) + noise(0.3)
channels.append(("11PELV0000ACYA", "Pelvis Y Acceleration", "g", 180, pelv_ay))
pelv_az = crash_pulse(0.014, 0.018, 0.050, 15.0) + noise(0.4)
channels.append(("11PELV0000ACZA", "Pelvis Z Acceleration", "g", 180, pelv_az))
# === FEMUR ===
# Left femur: compressive pulse ~4800 N (negative = compression in SAE)
femur_l = -crash_pulse(0.020, 0.015, 0.045, 4800.0) + noise(20.0)
channels.append(("11FEMRLE00FOZA", "Left Femur Axial Force", "N", 600, femur_l))
# Right femur: slightly lower
femur_r = -crash_pulse(0.022, 0.014, 0.042, 4200.0) + noise(18.0)
channels.append(("11FEMRRI00FOZA", "Right Femur Axial Force", "N", 600, femur_r))
# === TIBIA (left upper) ===
tibia_fz = -crash_pulse(0.025, 0.012, 0.040, 5500.0) + noise(25.0)
channels.append(("11TIBILEUOFOZA", "Left Upper Tibia Axial Force", "N", 600, tibia_fz))
tibia_mx = half_sine(0.028, 0.040, 80.0) + noise(2.0)
channels.append(("11TIBILEUOMOXA", "Left Upper Tibia Moment X", "N·m", 600, tibia_mx))
tibia_my = half_sine(0.027, 0.038, 120.0) + noise(2.5)
channels.append(("11TIBILEUOMOYA", "Left Upper Tibia Moment Y", "N·m", 600, tibia_my))
# === SEAT BELT ===
belt_fo = crash_pulse(0.005, 0.025, 0.070, 6500.0) + noise(30.0)
channels.append(("11BELT0000FOXA", "Lap Belt Force", "N", 60, belt_fo))
# Shoulder belt force
sbelt_fo = crash_pulse(0.003, 0.020, 0.065, 5800.0) + noise(25.0)
channels.append(("11BELTUPOOFOZA", "Shoulder Belt Force", "N", 60, sbelt_fo))
# === VEHICLE / STRUCTURAL ===
# B-pillar acceleration
bpil_ax = crash_pulse(0.001, 0.015, 0.040, 55.0) + noise(1.0)
channels.append(("10BPILLE00ACXA", "Left B-Pillar X Acceleration", "g", 60, bpil_ax))
# Steering column displacement
stcl_ds = np.zeros(NUM_SAMPLES)
mask = (TIME >= 0.015) & (TIME < 0.100)
t_local = TIME[mask] - 0.015
stcl_ds[mask] = 45.0 * (1 - np.exp(-t_local / 0.025))
stcl_ds[TIME >= 0.100] = stcl_ds[mask][-1] if np.any(mask) else 0
stcl_ds += noise(0.3)
channels.append(("10STCL0000DSXA", "Steering Column Displacement", "mm", 60, stcl_ds))
return channels
# ---------------------------------------------------------------------------
# MME writer
# ---------------------------------------------------------------------------
def write_mme(output_dir: Path) -> None:
"""Write a complete MME directory structure."""
output_dir.mkdir(parents=True, exist_ok=True)
# --- MME.ini ---
ini_content = """\
[Test]
test_number = IMPAKT_SYNTH_001
test_date = 2024-06-15
test_type = Full Frontal Rigid Barrier
test_speed = 56.3
test_facility = Impakt Synthetic Data Generator
test_standard = FMVSS 208
description = Synthetic frontal crash test for Impakt development and testing
[Vehicle]
vehicle_make = Synthetic
vehicle_model = TestCar
vehicle_year = 2024
vehicle_vin = IMPAKT0000000001
vehicle_mass = 1523.0
vehicle_type = Passenger Car
[Barrier]
barrier_type = Rigid
impact_angle = 0
overlap_percent = 100
impact_side = Front
[Dummy]
dummy_type = Hybrid III 50th Percentile Male
dummy_serial = H3-50M-SYNTH-001
dummy_position = Driver
dummy_mass = 78.0
restraint_type = 3-point belt + frontal airbag
seat_position = Mid-track, mid-height
[DataAcquisition]
sample_rate = 20000
pre_trigger_ms = 10
total_duration_ms = 210
num_channels = 28
"""
(output_dir / "MME.ini").write_text(ini_content, encoding="utf-8")
# --- Channel files ---
ch_dir = output_dir / "channels"
ch_dir.mkdir(exist_ok=True)
channels = gen_channels()
for code, description, unit, cfc, data in channels:
# Write .chn header
chn_content = f"""\
[Channel]
channel_code = {code}
description = {description}
unit = {unit}
sample_rate = {SAMPLE_RATE:.0f}
num_samples = {NUM_SAMPLES}
dt = {DT:.10f}
pre_trigger = {PRE_TRIGGER_SAMPLES}
cfc = {cfc}
data_format = ascii
"""
(ch_dir / f"{code}.chn").write_text(chn_content, encoding="utf-8")
# Write .dat data (ASCII, one value per line)
np.savetxt(str(ch_dir / f"{code}.dat"), data, fmt="%.8f")
print(f"Generated MME fixture: {output_dir}")
print(f" {len(channels)} channels, {NUM_SAMPLES} samples each")
print(f" Sample rate: {SAMPLE_RATE:.0f} Hz")
print(
f" Duration: {PRE_TRIGGER_S * 1000:.0f} ms pre-trigger + {EVENT_DURATION_S * 1000:.0f} ms event"
)
print(
f" Total size: ~{sum(len(data) for _, _, _, _, data in channels) * 12 / 1024 / 1024:.1f} MB"
)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if __name__ == "__main__":
if len(sys.argv) > 1:
out = Path(sys.argv[1])
else:
out = Path(__file__).parent / "sample_mme"
write_mme(out)