307 lines
11 KiB
Python
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)
|