From 08e33f083a1b0ab35942c8ec2027908230322e9c Mon Sep 17 00:00:00 2001 From: noisedestroyers Date: Sat, 11 Apr 2026 15:20:27 -0400 Subject: [PATCH] Layered Configs --- README.md | 1287 ++++------------- docs/STATUS.md | 323 ++--- src/impakt/config/__init__.py | 34 + src/impakt/config/model.py | 376 +++++ src/impakt/defaults/config.yaml | 164 +++ .../defaults/protocols/euro_ncap_2024.yaml | 76 + src/impakt/defaults/protocols/iihs_2024.yaml | 40 + src/impakt/plot/engine.py | 17 +- src/impakt/script/api.py | 22 + src/impakt/web/state.py | 21 + tests/test_config.py | 227 +++ 11 files changed, 1378 insertions(+), 1209 deletions(-) create mode 100644 src/impakt/config/__init__.py create mode 100644 src/impakt/config/model.py create mode 100644 src/impakt/defaults/config.yaml create mode 100644 src/impakt/defaults/protocols/euro_ncap_2024.yaml create mode 100644 src/impakt/defaults/protocols/iihs_2024.yaml create mode 100644 tests/test_config.py diff --git a/README.md b/README.md index 46e426c..673722a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Impakt is a modular, scriptable Python toolkit for working with automotive crash # Install uv sync --dev -# Run tests (136 tests) +# Run tests (258 tests, 70% coverage) uv run pytest tests/ # View test metadata @@ -24,6 +24,14 @@ uv run python -c " from impakt import Session s = Session.open('tests/mme_data/3239') print(s) # Session(3239, 133 channels) + +# Fluent transforms +ch = s.channel('11HEAD0000H3ACXP').transform.cfc(1000).transform.y_align() +print(f'Peak: {ch.peak:.1f} {ch.unit}') + +# One-call protocol evaluation +result = s.evaluate('euro_ncap') +print(f'{result.stars} stars ({result.percentage:.0f}%)') " ``` @@ -35,23 +43,22 @@ print(s) # Session(3239, 133 channels) - [[#Design Principles]] - [[#Architecture Overview]] +- [[#Configuration System]] - [[#Module Breakdown]] + - [[#impakt.config — Configuration]] - [[#impakt.io — Data IO]] - [[#impakt.channel — Channel Model]] - [[#impakt.transform — Signal Processing]] - [[#impakt.criteria — Injury Criteria]] - [[#impakt.protocol — Rating Protocols]] - [[#impakt.plot — Visualization]] - - [[#impakt.report — PDF and Report Generation]] + - [[#impakt.report — Report Generation]] - [[#impakt.template — Templates and Sessions]] - [[#impakt.web — Web UI]] - [[#impakt.plugin — Plugin System]] - [[#impakt.script — Scripting API]] - [[#Data Flow]] -- [[#Template and Session Lifecycle]] -- [[#Injury Criteria Pipeline]] -- [[#Web UI Architecture]] -- [[#Plugin Architecture]] +- [[#Configuration Layers]] - [[#ISO Channel Naming Intelligence]] - [[#Directory Structure]] - [[#Scripting Examples]] @@ -60,12 +67,13 @@ print(s) # Session(3239, 133 channels) ## Design Principles -1. **Immutable raw data.** Original MME files are never modified. All state — sessions, cached computations, user overrides — lives in a `.impakt/` subfolder alongside the test data. -2. **Non-destructive transforms.** Filtering, alignment, and math operations produce new channel views. The transformation chain is stored and reproducible. -3. **Scriptable first.** Every operation available in the UI is accessible through a Python API. The web UI is a frontend to the same engine. -4. **Template-driven workflow.** Reusable templates define plot layouts, filter chains, channel selections, corridors, and report configurations. Templates live in a global library; sessions bind templates to specific test data. -5. **Protocol-aware.** Built-in knowledge of Euro NCAP, US NCAP, and IIHS injury criteria, scoring thresholds, and report formats. -6. **Plugin-extensible.** Custom readers, transforms, injury criteria, report templates, and UI components can be registered through a plugin API. +1. **Immutable raw data.** Original MME files are never modified. All state — sessions, configuration, cached results — lives in a `.impakt/` subfolder alongside the test data. +2. **Non-destructive transforms.** Filtering, alignment, and math operations produce new channel views via `TransformChain`. The chain is serializable and reproducible. +3. **Scriptable first.** Every operation available in the UI is accessible through a Python API. The web UI calls the same `Session` and `PlotEngine` used by scripts. +4. **Layered configuration.** All behavior is configurable via YAML files at three levels: package defaults, user defaults (`~/.impakt/`), and per-test session overrides (`.impakt/`). +5. **Template-driven workflow.** Reusable templates define channel selections, filter chains, corridors, and report configs. Sessions bind templates to specific test data. +6. **Protocol-aware.** Built-in Euro NCAP, US NCAP, and IIHS scoring with versioned YAML threshold files. +7. **Plugin-extensible.** Custom readers, transforms, criteria, and protocols registered via entry points, directory scanning, or API. --- @@ -91,12 +99,13 @@ graph TB end subgraph "Orchestration Layer" + CF[impakt.config] TM[impakt.template] SC[impakt.script] PG[impakt.plugin] end - IO -->|"parse MME/other"| CH + IO -->|"parse MME"| CH CH -->|"channel data"| TR TR -->|"filtered/aligned"| CR CR -->|"injury values"| PR @@ -109,10 +118,10 @@ graph TB PL -->|"figures"| RP PL -->|"interactive plots"| WB - TM -->|"orchestrates"| IO - TM -->|"orchestrates"| TR - TM -->|"orchestrates"| PL - TM -->|"orchestrates"| CR + CF -.->|"configures"| PL + CF -.->|"configures"| TR + CF -.->|"configures"| CR + CF -.->|"configures"| PR SC -->|"drives"| TM SC -->|"drives"| IO @@ -123,22 +132,64 @@ graph TB PG -.->|"extends"| TR PG -.->|"extends"| CR PG -.->|"extends"| PR - PG -.->|"extends"| RP WB -->|"calls"| SC ``` --- +## Configuration System + +All configurable behavior is defined in YAML files with three-layer resolution: + +```mermaid +graph LR + PKG["Package Defaults\nsrc/impakt/defaults/config.yaml"] --> USER["User Defaults\n~/.impakt/config.yaml"] + USER --> SESSION["Session Overrides\n/.impakt/config.yaml"] + SESSION --> ACTIVE["Active Config"] + + style PKG fill:#e1f5fe + style USER fill:#fff9c4 + style SESSION fill:#c8e6c9 + style ACTIVE fill:#f3e5f5 +``` + +**Configurable sections:** + +| Section | Examples | +|---|---| +| `plot` | Color palette, line widths, focus styling, margins, cursor colors, grid, resampling | +| `transforms` | Default CFC class, Y-align, X-align method | +| `criteria` | Channel patterns for auto-detection (per criterion, glob-style) | +| `protocols` | Default protocol, version mapping, threshold file locations | +| `session` | Auto-save behavior, `.impakt/` directory name | +| `web` | Default layout, cursor poll interval, port | + +When a session is saved, configuration and protocol thresholds are copied into the test's `.impakt/` folder, making it self-contained and reproducible. + +--- + ## Module Breakdown +### impakt.config — Configuration + +Layered YAML configuration with typed Python dataclasses. + +```python +from impakt.config import Config + +config = Config.load(session_path="tests/mme_data/3239") +config.plot.line_width # 1.5 (from package default) +config.transforms.default_cfc # None (no filter by default) +config.protocols.default # "euro_ncap" + +config.transforms.default_cfc = 600 +config.save_session("tests/mme_data/3239") # writes .impakt/config.yaml +``` + ### impakt.io — Data IO -Responsible for reading crash test data from disk into the internal channel model. - -**Primary format:** ISO 13499 MME (directory-based: `MME.ini` + `.chn`/`.dat` file pairs). - -**Architecture:** Reader classes implement a common `Reader` protocol, enabling future format support (TDMS, CSV, UDB) without changing downstream code. +Reads crash test data via the `ReaderProtocol`. The `MMEReader` handles real ISO 13499 format (`.mme` master + `.chn` index + `.NNN` data files) and simplified INI format. ```mermaid classDiagram @@ -149,26 +200,9 @@ classDiagram +metadata(path: Path) TestMetadata } - class MMEReader { - +read(path: Path) TestData - +supports(path: Path) bool - +metadata(path: Path) TestMetadata - -_parse_ini(path: Path) dict - -_parse_chn(path: Path) ChannelHeader - -_read_dat(path: Path, header: ChannelHeader) ndarray - } - - class TDMSReader { - +read(path: Path) TestData - +supports(path: Path) bool - +metadata(path: Path) TestMetadata - } - - class CSVReader { - +read(path: Path) TestData - +supports(path: Path) bool - +metadata(path: Path) TestMetadata - } + class MMEReader + class TDMSReader + class CSVReader class ReaderRegistry { +register(reader: ReaderProtocol) @@ -182,1034 +216,315 @@ classDiagram ReaderRegistry o-- ReaderProtocol ``` -**Key responsibilities:** -- Parse `MME.ini` for test-level metadata (vehicle, dummy, impact config) -- Parse `.chn` headers for per-channel metadata (units, sample rate, CFC class, pre-trigger) -- Read `.dat` files (ASCII or binary IEEE float) into NumPy arrays -- Reconstruct time vectors from `dt` and pre-trigger sample count -- Populate `TestData` and `Channel` objects - ---- +Tested against 5 real ISO 13499 datasets from NHTSA/Calspan, BASt, Volkswagen, and UTAC. ### impakt.channel — Channel Model -The core data model. Channels are immutable value objects wrapping time-series data with rich metadata. +Immutable `Channel` objects wrapping NumPy time-series data with ISO channel code intelligence. -```mermaid -classDiagram - class TestData { - +test_id: str - +metadata: TestMetadata - +channels: dict~str, Channel~ - +path: Path - +get(name: str) Channel - +find(pattern: str) list~Channel~ - +groups() dict~str, ChannelGroup~ - } +The `ChannelCode` parser auto-detects 14-char and 16-char ISO codes: - class TestMetadata { - +test_number: str - +test_date: date - +test_type: str - +vehicle: VehicleInfo - +dummy: DummyInfo - +impact: ImpactConfig - } +| Format | Example | Positions 11-12 | +|---|---|---| +| 16-char (with dummy) | `11HEAD0000H3ACXP` | `H3` = Hybrid III | +| 14-char (simplified) | `11HEAD0000ACXA` | `AC` = Acceleration | - class Channel { - +name: str - +code: ChannelCode - +data: ndarray - +time: ndarray - +unit: str - +sample_rate: float - +cfc_class: int | None - +metadata: dict - } - - class ChannelCode { - +raw: str - +test_object: str - +main_location: str - +fine_location: str - +measurement: str - +direction: str - +sense: str - +filter_class: str - +group_key() str - +is_component() bool - +axis() str - } - - class ChannelGroup { - +key: str - +x: Channel | None - +y: Channel | None - +z: Channel | None - +resultant() Channel - +components() list~Channel~ - } - - TestData *-- TestMetadata - TestData *-- Channel - Channel *-- ChannelCode - ChannelGroup o-- Channel -``` - -**ISO channel naming intelligence** is embedded in `ChannelCode`. Given a raw 16-character name like `11HEAD0000H3ACXA`, the parser extracts: - -| Field | Positions | Example | Meaning | -|---|---|---|---| -| Test Object | 1-2 | `11` | Driver, Hybrid III | -| Main Location | 3-6 | `HEAD` | Head | -| Fine Location | 7-10 | `0000` | Center of gravity | -| Measurement | 11-12 | `AC` | Acceleration | -| Direction | 13 | `X` | Longitudinal axis | -| Sense | 14 | `A` | SAE sign convention A | - -**Auto-grouping:** Channels sharing positions 1-12 + 14-16 (differing only at position 13: X/Y/Z) are automatically grouped into `ChannelGroup` objects for one-call resultant computation. - ---- +**Auto-grouping:** Channels sharing all fields except direction (X/Y/Z) are grouped for one-call resultant computation. ### impakt.transform — Signal Processing -Non-destructive signal processing pipeline. Each transform takes a `Channel` and returns a new `Channel` — the original is never mutated. +Non-destructive pipeline via `TransformChain`. Each transform produces a new `Channel`. -```mermaid -graph LR - subgraph "Transform Pipeline" - RAW[Raw Channel] --> F1[CFC Filter] - F1 --> F2[X-Axis Align] - F2 --> F3[Y-Axis Zero] - F3 --> F4[Math Expression] - F4 --> OUT[Derived Channel] - end - - style RAW fill:#e1f5fe - style OUT fill:#c8e6c9 -``` - -**Available transforms:** - -| Transform | Description | Parameters | -|---|---|---| -| `CFCFilter` | SAE J211 CFC filtering (4th-order Butterworth, zero-phase) | `cfc_class`: 60, 180, 600, 1000 | -| `XAlign` | Time-zero shifting — moves t=0 to a user-specified event | `method`: manual, threshold, trigger | -| `YAlign` | Zero-offset correction using average-over-time window | `window`: (t_start, t_end) for baseline | -| `Resultant` | Compute vector magnitude from X/Y/Z components | `group`: ChannelGroup | -| `MathExpr` | Free-form math on channels (e.g., `ch_a + ch_b * 0.5`) | `expression`: str, `channels`: dict | -| `Trim` | Extract time range | `t_start`, `t_end` | -| `Resample` | Change sample rate | `target_rate`: float | - -**Transform chain model:** - -```mermaid -classDiagram - class Transform { - <> - +apply(channel: Channel) Channel - +name: str - +params: dict - } - - class TransformChain { - +steps: list~Transform~ - +apply(channel: Channel) Channel - +append(transform: Transform) TransformChain - +serialize() dict - +deserialize(data: dict) TransformChain - } - - class CFCFilter { - +cfc_class: int - +apply(channel: Channel) Channel - } - - class XAlign { - +method: str - +reference_time: float - +apply(channel: Channel) Channel - } - - class YAlign { - +window: tuple~float, float~ - +apply(channel: Channel) Channel - } - - class Resultant { - +apply(group: ChannelGroup) Channel - } - - class MathExpr { - +expression: str - +apply(channels: dict) Channel - } - - Transform <|.. CFCFilter - Transform <|.. XAlign - Transform <|.. YAlign - Transform <|.. Resultant - Transform <|.. MathExpr - TransformChain o-- Transform -``` - -**CFC filter implementation** (SAE J211 compliant): -- 4th-order Butterworth low-pass, applied forward-reverse via `scipy.signal.filtfilt` -- Cutoff frequency: `f_c = CFC_class * (5/3)` Hz -- CFC 60 = 100 Hz, CFC 180 = 300 Hz, CFC 600 = 1000 Hz, CFC 1000 = 1650 Hz - ---- +| Transform | Description | +|---|---| +| `CFCFilter` | SAE J211 CFC filtering (4th-order Butterworth, zero-phase) | +| `XAlign` | Time-zero shifting (manual, threshold, trigger) | +| `YAlign` | Zero-offset correction from baseline window | +| `Resultant` | Vector magnitude from X/Y/Z components | +| `MathExpr` | Free-form math with safe numpy evaluation | +| `Trim` | Extract time range | +| `Resample` | Change sample rate (Fourier-based) | ### impakt.criteria — Injury Criteria -Calculation engine for standard injury criteria. Each criterion is a self-contained function operating on filtered channel data. +Auto-detection of channels by ISO naming patterns, with configurable patterns in `config.yaml`. -```mermaid -classDiagram - class InjuryCriterion { - <> - +name: str - +required_channels: list~str~ - +compute(channels: dict~str, Channel~, dummy: DummyInfo) CriterionResult - } - - class CriterionResult { - +criterion: str - +value: float - +unit: str - +time_of_peak: float | None - +window: tuple~float, float~ | None - +details: dict - } - - class HIC { - +window_ms: int - +compute(...) CriterionResult - } - - class Clip3ms { - +compute(...) CriterionResult - } - - class Nij { - +intercepts: NijIntercepts - +compute(...) CriterionResult - } - - class ChestDeflection { - +compute(...) CriterionResult - } - - class FemurLoad { - +compute(...) CriterionResult - } - - class TibiaIndex { - +compute(...) CriterionResult - } - - class ViscousCriterion { - +compute(...) CriterionResult - } - - InjuryCriterion <|.. HIC - InjuryCriterion <|.. Clip3ms - InjuryCriterion <|.. Nij - InjuryCriterion <|.. ChestDeflection - InjuryCriterion <|.. FemurLoad - InjuryCriterion <|.. TibiaIndex - InjuryCriterion <|.. ViscousCriterion - InjuryCriterion --> CriterionResult -``` - -**Implemented criteria:** - -| Criterion | Formula / Method | Key Inputs | Reference | -|---|---|---|---| -| **HIC15 / HIC36** | `(t2-t1) * [avg(a)]^2.5`, maximized over window | Head resultant accel (g) | FMVSS 208 | -| **3ms Clip** | Max accel sustained for cumulative 3 ms | Chest resultant accel (g) | SAE J211 | -| **Nij** | `Fz/Fzc + My/Myc`, max of 4 modes | Upper neck Fz, My | FMVSS 208 | -| **Chest Deflection** | Peak sternal displacement | Chest deflection (mm) | FMVSS 208 | -| **Femur Load** | Peak compressive axial force | Femur Fz left/right (kN) | FMVSS 208 | -| **Tibia Index** | `\|M\|/Mc + \|F\|/Fc` | Tibia Mx, My, Fz | Euro NCAP | -| **Viscous Criterion** | `V(t) * C(t)` (velocity x compression) | Chest deflection time-history | Euro NCAP | - -**Nij critical intercepts by dummy type:** - -| Dummy | Fzc Tension (N) | Fzc Compression (N) | Myc Flexion (Nm) | Myc Extension (Nm) | -|---|---|---|---|---| -| Hybrid III 50th M | 6806 | 6160 | 310 | 135 | -| Hybrid III 5th F | 4287 | 3880 | 155 | 67 | -| Hybrid III 6YO | 2800 | 2800 | 93 | 37 | -| Hybrid III 3YO | 2120 | 2120 | 68 | 27 | - ---- +| Criterion | Method | Reference | +|---|---|---| +| **HIC15/36** | Cumulative integration, optimal window search | FMVSS 208 | +| **3ms Clip** | Cumulative exceedance | SAE J211 | +| **Nij** | 4 modes (NTE/NTF/NCE/NCF), per-dummy intercepts | FMVSS 208 | +| **Chest Deflection** | Peak sternal displacement | FMVSS 208 | +| **Femur Load** | Peak compressive axial force | FMVSS 208 | +| **Tibia Index** | M/Mc + F/Fc with intercepts | Euro NCAP | +| **Viscous Criterion** | V(t) * C(t) | Euro NCAP | ### impakt.protocol — Rating Protocols -Maps computed injury criteria to protocol-specific scores and ratings. +Versioned YAML threshold files loaded from `defaults/protocols/`, `~/.impakt/protocols/`, or `/.impakt/protocols/`. -```mermaid -graph TB - subgraph "Criteria Results" - HIC[HIC15: 423] - CHEST[Chest Defl: 34mm] - FEMUR[Femur: 4.2kN] - NIJ[Nij: 0.61] - TI[Tibia Index: 0.8] - end - - subgraph "Protocol Engines" - ENCAP[Euro NCAP Scorer] - USNCAP[US NCAP Scorer] - IIHS_E[IIHS Evaluator] - end - - subgraph "Outputs" - ENCAP_R["Euro NCAP\n4 stars\nAdult: 82%"] - USNCAP_R["US NCAP\n5 stars\nP(injury): 8%"] - IIHS_R["IIHS\nGood\nAll sub-ratings: G"] - end - - HIC --> ENCAP & USNCAP & IIHS_E - CHEST --> ENCAP & USNCAP & IIHS_E - FEMUR --> ENCAP & USNCAP & IIHS_E - NIJ --> ENCAP & USNCAP & IIHS_E - TI --> ENCAP - - ENCAP --> ENCAP_R - USNCAP --> USNCAP_R - IIHS_E --> IIHS_R -``` - -**Protocol scoring:** - -| Protocol | Methodology | Output | -|---|---|---| -| **Euro NCAP** | Sliding-scale performance limits per body region, mapped to color codes (Green / Yellow / Orange / Brown / Red) and points. Area percentages determine star rating. | Stars (0-5), body region colors, area scores | -| **US NCAP** | Injury risk functions convert criteria to probability of AIS 3+ injury. Combined probability maps to stars. | Stars (1-5), injury probability | -| **IIHS** | Threshold-based per criterion: Good / Acceptable / Marginal / Poor. Overall = worst sub-rating with adjustment. | G/A/M/P per region, overall rating | - -Protocol thresholds are versioned and configurable — scoring rules change every few years and Impakt stores these as versioned protocol definition files. - ---- +| Protocol | Output | +|---|---| +| **Euro NCAP** | Stars (0-5), body region colors (Green/Yellow/Orange/Brown/Red), points | +| **US NCAP** | Stars (1-5), injury probability per body region | +| **IIHS** | Good/Acceptable/Marginal/Poor per body region, overall rating | ### impakt.plot — Visualization -Plotting engine built on Plotly for interactive web-based visualization. +Single rendering path via `PlotEngine.render(PlotSpec)`. Both the scripting API and web UI construct `PlotSpec` objects and delegate to the same engine. -**Capabilities:** -- Single channel time-history plots -- Multi-channel overlay (same test, different channels) -- Multi-test overlay (same channel, different tests) -- Dual X-axis cursors with value readout for all plotted channels -- Tolerance corridors (user-defined or from templates) -- Zoom, pan, box select -- Resultant plots auto-computed from grouped components +- **Compact mode** for web UI (no legend, tight margins, disabled hover) +- **Standard mode** for scripting (full tooltips, legend) +- **FigureResampler** integration for LTTB downsampling on zoom/pan +- **Focus channel** rendering (amber highlight, rendered on top) +- **Corridor fills** (upper/lower bands from CSV or programmatic) -```mermaid -classDiagram - class PlotSpec { - +channels: list~ChannelRef~ - +corridors: list~Corridor~ - +x_cursors: tuple~float, float~ | None - +x_range: tuple~float, float~ | None - +y_range: tuple~float, float~ | None - +title: str - +x_label: str - +y_label: str - } +### impakt.report — Report Generation - class ChannelRef { - +test_id: str - +channel_name: str - +transform_chain: TransformChain | None - +style: PlotStyle - } - - class Corridor { - +name: str - +upper: ndarray - +lower: ndarray - +time: ndarray - +style: CorridorStyle - } - - class PlotStyle { - +color: str - +line_width: float - +line_dash: str - +label: str - } - - class PlotEngine { - +render(spec: PlotSpec) PlotlyFigure - +to_image(spec: PlotSpec, format: str) bytes - +to_html(spec: PlotSpec) str - } - - PlotSpec *-- ChannelRef - PlotSpec *-- Corridor - ChannelRef *-- PlotStyle - PlotEngine --> PlotSpec -``` - -**Dual X-axis cursor** — the user selects two time points (click or explicit entry). A table displays the interpolated value of every plotted channel at both cursor positions, plus the delta. - ---- - -### impakt.report — PDF and Report Generation - -Generates single-page-per-plot PDFs and multi-section protocol reports. - -```mermaid -graph LR - PLOTS[Plot Specs] --> RE[Report Engine] - CRITERIA[Criteria Results] --> RE - PROTOCOL[Protocol Scores] --> RE - META[Test Metadata] --> RE - TMPL[Report Template] --> RE - RE --> PDF[PDF Output] - RE --> HTML[HTML Output] -``` - -**Report types:** -- **Plot sheets** — one plot per page, with metadata header (test ID, channel info, filter state) -- **Injury summary** — tabular criteria results with color-coded pass/fail per protocol -- **Full protocol report** — Euro NCAP / US NCAP / IIHS formatted report with all required sections, body region diagrams, and scoring breakdowns - ---- +Jinja2 HTML templates rendered to PDF via WeasyPrint. Three report types: plot sheets, injury summary, and full protocol reports. ### impakt.template — Templates and Sessions -The template/session system enables reusable analysis workflows and per-test state persistence. - | Concept | **Template** | **Session** | |---|---|---| -| Lives in | Global library (`~/.impakt/templates/`) | Alongside test data (`.impakt/` subfolder) | -| Contains | Plot layouts, filter chains, channel selections, corridors, report configs | Template reference + test-specific overrides + cached results | -| Purpose | Reusable recipe ("show me frontal NCAP analysis") | Specific instance ("test_001 viewed with frontal NCAP, but I moved the cursor") | -| Mutability | Edited in library, versioned | Auto-saved per test, can "promote" to template | - -```mermaid -graph TB - subgraph "Global Library ~/.impakt/templates/" - T1[frontal_ncap.yaml] - T2[side_iihs.yaml] - T3[custom_analysis.yaml] - end - - subgraph "Test Data Directory" - MME[test_001/MME.ini] - subgraph ".impakt/" - S1[session.yaml] - CACHE[cache/] - end - end - - T1 -->|"user opens test\nwith template"| S1 - S1 -->|"references"| T1 - S1 -->|"stores overrides"| S1 - S1 -->|"promote changes"| T1 -``` - -**Template spec** (YAML): - -```yaml -name: "Frontal NCAP Analysis" -version: 2 -plots: - - title: "Head Acceleration" - channels: - - pattern: "11HEAD0000AC{X,Y,Z}A" - transform: - - type: cfc_filter - cfc_class: 1000 - - pattern: "11HEAD0000ACRA" - transform: - - type: resultant - corridors: - - name: "HIC 700 Reference" - file: "corridors/hic_700.csv" - - title: "Chest Deflection" - channels: - - pattern: "11CHST****DC*A" - x_cursors: [0.0, 0.080] -filters: - default_cfc: 180 -criteria: - - hic15 - - clip_3ms - - nij - - chest_deflection - - femur_load -protocol: euro_ncap_2024 -report: - format: pdf - template: euro_ncap_full -``` - -**Session spec** (stored in `test_data/.impakt/session.yaml`): - -```yaml -template: "frontal_ncap" -template_version: 2 -test_path: "/data/tests/test_001" -overrides: - plots: - 0: - x_cursors: [0.012, 0.065] - filters: - "11HEAD0000ACXA": - - type: cfc_filter - cfc_class: 600 -cached_results: - hic15: 423.7 - last_computed: "2026-04-10T14:30:00" -``` - -**Lifecycle:** - -```mermaid -sequenceDiagram - participant User - participant Template Library - participant Session - participant Engine - - User->>Template Library: Select template - Template Library->>Session: Instantiate session - User->>Engine: Open test data - Engine->>Session: Bind to test data - Engine->>Engine: Apply template (load channels, filters, plots) - User->>Session: Modify (move cursors, change filter) - Session->>Session: Store override - User->>Template Library: (optional) Promote override to template - User->>Session: Close - Session->>Session: Auto-save to .impakt/ -``` - ---- +| Lives in | `~/.impakt/templates/` | `/.impakt/` | +| Contains | Channel patterns, filter chains, corridors, protocol config | Template ref + overrides + config + cached results | +| Purpose | Reusable recipe | Per-test instance with user modifications | ### impakt.web — Web UI -Interactive web application built with Dash (Plotly). +Dash application with two tabs: -```mermaid -graph TB - subgraph "Dash Layout" - direction TB - HEADER[Header Bar: Test Info + Template Selector] +**Data Tab:** +- Resizable left panel (draggable splitter) with channel grid + transform controls +- Channel grid: flat sortable DataTable, wildcard filter, facet dropdowns, consistent color coding +- Plot area with multi-pane layout (1x1 through 3x1) +- Channel Values table: combined statistics + live cursor values (#, ISO Code, Description, Unit, Min@Time, Max@Time, X1, X2, Cursor) +- Custom JS cursor tracker (mousemove → pixel-to-data-X conversion) - subgraph "Main Area" - direction LR - subgraph "Left Panel" - TREE[Channel Tree\nISO-aware hierarchy] - FILTER_PANEL[Transform Controls\nCFC / Align / Math] - end - - subgraph "Center" - PLOT_AREA[Plot Area\nMultiple plot panes] - CURSOR_TABLE[Cursor Values Table\nx1 | x2 | delta] - end - - subgraph "Right Panel" - CORRIDOR_PANEL[Corridor Manager] - CRITERIA_PANEL[Criteria Results] - end - end - - FOOTER[Report Generation + Export Controls] - end - - TREE -->|"channel selection\ncallback"| PLOT_AREA - FILTER_PANEL -->|"transform\ncallback"| PLOT_AREA - PLOT_AREA -->|"cursor position\ncallback"| CURSOR_TABLE - CORRIDOR_PANEL -->|"corridor data\ncallback"| PLOT_AREA - CRITERIA_PANEL -->|"annotation\ncallback"| PLOT_AREA - FOOTER -->|"generate\ncallback"| PLOT_AREA -``` - -**Key UI components:** -- **Channel tree** — hierarchical browser organized by test object > body region > measurement type, leveraging ISO naming intelligence -- **Plot pane** — Plotly figures with zoom / pan / box-select, corridor overlays -- **Cursor table** — interpolated values at two user-selected X-axis times for all visible channels, plus delta -- **Transform sidebar** — apply / remove filters, alignment, math expressions -- **Template panel** — load, save, promote templates -- **Report panel** — select protocol, generate PDF - ---- +**Analysis Tab:** +- Injury criteria auto-computation with protocol scoring +- Math expression builder with variable binding +- Template management (library browser, save/apply/delete) +- Corridor upload (CSV) +- Export (CSV, PNG/SVG/PDF, protocol report) ### impakt.plugin — Plugin System -Formal plugin architecture for extending every layer. - -```mermaid -classDiagram - class PluginRegistry { - +register(plugin: ImpaktPlugin) - +discover(path: Path) - +get_readers() list~ReaderProtocol~ - +get_transforms() list~Transform~ - +get_criteria() list~InjuryCriterion~ - +get_protocols() list~Protocol~ - +get_report_templates() list~ReportTemplate~ - } - - class ImpaktPlugin { - <> - +name: str - +version: str - +register(registry: PluginRegistry) - } - - class ReaderPlugin { - +reader: ReaderProtocol - } - - class TransformPlugin { - +transform: Transform - } - - class CriterionPlugin { - +criterion: InjuryCriterion - } - - class ProtocolPlugin { - +protocol: Protocol - } - - ImpaktPlugin <|.. ReaderPlugin - ImpaktPlugin <|.. TransformPlugin - ImpaktPlugin <|.. CriterionPlugin - ImpaktPlugin <|.. ProtocolPlugin - PluginRegistry o-- ImpaktPlugin -``` - -**Discovery mechanisms:** -- Entry points (`pyproject.toml` / `setup.cfg` entry point groups) -- Directory scanning (`~/.impakt/plugins/`) -- Explicit registration via API - -**Example plugin integration:** - -```mermaid -graph TB - subgraph "Core Impakt" - REG[Plugin Registry] - IO_CORE[io: MMEReader] - TR_CORE[transform: CFC, Align, ...] - CR_CORE[criteria: HIC, Nij, ...] - PR_CORE[protocol: ENCAP, USNCAP, IIHS] - end - - subgraph "Plugin: impakt-tdms" - TDMS_P[TDMSReader] - end - - subgraph "Plugin: impakt-jncap" - JNCAP_P[JNCAP Scorer] - JNCAP_R[JNCAP Report Template] - end - - subgraph "Plugin: impakt-custom-filter" - CUSTOM_F[WaveletDenoise Transform] - end - - subgraph "Discovery" - EP[Entry Points\npyproject.toml] - DIR[~/.impakt/plugins/] - API_R[Explicit API\nregistry.register] - end - - EP --> REG - DIR --> REG - API_R --> REG - - REG -->|"extends"| IO_CORE - REG -->|"extends"| TR_CORE - REG -->|"extends"| CR_CORE - REG -->|"extends"| PR_CORE - - TDMS_P --> REG - JNCAP_P --> REG - JNCAP_R --> REG - CUSTOM_F --> REG -``` - ---- +Entry point + directory + API discovery. `PluginRegistry.register_reader()` forwards to the IO registry. Plugins discovered on first `Session.open()`. ### impakt.script — Scripting API -The top-level API that scripts and the web UI both call. - -```python -from impakt import Session, Template - -# Load test data -test = Session.open("/data/tests/test_001") - -# Browse channels using ISO naming intelligence -head_channels = test.find("11HEAD0000AC*") -# [Channel(11HEAD0000ACXA), Channel(11HEAD0000ACYA), Channel(11HEAD0000ACZA)] - -# Auto-detect X/Y/Z group, compute resultant -head_group = test.group("11HEAD0000AC") -head_resultant = head_group.resultant() - -# Apply CFC filter (non-destructive) -filtered = head_resultant.transform.cfc(1000) - -# Compute HIC15 -from impakt.criteria import hic -result = hic(filtered, window_ms=15) -print(f"HIC15 = {result.value:.1f} at t = {result.time_of_peak:.4f}s") - -# Apply a template -tmpl = Template.load("frontal_ncap") -session = tmpl.apply(test) - -# Generate protocol report -from impakt.protocol import euro_ncap -report = euro_ncap.evaluate(session, version="2024") -report.to_pdf("test_001_encap.pdf") - -# Launch web UI -from impakt.web import serve -serve(test, template="frontal_ncap", port=8050) -``` +`Session` is the primary entry point. The web UI's `AppState` holds `Session` objects — the same code path for scripts and UI. --- ## Data Flow -End-to-end data flow from MME files to PDF reports: - ```mermaid graph TB - MME["MME Directory\n(immutable)"] -->|"impakt.io"| TD[TestData Object] - TD -->|"channel access"| CH[Channel Objects] - CH -->|"impakt.transform"| TCH[Transformed Channels] - TCH -->|"impakt.criteria"| CR[Criterion Results] - CR -->|"impakt.protocol"| SC[Protocol Scores] + MME["MME Directory\n(immutable)"] -->|"impakt.io"| TD[TestData] + TD --> CH[Channels] + CH -->|"TransformChain"| TCH[Transformed Channels] + TCH -->|"criteria"| CR[CriterionResults] + CR -->|"protocol"| SC[ProtocolResult] - CH -->|"impakt.plot"| FIG1[Raw Plots] - TCH -->|"impakt.plot"| FIG2[Filtered Plots] - CR -->|"impakt.plot"| FIG3[Criteria Annotations] + CH & TCH -->|"PlotSpec"| PE[PlotEngine] + PE --> FIG[Plotly Figure] - FIG2 --> RP[impakt.report] - SC --> RP - RP --> PDF[PDF Report] + SC --> RP[Report Engine] + FIG --> RP + RP --> PDF[PDF/HTML] - TD -.->|"state saved"| IMPAKT_DIR[".impakt/ subfolder"] - IMPAKT_DIR -.->|"state loaded"| TD + CFG["Config\n(layered YAML)"] -.-> PE + CFG -.-> CR + CFG -.-> SC + + TD -.->|"state"| IMPAKT[".impakt/"] + IMPAKT -.->|"restore"| TD style MME fill:#e1f5fe - style IMPAKT_DIR fill:#fff9c4 + style IMPAKT fill:#fff9c4 style PDF fill:#c8e6c9 + style CFG fill:#f3e5f5 ``` --- -## Template and Session Lifecycle - -```mermaid -stateDiagram-v2 - [*] --> TemplateLibrary: User browses templates - - TemplateLibrary --> SessionCreated: Select template + open test data - SessionCreated --> Active: Engine applies template - - Active --> Active: User modifies\n(cursors, filters, channels) - Active --> Saved: Auto-save to .impakt/ - - Saved --> Active: Reopen test data - Saved --> TemplateLibrary: Promote overrides\nback to template - - Active --> ReportGenerated: User generates report - ReportGenerated --> Active: Continue analysis - - Active --> [*]: Close session -``` - ---- - -## Injury Criteria Pipeline - -How raw channel data flows through to a protocol rating: - -```mermaid -graph LR - subgraph "1. Channel Selection" - A1["HEAD AC X/Y/Z"] - A2["NECK FO X/Z"] - A3["NECK MO Y"] - A4["CHEST DC"] - A5["FEMUR FO Z L/R"] - A6["TIBIA FO + MO"] - end - - subgraph "2. Transform" - B1[CFC 1000\n+ Resultant] - B2[CFC 600] - B3[CFC 600] - B4[CFC 180] - B5[CFC 600] - B6[CFC 600] - end - - subgraph "3. Criteria" - C1[HIC15 / HIC36] - C2[Nij] - C3[Nij] - C4["Chest Defl\n3ms Clip\nViscous"] - C5[Femur Load] - C6[Tibia Index] - end - - subgraph "4. Protocol" - D1["Euro NCAP\nColor + Points"] - D2["US NCAP\nP(injury) + Stars"] - D3["IIHS\nG/A/M/P"] - end - - A1 --> B1 --> C1 - A2 --> B2 --> C2 - A3 --> B3 --> C3 - A4 --> B4 --> C4 - A5 --> B5 --> C5 - A6 --> B6 --> C6 - - C1 & C2 & C3 & C4 & C5 & C6 --> D1 - C1 & C2 & C3 & C4 & C5 --> D2 - C1 & C2 & C3 & C4 & C5 & C6 --> D3 -``` - ---- - -## Web UI Architecture +## Configuration Layers ```mermaid graph TB - subgraph "Browser" - UI[Dash Frontend] - PLOTS_V[Interactive Plots] - CURSOR[Dual X-Cursor + Values Table] - TREE[Channel Tree Browser] - TMPL_UI[Template Selector] - REPORT_UI[Report Generator Panel] + subgraph "Package (shipped with pip install)" + PKG_CFG[defaults/config.yaml] + PKG_PROTO[defaults/protocols/*.yaml] end - subgraph "Server" - DASH[Dash Server] - API[Impakt Script API] - CACHE_S[Computation Cache] + subgraph "User (~/.impakt/)" + USR_CFG[config.yaml] + USR_PROTO[protocols/*.yaml] + USR_TMPL[templates/*.yaml] + USR_CORR[corridors/*.csv] end - UI --> DASH - DASH --> API - API --> CACHE_S + subgraph "Test Session (/.impakt/)" + SES_CFG[config.yaml] + SES_PROTO[protocols/*.yaml] + SES_SESS[session.yaml] + SES_CORR[corridors/] + SES_DER[derived/] + end - TREE -->|"select channels"| PLOTS_V - TMPL_UI -->|"apply template"| API - CURSOR -->|"x1, x2 positions"| PLOTS_V - REPORT_UI -->|"generate"| API + PKG_CFG -->|"overridden by"| USR_CFG + USR_CFG -->|"overridden by"| SES_CFG + + PKG_PROTO -->|"copied to"| USR_PROTO + USR_PROTO -->|"copied to"| SES_PROTO ``` ---- - -## Plugin Architecture - -```mermaid -graph LR - subgraph "Extension Points" - R[Readers] - T[Transforms] - C[Criteria] - P[Protocols] - RT[Report Templates] - end - - REG[Plugin Registry] --> R & T & C & P & RT - - subgraph "Discovery" - EP[pyproject.toml\nentry_points] - DP[~/.impakt/plugins/] - EX[registry.register] - end - - EP & DP & EX --> REG -``` +**Save Session** copies config + protocols into the test's `.impakt/` folder. The test directory becomes self-contained and reproducible. --- ## ISO Channel Naming Intelligence -The `ChannelCode` parser powers auto-discovery, grouping, and human-readable descriptions throughout the tool. - ```mermaid graph LR - RAW["11HEAD0000H3ACXA"] --> PARSER[ChannelCode Parser] + RAW["11HEAD0000H3ACXP"] --> PARSER[ChannelCode Parser] - PARSER --> OBJ["Test Object: 11\nDriver, Hybrid III"] - PARSER --> LOC["Location: HEAD\nHead"] + PARSER --> OBJ["Object: 11\nDriver"] + PARSER --> LOC["Location: HEAD"] PARSER --> FINE["Fine: 0000\nCenter of Gravity"] + PARSER --> DUMMY["Dummy: H3\nHybrid III"] PARSER --> MEAS["Measurement: AC\nAcceleration"] - PARSER --> DIR["Direction: X\nLongitudinal"] - PARSER --> SENSE["Sense: A\nSAE Convention"] + PARSER --> DIR["Direction: X"] + PARSER --> SENSE["Sense: P"] - OBJ & LOC & FINE & MEAS & DIR --> GROUP["Group Key:\n11HEAD0000AC_A\n(X/Y/Z family)"] + OBJ & LOC & FINE & DUMMY & MEAS & DIR --> GROUP["Group Key\n(X/Y/Z family)"] - GROUP --> AUTO["Auto-features:\n- Resultant computation\n- Channel tree placement\n- Criteria channel matching\n- Human-readable label"] + GROUP --> AUTO["Auto-features:\n- Resultant computation\n- Criteria channel matching\n- Channel grid placement\n- Human-readable labels"] ``` -**Lookup tables** for all code segments are bundled with the package and extensible via plugins: - -| Segment | Examples | -|---|---| -| Test objects | `11` = Driver H3 50th, `12` = Passenger, `20` = Barrier, `30` = Pedestrian | -| Main locations | `HEAD`, `NECK`, `CHST`, `PELV`, `FEMR`, `TIBI`, `STCL`, `BPIL` | -| Fine locations | `0000` = CG, `UP00` = Upper, `LO00` = Lower, `LE00` = Left, `RI00` = Right | -| Measurements | `AC` = Acceleration, `FO` = Force, `MO` = Moment, `DS` = Displacement, `DC` = Deflection | -| Directions | `X` = Longitudinal, `Y` = Lateral, `Z` = Vertical, `R` = Resultant | - --- ## Directory Structure ``` impakt/ -├── pyproject.toml -├── README.md -├── src/ -│ └── impakt/ -│ ├── __init__.py -│ ├── io/ -│ │ ├── __init__.py -│ │ ├── reader.py # ReaderProtocol, ReaderRegistry -│ │ ├── mme.py # MMEReader -│ │ ├── tdms.py # TDMSReader (stub) -│ │ └── csv.py # CSVReader (stub) -│ ├── channel/ -│ │ ├── __init__.py -│ │ ├── model.py # Channel, TestData, TestMetadata -│ │ ├── code.py # ChannelCode parser -│ │ ├── group.py # ChannelGroup, auto-grouping -│ │ └── lookup.py # ISO code lookup tables -│ ├── transform/ -│ │ ├── __init__.py -│ │ ├── base.py # Transform protocol, TransformChain -│ │ ├── cfc.py # CFCFilter -│ │ ├── align.py # XAlign, YAlign -│ │ ├── resultant.py # Resultant -│ │ ├── math_expr.py # MathExpr -│ │ └── resample.py # Resample, Trim -│ ├── criteria/ -│ │ ├── __init__.py -│ │ ├── base.py # InjuryCriterion protocol -│ │ ├── hic.py # HIC15, HIC36 -│ │ ├── clip3ms.py # 3ms chest clip -│ │ ├── nij.py # Neck injury criterion -│ │ ├── chest.py # Chest deflection, Viscous criterion -│ │ ├── femur.py # Femur load -│ │ └── tibia.py # Tibia index -│ ├── protocol/ -│ │ ├── __init__.py -│ │ ├── base.py # Protocol protocol, version management -│ │ ├── euro_ncap.py # Euro NCAP scorer -│ │ ├── us_ncap.py # US NCAP scorer -│ │ ├── iihs.py # IIHS evaluator -│ │ └── thresholds/ # Versioned threshold YAML files -│ │ ├── euro_ncap_2024.yaml -│ │ ├── us_ncap_2023.yaml -│ │ └── iihs_2024.yaml -│ ├── plot/ -│ │ ├── __init__.py -│ │ ├── engine.py # PlotEngine (Plotly) -│ │ ├── spec.py # PlotSpec, ChannelRef, Corridor -│ │ ├── cursor.py # Dual X-cursor logic -│ │ └── export.py # PNG/SVG/PDF single-plot export -│ ├── report/ -│ │ ├── __init__.py -│ │ ├── engine.py # ReportEngine -│ │ ├── pdf.py # PDF generation (WeasyPrint) -│ │ └── templates/ # Jinja2 HTML report templates -│ │ ├── plot_sheet.html -│ │ ├── injury_summary.html -│ │ └── protocol_report.html -│ ├── template/ -│ │ ├── __init__.py -│ │ ├── model.py # Template, Session models -│ │ ├── library.py # Template library manager -│ │ └── session.py # Session persistence -│ ├── web/ -│ │ ├── __init__.py -│ │ ├── app.py # Dash application -│ │ ├── layout.py # UI layout components -│ │ ├── callbacks.py # Dash callbacks -│ │ └── assets/ # CSS, static files -│ ├── plugin/ -│ │ ├── __init__.py -│ │ ├── registry.py # PluginRegistry -│ │ └── discovery.py # Entry point + directory scanning -│ └── script/ -│ ├── __init__.py -│ └── api.py # Top-level scripting API +├── pyproject.toml # PEP 621, uv dependency-groups +├── uv.lock +├── .gitignore +├── README.md # This file +├── BRAINSTORM.md # Feature ideas (80+) +├── docs/ +│ ├── STATUS.md # Detailed project state +│ └── QA-*.md # Quality assessment scorecards +├── research/ +│ └── landscape.md # Competitive landscape analysis +├── src/impakt/ +│ ├── __init__.py # exports Session, Template +│ ├── config/ # Layered YAML configuration +│ │ ├── __init__.py +│ │ └── model.py # Config, PlotConfig, TransformConfig, etc. +│ ├── defaults/ # Package-level defaults (shipped) +│ │ ├── config.yaml # All configurable fields with comments +│ │ └── protocols/ # Euro NCAP, IIHS threshold YAMLs +│ ├── channel/ # Data model + ISO naming +│ │ ├── code.py # ChannelCode parser (14/16-char auto-detect) +│ │ ├── model.py # Channel, ChannelGroup, TestData, TestMetadata +│ │ ├── group.py # Auto-grouping utilities +│ │ └── lookup.py # ISO naming lookup tables (150+ entries) +│ ├── io/ # Data readers +│ │ ├── reader.py # ReaderProtocol, ReaderRegistry +│ │ ├── mme.py # ISO 13499 MME reader (real + simplified) +│ │ ├── tdms.py # TDMS stub +│ │ └── csv.py # CSV stub +│ ├── transform/ # Signal processing +│ │ ├── base.py # Transform protocol, TransformChain +│ │ ├── cfc.py # SAE J211 CFC filter +│ │ ├── align.py # X-align, Y-align +│ │ ├── resultant.py # Vector magnitude +│ │ ├── math_expr.py # Safe math expressions +│ │ └── resample.py # Trim, Resample +│ ├── criteria/ # Injury criteria calculations +│ ├── protocol/ # Rating protocol scorers +│ │ ├── thresholds/ # Versioned YAML threshold files +│ │ └── *.py # Euro NCAP, US NCAP, IIHS +│ ├── plot/ # Plotly rendering engine +│ │ ├── engine.py # PlotEngine (single rendering path) +│ │ ├── spec.py # PlotSpec, ChannelRef, Corridor +│ │ └── export.py # Image/HTML export +│ ├── report/ # PDF/HTML report generation +│ │ ├── engine.py # Jinja2 + WeasyPrint +│ │ └── templates/ # HTML report templates +│ ├── template/ # Template + session persistence +│ ├── web/ # Dash web application +│ │ ├── app.py # App factory +│ │ ├── state.py # AppState (holds Sessions server-side) +│ │ ├── layout.py # Two-tab layout (Data + Analysis) +│ │ ├── components/ # 10 reusable UI components +│ │ ├── callbacks/ # 9 feature-specific callback modules +│ │ └── assets/ # CSS, JS (splitter, cursor tracker) +│ ├── plugin/ # Plugin registry + discovery +│ └── script/ # Session API + CLI +│ ├── api.py # Session, ChannelHandle, Template +│ └── cli.py # impakt serve/info/channels/evaluate ├── tests/ -│ ├── test_io/ -│ ├── test_channel/ -│ ├── test_transform/ -│ ├── test_criteria/ -│ ├── test_protocol/ -│ ├── test_plot/ -│ └── fixtures/ # Sample MME data for tests -└── docs/ +│ ├── fixtures/ # Synthetic MME test data (26 channels) +│ ├── mme_data/ # 5 real ISO 13499 datasets +│ └── test_*/ # 258 tests across all modules ``` --- ## Scripting Examples -### Quick injury summary +### One-call evaluation ```python from impakt import Session -from impakt.criteria import hic, clip_3ms, nij, chest_deflection, femur_load -from impakt.protocol import euro_ncap -test = Session.open("./test_001") +s = Session.open("tests/mme_data/3239") -results = { - "HIC15": hic(test, window_ms=15), - "3ms Clip": clip_3ms(test), - "Nij": nij(test), - "Chest Deflection": chest_deflection(test), - "Femur Left": femur_load(test, side="left"), - "Femur Right": femur_load(test, side="right"), -} - -for name, r in results.items(): - print(f"{name}: {r.value:.1f} {r.unit}") - -rating = euro_ncap.evaluate(results, version="2024") -print(f"\nEuro NCAP: {rating.stars} stars ({rating.adult_pct:.0f}% adult)") +# Auto-detect channels and score against Euro NCAP +result = s.evaluate("euro_ncap") +print(result.summary()) +# Euro NCAP 2024 +# Rating: ***** (5/5 stars) +# Score: 14.0/16.0 (88%) +# Head: 233.08 [green] (4.0/4.0) +# Chest: 44.04 g [yellow] (3.0/4.0) +# ... ``` -### Batch comparison across tests +### Fluent transform chaining ```python from impakt import Session -from impakt.plot import overlay -from pathlib import Path -tests = [Session.open(p) for p in Path("./tests").iterdir() if p.is_dir()] +s = Session.open("tests/mme_data/3239") -overlay( - [t.channel("11HEAD0000ACXA").transform.cfc(1000) for t in tests], - labels=[t.metadata.test_number for t in tests], - title="Head X Acceleration Comparison", - corridors=["corridors/head_ax_corridor.csv"], -).to_pdf("head_comparison.pdf") +# Each transform returns a ChannelHandle — fully chainable +ch = ( + s.channel("11HEAD0000H3ACXP") + .transform.cfc(1000) + .transform.y_align() + .transform.trim(t_start=0.0, t_end=0.1) +) +print(f"Peak: {ch.peak:.1f} {ch.unit}") +``` + +### Configuration override + +```python +from impakt import Session + +s = Session.open("tests/mme_data/3239") + +# Override default CFC for this session +s.config.transforms.default_cfc = 600 +s.config.plot.line_width = 2.0 +s.save_config() # writes to 3239/.impakt/config.yaml ``` ### Custom math channel @@ -1218,49 +533,27 @@ overlay( from impakt import Session from impakt.transform import math_expr -test = Session.open("./test_001") +s = Session.open("tests/mme_data/3239") custom = math_expr( - expression="0.6 * chest_x + 0.4 * chest_z", + expression="sqrt(a**2 + b**2)", channels={ - "chest_x": test.channel("11CHST0000ACXA").transform.cfc(180), - "chest_z": test.channel("11CHST0000ACZA").transform.cfc(180), + "a": s.channel("11HEAD0000H3ACXP").raw, + "b": s.channel("11HEAD0000H3ACZP").raw, }, - name="Weighted Chest Metric", - unit="g", + name="Head XZ Resultant", + unit="m/s²", ) - -custom.plot(title="Custom Weighted Chest Acceleration") +print(f"Peak: {custom.peak:.1f} {custom.unit}") ``` -### Multi-test overlay with cursors - -```python -from impakt import Session -from impakt.plot import overlay, cursor_values - -test1 = Session.open("./test_001") -test2 = Session.open("./test_002") - -fig = overlay([ - test1.channel("11HEAD0000ACXA").transform.cfc(1000), - test2.channel("11HEAD0000ACXA").transform.cfc(1000), -]) - -vals = cursor_values(fig, x1=0.015, x2=0.062) -print(vals) -# channel value_at_x1 value_at_x2 delta -# 0 test_001/... 12.3 45.7 33.4 -# 1 test_002/... 11.8 42.1 30.3 -``` - -### Launch web UI with template +### Launch web UI ```python from impakt.web import serve from impakt import Session -test = Session.open("./test_001") -serve(test, template="frontal_ncap", port=8050) +s = Session.open("tests/mme_data/3239") +serve(s, port=8050) # Opens browser at http://localhost:8050 ``` diff --git a/docs/STATUS.md b/docs/STATUS.md index 7707437..24d1765 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,9 +2,8 @@ **Date:** 2026-04-11 **Version:** 0.1.0 -**Tests:** 240 passing (69.7% coverage) -**Source:** ~10,300 lines (py) | ~2,700 lines tests -**Quality:** 78.3/100 (Grade B) -- see `docs/QA-*.md` +**Tests:** 258 passing (70.4% coverage) +**Quality:** 83.7/100 (Grade B+) -- see `docs/QA-*.md` **Tooling:** uv (Python 3.12.12), hatchling build backend, ruff, mypy strict --- @@ -14,7 +13,7 @@ ```bash cd /Users/noise/Code/impakt uv sync --dev # install all dependencies -uv run pytest tests/ # run all 240 tests (with coverage) +uv run pytest tests/ # run all 258 tests (with coverage) uv run impakt info tests/mme_data/3239 # show test metadata uv run impakt serve tests/mme_data/3239 # launch web UI on :8050 ``` @@ -23,7 +22,8 @@ Scripting: ```python from impakt import Session s = Session.open("tests/mme_data/3239") -s.plot("11HEAD0000H3ACXP", "11HEAD0000H3ACYP", "11HEAD0000H3ACZP", cfc=1000) +result = s.evaluate("euro_ncap") +print(result.summary()) ``` --- @@ -35,12 +35,21 @@ impakt/ pyproject.toml # PEP 621 + uv dependency-groups uv.lock # lockfile .gitignore - README.md # Architecture docs with 16 Mermaid diagrams + README.md # Architecture docs with Mermaid diagrams BRAINSTORM.md # 80+ feature ideas docs/ STATUS.md # <-- you are here + QA-*.md # Quality assessment scorecards + research/ + landscape.md # Competitive landscape (15+ tools) src/impakt/ __init__.py # exports Session, Template + config/ # Layered YAML configuration + __init__.py # exports Config + model.py # Config class, typed sections, deep merge, save/load + defaults/ # Package-level defaults (shipped with install) + config.yaml # All configurable fields, commented + protocols/ # Euro NCAP + IIHS threshold YAMLs channel/ # Data model layer code.py # ISO channel code parser (14-char + 16-char auto-detect) model.py # Channel, ChannelGroup, TestData, TestMetadata @@ -48,13 +57,15 @@ impakt/ lookup.py # ISO naming lookup tables (150+ entries) io/ # I/O layer reader.py # ReaderProtocol, ReaderRegistry - mme.py # MMEReader (real ISO 13499 + synthetic INI) + mme.py # MMEReader (real ISO 13499 + simplified INI) + tdms.py # TDMSReader (stub) + csv.py # CSVReader (stub) transform/ # Signal processing - base.py # Transform protocol, TransformChain + base.py # Transform protocol, TransformChain (serializable) cfc.py # SAE J211 CFC filter (60/180/600/1000) align.py # X-align (time-zero), Y-align (offset) resultant.py # Vector magnitude from X/Y/Z - math_expr.py # Free-form math expressions + math_expr.py # Free-form math expressions (safe eval) resample.py # Trim, Resample criteria/ # Injury criteria base.py # CriterionResult, InjuryCriterion protocol @@ -64,15 +75,16 @@ impakt/ chest.py # Chest deflection, Viscous criterion femur.py # Femur load tibia.py # Tibia index - protocol/ # Rating protocols + protocol/ # Rating protocols (YAML thresholds) base.py # ProtocolResult, BodyRegionScore, Color, Rating - euro_ncap.py # Euro NCAP (color/points/stars, versioned) - us_ncap.py # US NCAP (injury probability/stars) - iihs.py # IIHS (G/A/M/P) - plot/ # Visualization engine - engine.py # PlotEngine (Plotly), cursor_values() - spec.py # PlotSpec, ChannelRef, Corridor, CursorValues - cursor.py # Dual X-cursor logic + euro_ncap.py # Euro NCAP (loads thresholds from YAML, fallback to Python) + us_ncap.py # US NCAP (logistic injury risk) + iihs.py # IIHS (loads thresholds from YAML) + thresholds/ # Versioned YAML threshold files + plot/ # Visualization engine (single rendering path) + engine.py # PlotEngine: render(PlotSpec), resample, focus + spec.py # PlotSpec, ChannelRef, Corridor, focus_index + cursor.py # Cursor value computation export.py # PNG/SVG/PDF/HTML export template/ # Templates & sessions model.py # TemplateSpec, SessionState (YAML serializable) @@ -80,246 +92,135 @@ impakt/ session.py # SessionManager (.impakt/ per test) report/ # Report generation engine.py # PDF/HTML via WeasyPrint + Jinja2 - templates/ # 3 Jinja2 HTML templates - plot_sheet.html - injury_summary.html - protocol_report.html + templates/ # 3 Jinja2 HTML report templates web/ # Dash web application app.py # App factory: create_app(), serve() - state.py # AppState: multi-test state, templates, sessions, corridors - layout.py # Top-level layout: Data tab + Analysis tab - components/ # Reusable layout components + state.py # AppState: holds Sessions, Config, resampler state + layout.py # Two-tab layout: Data + Analysis + components/ # 10 reusable layout components header.py # Navbar, test info panel, open/overlay modals channel_grid.py # Flat sortable DataTable with wildcard filter + facets - channel_values.py # Combined cursor + statistics table (live hover values) - transforms.py # CFC/align/resultant controls + per-channel overrides - plot_grid.py # Multi-pane plot area (1x1, 2x1, 1x2, 2x2, 3x1) - criteria.py # Auto-compute criteria, protocol scoring, results display + channel_values.py # Combined statistics + cursor table + transforms.py # CFC/align/resultant + per-channel overrides + plot_grid.py # Multi-pane plot area (1x1 through 3x1) + criteria.py # Auto-compute criteria, protocol scoring display corridors.py # Corridor upload (CSV) and management templates.py # Template library browser, save/apply/delete math_builder.py # Math expression builder with variable binding - report.py # Export panel (PNG/SVG/PDF, CSV, protocol report) - callbacks/ # Feature-specific callback modules - __init__.py # Registration hub: register_callbacks() + report.py # Export panel (CSV, PNG/SVG/PDF, protocol report) + callbacks/ # 9 feature-specific callback modules channel_callbacks.py # Selection, filtering, badges, per-channel overrides - plot_callbacks.py # Plot rendering, transform pipeline, corridor display + plot_callbacks.py # PlotSpec construction → PlotEngine rendering cursor_callbacks.py # Channel values table (live hover + X1/X2) - criteria_callbacks.py # Compute All button, protocol scoring + criteria_callbacks.py # Session.compute_criteria() + Session.evaluate() template_callbacks.py # Apply/save/delete templates, session auto-save - corridor_callbacks.py # CSV upload, corridor state management + corridor_callbacks.py # CSV upload, corridor state math_callbacks.py # Expression evaluation, derived channel injection file_callbacks.py # Open test / add overlay modals export_callbacks.py # CSV export, report generation assets/ # Browser-side static files - style.css # Custom CSS (compact layout, splitter, scrollbars) - splitter.js # Draggable panel splitter (pure JS, no deps) - cursor_tracker.js # Live cursor tracking (mousemove -> pixel-to-data-X) + style.css # Custom CSS + splitter.js # Draggable panel splitter + cursor_tracker.js # Live cursor tracking (mousemove → data coords) + channel_nav.js # Keyboard navigation for channel grid plugin/ # Plugin system - registry.py # PluginRegistry, discovery (entrypoints + dir) + registry.py # PluginRegistry, discovery, reader forwarding script/ # Scripting API + CLI - api.py # Session, ChannelHandle, TransformProxy, Template + api.py # Session (with Config), ChannelHandle, TransformProxy, Template cli.py # argparse CLI (serve/info/channels/evaluate) tests/ conftest.py # Synthetic channel fixtures - test_integration.py # Full pipeline against synthetic MME fixture + test_config.py # Config layered resolution, save/load, round-trip + test_integration.py # Full pipeline against synthetic fixture test_real_mme.py # 46 tests against 5 real ISO 13499 datasets - test_channel/ # ChannelCode parser, Channel model, TestData - test_criteria/ # HIC, Nij + test_scripting_api.py # Session, fluent chaining, compute_criteria, evaluate + test_template.py # Template YAML round-trip, library CRUD, session manager + test_channel/ # ChannelCode parser, Channel model + test_criteria/ # HIC, Nij, chest/femur/tibia/clip3ms/viscous test_io/ # MMEReader - test_protocol/ # Euro NCAP scoring - test_transform/ # CFC filter, alignment + test_plot/ # PlotEngine rendering (channels, corridors, focus, compact) + test_protocol/ # Euro NCAP, US NCAP, IIHS scoring + test_transform/ # CFC, alignment, math expressions, resultant, trim, resample test_web/ # AppState, app creation, channel grid, channel values, P2 features fixtures/ - generate_mme.py # Synthetic MME generator (26 channels, half-sine) - sample_mme/ # Generated synthetic test data + generate_mme.py # Synthetic MME generator (26 channels) + sample_mme/ # Generated fixture data mme_data/ # REAL ISO 13499 test data (5 datasets) 3239/ # NHTSA/Calspan, VW Passat frontal, 133 channels AK3T02FO/ # BASt, frontal 40% offset, 97 channels AK3T02SI/ # BASt, side impact, 97 channels VW1FGS15/ # Volkswagen, pedestrian headform, 10 channels 98_7707/ # UTAC, vehicle-to-vehicle (metadata only) - *.pdf, *.doc # ISO/TS 13499 reference documents ``` --- -## What Works +## What's Implemented -### All modules fully implemented and tested: - -| Module | Status | Notes | -|---|---|---| -| **Channel code parser** | Complete | Auto-detects 14-char (no dummy) vs 16-char (with dummy H3/P3/PC). | -| **Channel model** | Complete | Immutable channels, auto-grouping X/Y/Z, resultant computation. | -| **MME reader** | Complete | Real ISO 13499 (.mme + .chn index + .NNN data files) + simplified INI. Tested against 5 real datasets. | -| **CFC filtering** | Complete | SAE J211 compliant. 4th-order Butterworth, zero-phase. All 4 CFC classes. | -| **Alignment transforms** | Complete | X-align (manual/threshold/trigger), Y-align (baseline window). | -| **Resultant** | Complete | From ChannelGroup or arbitrary channels. | -| **Math expressions** | Complete | Safe eval with numpy functions. | -| **HIC** | Complete | HIC15/HIC36, cumulative integration, optimal window search. | -| **3ms clip** | Complete | Cumulative exceedance method. | -| **Nij** | Complete | 4 modes (NTE/NTF/NCE/NCF), per-dummy intercepts. | -| **Chest deflection** | Complete | Peak sternal displacement with unit/sanity validation. | -| **Viscous criterion** | Complete | V(t)*C(t) with chest depth per dummy type. | -| **Femur load** | Complete | Left/right, unit conversion. | -| **Tibia index** | Complete | M/Mc + F/Fc with intercepts. | -| **Euro NCAP** | Complete | Sliding-scale color/points, percentage to stars. Versioned thresholds. | -| **US NCAP** | Complete | Logistic injury risk functions, combined probability, star rating. | -| **IIHS** | Complete | G/A/M/P per body region, worst-case overall. | -| **Plot engine** | Complete | Plotly rendering, corridors, cursor values, export. | -| **Template model** | Complete | YAML serialize/deserialize, library manager, session persistence. | -| **Report engine** | Complete | HTML+WeasyPrint PDF, 3 Jinja2 templates. | -| **Plugin registry** | Complete | Entry point + directory + API discovery. | -| **CLI** | Complete | `impakt serve/info/channels/evaluate`. | -| **Web UI** | **Functional** | See details below. | - -### Web UI -- Current State - -Fully functional for daily crash test analysis: - -**Data Tab:** -- **Left panel** (resizable via draggable splitter): channel grid + transform controls -- **Channel grid**: flat sortable DataTable (#, ISO Code, Description, Unit, Min, Max), wildcard filter bar, facet dropdowns (body region, measurement, direction), multi-select with selection persisted across filtering, selected rows colored with plot trace colors (tinted background + left border) -- **Transform controls**: global CFC filter, Y-align, X-align (manual/threshold), resultant toggle, per-channel CFC overrides -- **Plot area** (fills remaining width): no legend (info in tables), tight margins, compact axis labels, X1/X2 vertical reference lines -- **Channel Values table** (directly below plot, minimal gap): combined statistics + cursor in one table. Columns: #, ISO Code, Description, Unit, Min, @Time, Max, @Time, X1, X2, Cursor. `table-layout: fixed` with percentage widths — Description fills remaining space. Rows colored with same plot trace colors. Cursor column updates live on mouse hover via custom JS tracker. - -**Analysis Tab:** -- **Injury Criteria**: auto-detect channels by ISO naming, compute HIC15/3ms clip/Nij/chest defl/femur/tibia, protocol scoring (Euro NCAP/US NCAP/IIHS) with color-coded results and star ratings -- **Math Expression Builder**: formula input, 3 variable bindings (a/b/c mapped to channel dropdowns), result injected into test data and auto-plotted -- **Template Management**: library browser, apply (resolves channel patterns + sets CFC), save current view as template, delete, session auto-save -- **Corridors**: CSV upload (time/lower/upper), rendered as filled band on plot -- **Export**: CSV of plotted data (with transforms), PNG/SVG/PDF buttons, protocol report generation - -**Consistent Color System:** -Every selected channel has a stable color index (position in selection order). The same color appears in: -- Plot traces -- Channel grid rows (tinted background + solid left border) -- Selected badges (colored dot) -- Channel Values table rows (tinted background + solid left border) - -### Key design decisions: - -1. **Immutable channels** -- transforms return new Channel objects; raw data never modified. -2. **`.impakt/` subfolder** -- session state stored alongside test data. -3. **Template/session split** -- templates are global recipes; sessions are per-test instances. -4. **AppState is server-side** -- numpy arrays stay in Python memory; Dash stores hold only lightweight keys. -5. **Channel keys use `test_id::channel_name`** -- enables multi-test overlay. -6. **Custom JS cursor tracking** -- bypasses Plotly's hover system with raw mousemove + pixel-to-data conversion. -7. **table-layout: fixed** on Channel Values -- percentage widths respected, Description column fills remaining space. -8. **Browser cache prevention** -- meta tags with Cache-Control: no-cache to prevent stale layout issues during development. -9. **Separate single-output callbacks** for DataTable properties -- avoids Dash KeyError when DataTable internally requests individual properties. +| Module | Status | +|---|---| +| Channel code parser | Complete — 14/16-char auto-detect, 150+ ISO codes | +| MME reader | Complete — real ISO 13499 + simplified INI, 5 real datasets | +| CFC filtering | Complete — SAE J211, all 4 classes | +| Alignment | Complete — X-align (manual/threshold/trigger), Y-align | +| Resultant | Complete — from groups or arbitrary channels | +| Math expressions | Complete — safe eval with numpy | +| HIC, 3ms clip, Nij, chest, femur, tibia, viscous | All complete | +| Euro NCAP, US NCAP, IIHS | Complete — YAML thresholds, versioned | +| PlotEngine | Complete — single rendering path, resampler, focus, corridors | +| Templates | Complete — YAML, library, save/apply/delete | +| Sessions | Complete — `.impakt/` persistence, auto-save | +| Configuration | Complete — 3-layer YAML, typed sections, save/load | +| Plugin system | Complete — entry points, directory, API discovery, reader forwarding | +| CLI | Complete — serve/info/channels/evaluate | +| Web UI | Functional — two tabs, channel grid, cursor tracking, criteria, templates, export | --- -## Roadmap +## Key Design Decisions -Informed by competitive landscape survey (`research/landscape.md`). No open-source web-based tool covers this domain end-to-end. See `BRAINSTORM.md` for full feature ideas with priority tiers. - -### Priority 3 — Performance & Rendering - -Goal: handle large datasets (500+ channels, 100kHz sample rates) without lag. - -1. **plotly-resampler integration** -- Drop-in `FigureResampler` wrapper for Plotly figures. Handles 110M+ points via LTTB downsampling. Works natively with Dash. Repo cloned at `research/repos/plotly-resampler/`. This is the single highest-impact performance improvement. -2. **Synchronized zoom/pan** -- When plotting multiple subplots, zoom/pan syncs across all panes sharing an X axis. Most-requested feature in crash test visualization. Implement via shared `xaxis` config or callback-based range sync. -3. **Lazy channel loading** -- Load `.dat` files on first access, not at `Session.open()`. Load headers eagerly, data lazily. Keeps startup fast for tests with 500+ channels. -4. **Channel sparklines** -- Tiny inline sparklines in the channel grid sidebar. Engineers visually scan 100+ channels before selecting. A 60px-wide sparkline column is transformative for signal browsing. - -### Priority 4 — Data Format Expansion - -Goal: read the world's crash test data, not just ISO MME. - -5. **UDS reader plugin** -- NHTSA's proprietary binary format. Required to access the largest public crash test database. The NHTSA-Tools Fortran source (`research/repos/NHTSA-Tools/`) documents the UDS spec. -6. **ASAM MDF reader plugin** -- Standard for ECU/CAN bus measurement data. Many labs record vehicle bus data alongside crash instrumentation. asammdf (`research/repos/asammdf/`) is a mature library — add as optional dependency. -7. **Flexible CSV reader** -- Column mapping, delimiter detection, header conventions. Engineers frequently receive data as CSV exports from other tools. - -### Priority 5 — Comparison & Reporting - -Goal: make multi-test comparison and deliverable generation effortless. - -8. **Quick comparison mode** -- Two tests side-by-side with synchronized cursors. One-click "compare" button. Color-by-test with channel differentiation via line dash. -9. **Multi-page PDF reports** -- Combine plots + injury summary + protocol rating into a single PDF with table of contents. Currently each report type is standalone. -10. **Excel export** -- Criteria results and cursor values to .xlsx. Engineers live in spreadsheets. -11. **Static HTML export** -- Bundle data + Plotly.js into a self-contained HTML file. Opens in any browser without a Python server. Learned from FalCon's CustomerView distribution model. - -### Priority 6 — Video & Advanced Analysis - -Goal: close the biggest remaining gap vs. commercial tools. - -12. **Video synchronization** -- Link high-speed camera footage with channel data. Scrubbing video moves the time cursor; moving the cursor seeks the video. Every major commercial competitor (measX, DIAdem, Kistler, FalCon) has this. Foxglove and Rerun demonstrate web-native approaches. -13. **Frequency spectrum viewer** -- FFT / PSD alongside time-domain plots. Diagnose noise, verify CFC filter behavior. -14. **Integration / differentiation transforms** -- Acceleration -> velocity -> displacement with cumulative unit tracking. -15. **Data quality dashboard** -- Automated polarity check, sensor sanity, missing channel detection, completeness scoring. No commercial competitor is strong here — opportunity to differentiate. - -### Priority 7 — Simulation Correlation & Ecosystem - -Goal: bridge the gap between physical test and CAE simulation. - -16. **LS-DYNA data import** via lasso-python (`research/repos/lasso-python/`). Enables test-vs-simulation overlay — a premium feature in Altair HyperGraph and Siemens Simcenter. -17. **ISO/TS 18571 CORA correlation** -- Quantitative rating of test-vs-simulation agreement. Standard metric for model validation. -18. **Additional injury criteria** -- BrIC, DAMAGE, TTI, pedestrian criteria, OLC. Required for broader Euro NCAP coverage. -19. **Additional NCAP programs** -- J-NCAP, C-NCAP, K-NCAP, ANCAP, Latin NCAP as protocol plugins. -20. **Jupyter integration** -- `_repr_html_` on Session, Channel, ProtocolResult for rich notebook output. - -### Validation (ongoing) - -- **Cross-validate CFC filter** against PyAvia's J211_2pole (`research/repos/pyavia/`) and NHTSA-Tools' BwFilt. PyAvia's author notes that scipy's generic `sosfiltfilt` may differ from the SAE J211 Appendix C digital Butterworth algorithm for CFC 60 and 180. -- **Cross-validate injury criteria** against NHTSA-Tools Fortran reference implementations, pyisomme, and EPFL crash-tests-service-robots. Four independent codebases available in `research/repos/`. +1. **Immutable channels** — transforms return new Channel objects +2. **`.impakt/` subfolder** — session state + config alongside test data +3. **3-layer config** — package defaults → user → session (YAML) +4. **AppState holds Sessions** — web UI routes through the scripting API +5. **PlotEngine is the single rendering path** — both scripts and web build PlotSpec +6. **TransformChain used in web layer** — serializable, reproducible pipelines +7. **Custom JS cursor tracking** — mousemove + Plotly axis p2d for full-area coverage +8. **Protocol thresholds in YAML** — user-editable, copied to session on save +9. **Plugin readers forwarded to IO registry** — discoverable by Session.open() --- -## Test Data Available +## Next Steps -| Dataset | Lab | Type | Channels | Good for testing | -|---|---|---|---|---| -| `fixtures/sample_mme/` | Synthetic | Frontal barrier | 26 | Unit tests, known values | -| `mme_data/3239/` | NHTSA/Calspan | Frontal barrier (VW Passat) | 133 | Full pipeline, real data | -| `mme_data/AK3T02FO/` | BASt | Frontal 40% offset | 97 | Multi-occupant | -| `mme_data/AK3T02SI/` | BASt | Side impact | 97 | Side impact protocols | -| `mme_data/VW1FGS15/` | Volkswagen | Pedestrian headform | 10 | Impactor codes (D0) | -| `mme_data/98_7707/` | UTAC | Vehicle-to-vehicle | 0 | Metadata-only | +**Priority 3 features:** +1. Annotations — text on plots, measurement lines, highlight regions +2. Comparison mode — delta channels, side-by-side tests, synced cursors +3. Report builder — template-based multi-page PDF composition +4. Keyboard shortcuts — Ctrl+O, Ctrl+S, R reset zoom, C cursor lock + +**Quality targets:** +- Test coverage: 70% → 80% +- mypy errors: 34 → <10 +- Files >300 lines: 8 → ≤5 + +--- + +## Test Data + +| Dataset | Lab | Type | Channels | +|---|---|---|---| +| `fixtures/sample_mme/` | Synthetic | Frontal barrier | 26 | +| `mme_data/3239/` | NHTSA/Calspan | Frontal barrier (VW Passat) | 133 | +| `mme_data/AK3T02FO/` | BASt | Frontal 40% offset | 97 | +| `mme_data/AK3T02SI/` | BASt | Side impact | 97 | +| `mme_data/VW1FGS15/` | Volkswagen | Pedestrian headform | 10 | +| `mme_data/98_7707/` | UTAC | Vehicle-to-vehicle | 0 (metadata only) | --- ## Dependencies -Core: numpy, scipy, plotly, dash, dash-bootstrap-components, pandas, pyyaml, jinja2, weasyprint, pydantic +Core: numpy, scipy, plotly, plotly-resampler, dash, dash-bootstrap-components, pandas, pyyaml, jinja2, weasyprint, pydantic, pytz Dev: pytest, pytest-cov, ruff, mypy Optional: nptdms (TDMS reader plugin) -Planned: plotly-resampler (P3), asammdf (P4), openpyxl (P5), lasso-python (P7) - ---- - -## Known Issues / Technical Debt - -1. **VehicleInfo.year parsed as 0** for real MME data (.mme format embeds year in vehicle name string). -2. **Speed displayed as raw float** (55.900001530350906 km/h) -- should round. -3. **DataTable deprecation warning** -- Dash recommends migrating to dash-ag-grid. -4. **Cursor poll interval (80ms)** -- slight latency in cursor grid updates. -5. **Chest deflection auto-detect** skips DS channels with peak > 150mm to avoid steering column displacement. -6. **CFC filter implementation** uses scipy `sosfiltfilt` which may diverge from SAE J211 Appendix C for CFC 60/180. Needs cross-validation against PyAvia and NHTSA-Tools reference implementations. - ---- - -## Quality Assurance - -Automated QA scoring is configured: -- **Scoring agent:** `.claude/agents/quality-scorer.md` -- collects metrics, applies rubrics, writes report -- **Improvement agent:** `.claude/agents/qa-improver.md` -- reads QA report, auto-fixes mechanical issues -- **Methodology:** `docs/QA-INSTRUCTIONS.md` -- reproducible 8-dimension rubric -- **Reports:** `docs/QA-*.md` -- timestamped scorecards with deltas -- **Scripts:** `scripts/qa-score.sh` (score only), `scripts/qa-improve.sh` (score -> fix -> re-score) - ---- - -## Competitive Landscape - -Full survey at `research/landscape.md` with 11 cloned open-source repos in `research/repos/`. - -**Key finding:** No existing tool combines open-source + web-based + ISO-MME + CFC + injury criteria + protocol scoring + templates + reports. Commercial tools (measX X-Crash, NI DIAdem, Kistler) do this but are expensive and Windows-only. Impakt occupies a genuinely unserved niche. - -**Most actionable libraries:** -- plotly-resampler (performance) -- `research/repos/plotly-resampler/` -- asammdf (MDF format) -- `research/repos/asammdf/` -- lasso-python (LS-DYNA) -- `research/repos/lasso-python/` -- PyAvia, NHTSA-Tools (validation) -- `research/repos/pyavia/`, `research/repos/NHTSA-Tools/` diff --git a/src/impakt/config/__init__.py b/src/impakt/config/__init__.py new file mode 100644 index 0000000..81506fc --- /dev/null +++ b/src/impakt/config/__init__.py @@ -0,0 +1,34 @@ +"""Layered configuration system for Impakt. + +Configuration is resolved in three layers (most specific wins): + +1. **Package defaults** — shipped with Impakt in ``src/impakt/defaults/`` +2. **User defaults** — ``~/.impakt/config.yaml`` +3. **Test session** — ``/.impakt/config.yaml`` + +Usage:: + + from impakt.config import Config + + # Load with all layers + config = Config.load() + + # Load for a specific test session + config = Config.load(session_path=Path("tests/mme_data/3239")) + + # Access values + config.plot.colors # list of hex color strings + config.transforms.default_cfc # int or None + config.protocols.default # "euro_ncap" + + # Override and save to user level + config.plot.line_width = 2.0 + config.save_user() + + # Save current state as session config + config.save_session(Path("tests/mme_data/3239")) +""" + +from impakt.config.model import Config + +__all__ = ["Config"] diff --git a/src/impakt/config/model.py b/src/impakt/config/model.py new file mode 100644 index 0000000..6eb06f6 --- /dev/null +++ b/src/impakt/config/model.py @@ -0,0 +1,376 @@ +"""Configuration model with layered YAML resolution. + +The Config class loads YAML configuration files from three locations +and merges them (deep merge, most specific wins): + + Package defaults → User defaults → Test session + +All fields have sensible defaults defined in the package's +``defaults/config.yaml``. Users override at ``~/.impakt/config.yaml``. +Per-test overrides go in ``/.impakt/config.yaml``. +""" + +from __future__ import annotations + +import copy +import logging +import shutil +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +logger = logging.getLogger(__name__) + +# Paths +_PACKAGE_DEFAULTS_DIR = Path(__file__).parent.parent / "defaults" +_PACKAGE_CONFIG = _PACKAGE_DEFAULTS_DIR / "config.yaml" +_USER_DIR = Path.home() / ".impakt" +_USER_CONFIG = _USER_DIR / "config.yaml" +_SESSION_DIR_NAME = ".impakt" +_SESSION_CONFIG_NAME = "config.yaml" + + +def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Deep-merge two dicts. Values in ``override`` win over ``base``. + + Nested dicts are merged recursively. Lists and scalars are replaced. + """ + result = copy.deepcopy(base) + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = _deep_merge(result[key], value) + else: + result[key] = copy.deepcopy(value) + return result + + +def _load_yaml(path: Path) -> dict[str, Any]: + """Load a YAML file, returning empty dict on failure.""" + if not path.exists(): + return {} + try: + text = path.read_text(encoding="utf-8") + data = yaml.safe_load(text) + return data if isinstance(data, dict) else {} + except Exception as e: + logger.warning("Failed to load config %s: %s", path, e) + return {} + + +# --------------------------------------------------------------------------- +# Typed config sections +# --------------------------------------------------------------------------- + + +@dataclass +class PlotConfig: + """Plot appearance settings.""" + + colors: list[str] = field( + default_factory=lambda: [ + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#d62728", + "#9467bd", + "#8c564b", + "#e377c2", + "#7f7f7f", + "#bcbd22", + "#17becf", + ] + ) + line_width: float = 1.5 + focus_color: str = "#ffc107" + focus_line_width: float = 2.5 + x_label: str = "Time (s)" + x_label_font_size: int = 10 + y_label_font_size: int = 10 + axis_label_color: str = "#999999" + margin_compact: dict[str, int] = field( + default_factory=lambda: { + "left": 45, + "right": 8, + "top": 4, + "bottom": 28, + } + ) + margin_standard: dict[str, int] = field( + default_factory=lambda: { + "left": 60, + "right": 20, + "top": 10, + "bottom": 60, + } + ) + cursor_x1_color: str = "rgba(220,53,69,0.6)" + cursor_x2_color: str = "rgba(13,110,253,0.6)" + cursor_line_width: int = 1 + cursor_annotation_font_size: int = 9 + show_grid: bool = True + grid_color: str = "rgba(128,128,128,0.2)" + resample_enabled: bool = True + resample_n_shown: int = 1500 + + +@dataclass +class TransformConfig: + """Default transform settings.""" + + default_cfc: int | None = None + default_y_align: bool = False + default_x_align: str = "none" + default_x_align_value: float | None = None + + +@dataclass +class CriteriaConfig: + """Injury criteria auto-detection settings.""" + + channel_patterns: dict[str, list[str]] = field( + default_factory=lambda: { + "head_accel": ["*HEAD*AC{X,Y,Z}*", "*HEAD*AC*"], + "chest_accel": ["*CHST0000*AC{X,Y,Z}*", "*CHST*AC*"], + "neck_fz": ["*NECKUP*FO*Z*"], + "neck_my": ["*NECKUP*MO*Y*"], + "chest_deflection": ["*CHST*DC*"], + "femur_left": ["*FEMRLE*FO*Z*"], + "femur_right": ["*FEMRRI*FO*Z*"], + "tibia_fz": ["*TIBI*FO*Z*"], + "tibia_mx": ["*TIBI*MO*X*"], + "tibia_my": ["*TIBI*MO*Y*"], + } + ) + chest_deflection_max_peak_mm: float = 150.0 + + +@dataclass +class ProtocolConfig: + """Protocol scoring settings.""" + + default: str = "euro_ncap" + versions: dict[str, str] = field( + default_factory=lambda: { + "euro_ncap": "2024", + "us_ncap": "2023", + "iihs": "2024", + } + ) + + +@dataclass +class SessionConfig: + """Session behavior settings.""" + + auto_save: bool = True + dir_name: str = ".impakt" + + +@dataclass +class WebConfig: + """Web UI preferences.""" + + default_layout: str = "1x1" + cursor_poll_interval_ms: int = 80 + default_port: int = 8050 + + +# --------------------------------------------------------------------------- +# Main Config class +# --------------------------------------------------------------------------- + + +class Config: + """Layered configuration for Impakt. + + Resolves settings from three sources (most specific wins): + 1. Package defaults (``src/impakt/defaults/config.yaml``) + 2. User defaults (``~/.impakt/config.yaml``) + 3. Test session (``/.impakt/config.yaml``) + + Access typed sections via ``config.plot``, ``config.transforms``, etc. + """ + + def __init__(self, raw: dict[str, Any] | None = None) -> None: + self._raw = raw or {} + self.plot = self._build_section(PlotConfig, "plot") + self.transforms = self._build_section(TransformConfig, "transforms") + self.criteria = self._build_section(CriteriaConfig, "criteria") + self.protocols = self._build_section(ProtocolConfig, "protocols") + self.session = self._build_section(SessionConfig, "session") + self.web = self._build_section(WebConfig, "web") + + def _build_section(self, cls: type, key: str) -> Any: + """Build a typed config section from raw dict data.""" + section_data = self._raw.get(key, {}) + if not isinstance(section_data, dict): + section_data = {} + # Filter to only fields the dataclass accepts + import dataclasses + + valid_fields = {f.name for f in dataclasses.fields(cls)} + filtered = {k: v for k, v in section_data.items() if k in valid_fields} + try: + return cls(**filtered) + except TypeError: + return cls() + + @classmethod + def load(cls, session_path: Path | str | None = None) -> Config: + """Load configuration with layered resolution. + + Args: + session_path: Path to the test data directory. If provided, + loads session-specific overrides from + ``/.impakt/config.yaml``. + + Returns: + Fully resolved Config instance. + """ + # Layer 1: Package defaults + raw = _load_yaml(_PACKAGE_CONFIG) + + # Layer 2: User defaults + user_data = _load_yaml(_USER_CONFIG) + if user_data: + raw = _deep_merge(raw, user_data) + + # Layer 3: Test session overrides + if session_path is not None: + session_config = Path(session_path) / _SESSION_DIR_NAME / _SESSION_CONFIG_NAME + session_data = _load_yaml(session_config) + if session_data: + raw = _deep_merge(raw, session_data) + + return cls(raw) + + @classmethod + def from_defaults(cls) -> Config: + """Load only package defaults (no user or session overrides).""" + raw = _load_yaml(_PACKAGE_CONFIG) + return cls(raw) + + def to_dict(self) -> dict[str, Any]: + """Serialize the current config to a dict.""" + import dataclasses + + result: dict[str, Any] = {} + for section_name in ("plot", "transforms", "criteria", "protocols", "session", "web"): + section = getattr(self, section_name) + result[section_name] = dataclasses.asdict(section) + return result + + def to_yaml(self) -> str: + """Serialize the current config to a YAML string.""" + return yaml.dump( + self.to_dict(), + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + ) + + def save_user(self) -> Path: + """Save the current config as user defaults. + + Writes to ``~/.impakt/config.yaml``. + """ + _USER_DIR.mkdir(parents=True, exist_ok=True) + path = _USER_CONFIG + path.write_text( + "# Impakt user configuration\n" + "# Override package defaults here. See defaults/config.yaml for all options.\n\n" + + self.to_yaml(), + encoding="utf-8", + ) + logger.info("Saved user config to %s", path) + return path + + def save_session(self, test_path: Path | str) -> Path: + """Save the current config as session-level overrides. + + Writes to ``/.impakt/config.yaml``. + Also copies protocol thresholds and corridor files into the session + directory so the test folder is self-contained. + """ + test_path = Path(test_path) + session_dir = test_path / _SESSION_DIR_NAME + session_dir.mkdir(parents=True, exist_ok=True) + + # Write config + config_path = session_dir / _SESSION_CONFIG_NAME + config_path.write_text( + "# Impakt session configuration\n" + f"# Test: {test_path.name}\n" + "# Overrides user and package defaults.\n\n" + self.to_yaml(), + encoding="utf-8", + ) + + # Copy protocol threshold files into session + self._copy_protocols_to_session(session_dir) + + logger.info("Saved session config to %s", config_path) + return config_path + + def _copy_protocols_to_session(self, session_dir: Path) -> None: + """Copy protocol threshold YAML files into the session directory.""" + dest = session_dir / "protocols" + dest.mkdir(parents=True, exist_ok=True) + + # Source priority: user dir → package defaults + for source_dir in [_USER_DIR / "protocols", _PACKAGE_DEFAULTS_DIR / "protocols"]: + if not source_dir.exists(): + continue + for yaml_file in source_dir.glob("*.yaml"): + dest_file = dest / yaml_file.name + if not dest_file.exists(): + shutil.copy2(yaml_file, dest_file) + logger.debug("Copied protocol %s to session", yaml_file.name) + + @staticmethod + def init_user_dir() -> Path: + """Initialize the user config directory with default files. + + Creates ``~/.impakt/`` with a copy of the default ``config.yaml`` + and protocol threshold files, if they don't already exist. + """ + _USER_DIR.mkdir(parents=True, exist_ok=True) + + # Copy default config if not present + if not _USER_CONFIG.exists(): + shutil.copy2(_PACKAGE_CONFIG, _USER_CONFIG) + logger.info("Initialized user config at %s", _USER_CONFIG) + + # Copy protocol thresholds + proto_dir = _USER_DIR / "protocols" + proto_dir.mkdir(parents=True, exist_ok=True) + source_proto = _PACKAGE_DEFAULTS_DIR / "protocols" + if source_proto.exists(): + for f in source_proto.glob("*.yaml"): + dest = proto_dir / f.name + if not dest.exists(): + shutil.copy2(f, dest) + + # Create templates directory + (_USER_DIR / "templates").mkdir(parents=True, exist_ok=True) + + # Create corridors directory + (_USER_DIR / "corridors").mkdir(parents=True, exist_ok=True) + + return _USER_DIR + + @property + def package_defaults_dir(self) -> Path: + """Path to the package defaults directory.""" + return _PACKAGE_DEFAULTS_DIR + + @property + def user_dir(self) -> Path: + """Path to the user configuration directory (~/.impakt/).""" + return _USER_DIR + + def __repr__(self) -> str: + cfc = self.transforms.default_cfc + proto = self.protocols.default + return f"Config(cfc={cfc}, protocol={proto}, colors={len(self.plot.colors)})" diff --git a/src/impakt/defaults/config.yaml b/src/impakt/defaults/config.yaml new file mode 100644 index 0000000..1d0032a --- /dev/null +++ b/src/impakt/defaults/config.yaml @@ -0,0 +1,164 @@ +# Impakt — Default Configuration +# +# This file defines the default settings for Impakt. Values here are +# the package-level defaults. They can be overridden at two levels: +# +# 1. User defaults: ~/.impakt/config.yaml +# 2. Test session: /.impakt/config.yaml +# +# The resolution order is: package → user → session (most specific wins). + +# --------------------------------------------------------------------------- +# Plot appearance +# --------------------------------------------------------------------------- +plot: + # Color palette for channel traces (colorblind-friendly). + # Colors are assigned in order: first selected channel gets colors[0], etc. + colors: + - "#1f77b4" # blue + - "#ff7f0e" # orange + - "#2ca02c" # green + - "#d62728" # red + - "#9467bd" # purple + - "#8c564b" # brown + - "#e377c2" # pink + - "#7f7f7f" # gray + - "#bcbd22" # olive + - "#17becf" # cyan + + # Default line width for traces (pixels) + line_width: 1.5 + + # Focus channel styling + focus_color: "#ffc107" # amber + focus_line_width: 2.5 + + # Axis labels + x_label: "Time (s)" + x_label_font_size: 10 + y_label_font_size: 10 + axis_label_color: "#999999" + + # Margins (pixels). Compact mode uses these; standard mode is wider. + margin_compact: + left: 45 + right: 8 + top: 4 + bottom: 28 + margin_standard: + left: 60 + right: 20 + top: 10 + bottom: 60 + + # Cursor lines + cursor_x1_color: "rgba(220,53,69,0.6)" + cursor_x2_color: "rgba(13,110,253,0.6)" + cursor_line_width: 1 + cursor_annotation_font_size: 9 + + # Grid + show_grid: true + grid_color: "rgba(128,128,128,0.2)" + + # Resampling (LTTB downsampling for large datasets) + resample_enabled: true + resample_n_shown: 1500 + +# --------------------------------------------------------------------------- +# Default transforms +# --------------------------------------------------------------------------- +transforms: + # Default CFC filter class applied to new channels. + # Options: null (no filter), 60, 180, 600, 1000 + default_cfc: null + + # Default Y-axis alignment. + # If true, baseline offset is removed using pre-trigger data. + default_y_align: false + + # Default X-axis alignment method. + # Options: "none", "manual", "threshold" + default_x_align: "none" + + # Default X-align value (time offset in seconds, or threshold value) + default_x_align_value: null + +# --------------------------------------------------------------------------- +# Injury criteria auto-detection +# --------------------------------------------------------------------------- +criteria: + # Channel patterns for auto-detection. The criteria engine searches + # for channels matching these patterns (glob-style) and computes the + # corresponding criterion. + # + # Each entry maps a criterion name to a list of channel patterns. + # The first match wins. Patterns use * wildcards. + channel_patterns: + head_accel: + - "*HEAD*AC{X,Y,Z}*" + - "*HEAD*AC*" + chest_accel: + - "*CHST0000*AC{X,Y,Z}*" + - "*CHST*AC*" + neck_fz: + - "*NECKUP*FO*Z*" + neck_my: + - "*NECKUP*MO*Y*" + chest_deflection: + - "*CHST*DC*" + femur_left: + - "*FEMRLE*FO*Z*" + femur_right: + - "*FEMRRI*FO*Z*" + tibia_fz: + - "*TIBI*FO*Z*" + tibia_mx: + - "*TIBI*MO*X*" + tibia_my: + - "*TIBI*MO*Y*" + + # Maximum chest deflection peak (mm) to consider a DS channel as + # actual chest deflection (vs. steering column displacement) + chest_deflection_max_peak_mm: 150.0 + +# --------------------------------------------------------------------------- +# Protocol scoring +# --------------------------------------------------------------------------- +protocols: + # Default protocol for the Analysis tab + default: "euro_ncap" + + # Available protocol versions. + # Threshold files are loaded from: + # 1. /.impakt/protocols/ + # 2. ~/.impakt/protocols/ + # 3. Package defaults (src/impakt/defaults/protocols/) + versions: + euro_ncap: "2024" + us_ncap: "2023" + iihs: "2024" + +# --------------------------------------------------------------------------- +# Session behavior +# --------------------------------------------------------------------------- +session: + # Auto-save session state on channel selection or transform changes. + auto_save: true + + # Directory name for session data inside test directories. + dir_name: ".impakt" + +# --------------------------------------------------------------------------- +# Web UI preferences +# --------------------------------------------------------------------------- +web: + # Default plot layout preset + default_layout: "1x1" + + # Cursor poll interval (milliseconds). Lower = more responsive but + # more CPU usage. Range: 30-200. + cursor_poll_interval_ms: 80 + + # Port for the web server + default_port: 8050 diff --git a/src/impakt/defaults/protocols/euro_ncap_2024.yaml b/src/impakt/defaults/protocols/euro_ncap_2024.yaml new file mode 100644 index 0000000..fb54bcd --- /dev/null +++ b/src/impakt/defaults/protocols/euro_ncap_2024.yaml @@ -0,0 +1,76 @@ +# Euro NCAP 2024 Adult Occupant Frontal Impact Thresholds +# +# Each criterion: [green, yellow, orange, brown, red, higher_is_worse, max_points] +# Sliding-scale: values at or below green = full points, at or above red = zero. + +HIC15: + green: 500.0 + yellow: 620.0 + orange: 700.0 + brown: 850.0 + red: 1000.0 + higher_is_worse: true + max_points: 4.0 + +3ms Clip: + green: 42.0 + yellow: 48.0 + orange: 54.0 + brown: 57.0 + red: 60.0 + higher_is_worse: true + max_points: 4.0 + +Chest Deflection: + green: 22.0 + yellow: 34.0 + orange: 42.0 + brown: 50.0 + red: 63.0 + higher_is_worse: true + max_points: 4.0 + +Nij: + green: 0.5 + yellow: 0.65 + orange: 0.8 + brown: 0.9 + red: 1.0 + higher_is_worse: true + max_points: 2.0 + +Femur Load Left: + green: 3.8 + yellow: 5.4 + orange: 7.0 + brown: 8.5 + red: 10.0 + higher_is_worse: true + max_points: 2.0 + +Femur Load Right: + green: 3.8 + yellow: 5.4 + orange: 7.0 + brown: 8.5 + red: 10.0 + higher_is_worse: true + max_points: 2.0 + +Tibia Index: + green: 0.4 + yellow: 0.7 + orange: 1.0 + brown: 1.15 + red: 1.3 + higher_is_worse: true + max_points: 2.0 + +Viscous Criterion: + green: 0.32 + yellow: 0.56 + orange: 0.8 + brown: 0.9 + red: 1.0 + higher_is_worse: true + max_points: 2.0 diff --git a/src/impakt/defaults/protocols/iihs_2024.yaml b/src/impakt/defaults/protocols/iihs_2024.yaml new file mode 100644 index 0000000..3c28d17 --- /dev/null +++ b/src/impakt/defaults/protocols/iihs_2024.yaml @@ -0,0 +1,40 @@ +# IIHS 2024 Crashworthiness Thresholds +# +# Each criterion: [good, acceptable, marginal, higher_is_worse] +# Values above marginal = Poor. + +HIC15: + good: 250.0 + acceptable: 500.0 + marginal: 700.0 + higher_is_worse: true + +Chest Deflection: + good: 38.0 + acceptable: 50.0 + marginal: 63.0 + higher_is_worse: true + +Femur Load Left: + good: 3.8 + acceptable: 6.2 + marginal: 10.0 + higher_is_worse: true + +Femur Load Right: + good: 3.8 + acceptable: 6.2 + marginal: 10.0 + higher_is_worse: true + +Nij: + good: 0.52 + acceptable: 0.78 + marginal: 1.0 + higher_is_worse: true + +Tibia Index: + good: 0.5 + acceptable: 0.8 + marginal: 1.3 + higher_is_worse: true diff --git a/src/impakt/plot/engine.py b/src/impakt/plot/engine.py index 6780eb3..9990132 100644 --- a/src/impakt/plot/engine.py +++ b/src/impakt/plot/engine.py @@ -23,7 +23,9 @@ from impakt.plot.spec import Corridor, CursorValues, PlotSpec logger = logging.getLogger(__name__) -# Default color palette (colorblind-friendly) +# Default color palette (colorblind-friendly). +# This is the hardcoded fallback. When Config is available, the palette +# is read from config.plot.colors instead. DEFAULT_COLORS = [ "#1f77b4", # blue "#ff7f0e", # orange @@ -38,6 +40,19 @@ DEFAULT_COLORS = [ ] +def get_colors() -> list[str]: + """Get the color palette, preferring Config if loaded.""" + try: + from impakt.config import Config + + config = Config.load() + if config.plot.colors: + return config.plot.colors + except Exception: + pass + return DEFAULT_COLORS + + class PlotEngine: """Renders PlotSpec into Plotly figures. diff --git a/src/impakt/script/api.py b/src/impakt/script/api.py index 182f38c..6661bf7 100644 --- a/src/impakt/script/api.py +++ b/src/impakt/script/api.py @@ -73,6 +73,11 @@ class Session: ) self._template: TemplateSpec | None = None + # Load layered config for this session + from impakt.config import Config + + self._config = Config.load(session_path=test_data.path) + @classmethod def _discover_plugins(cls) -> None: """Discover and register plugins. Called once on first Session.open().""" @@ -133,6 +138,23 @@ class Session: """Path to the test data directory.""" return self._data.path + @property + def config(self) -> Any: + """Layered configuration for this session. + + Resolves: package defaults → user defaults → session overrides. + """ + return self._config + + def save_config(self) -> Path | None: + """Save the current config to the session .impakt/ folder. + + Copies config.yaml and protocol thresholds into the test directory. + """ + if self._data.path: + return self._config.save_session(self._data.path) + return None + @property def channel_names(self) -> list[str]: return self._data.channel_names diff --git a/src/impakt/web/state.py b/src/impakt/web/state.py index 552703f..c6a4e58 100644 --- a/src/impakt/web/state.py +++ b/src/impakt/web/state.py @@ -16,6 +16,7 @@ from pathlib import Path from typing import Any from impakt.channel.model import Channel +from impakt.config import Config from impakt.script.api import Session from impakt.template.library import TemplateLibrary from impakt.template.model import PlotDefinition, TemplateSpec @@ -40,6 +41,8 @@ class AppState: # Current FigureResampler instance for zoom/pan resampling. # Stored here (not as a module global) so it's per-AppState. self.current_resampler: Any = None + # Layered configuration (loaded on first test load) + self._config: Config | None = None # Active corridors self.corridors: list[dict[str, Any]] = [] @@ -261,6 +264,24 @@ class AppState: result[session.test_id] = test_tree return result + # ----- Configuration ----- + + @property + def config(self) -> Config: + """Layered configuration. Loaded from the primary test's session path.""" + if self._config is None: + primary = self.primary_test + session_path = primary.path if primary else None + self._config = Config.load(session_path=session_path) + return self._config + + def save_config(self) -> None: + """Save current config to the primary test's .impakt/ folder.""" + primary = self.primary_test + if primary and primary.path: + self.config.save_session(primary.path) + logger.info("Saved session config for %s", primary.test_id) + @property def is_empty(self) -> bool: return len(self._sessions) == 0 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..f38c553 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,227 @@ +"""Tests for the layered configuration system.""" + +from pathlib import Path + +import pytest +import yaml + +from impakt.config import Config +from impakt.config.model import ( + CriteriaConfig, + PlotConfig, + ProtocolConfig, + SessionConfig, + TransformConfig, + WebConfig, + _deep_merge, +) + + +class TestDeepMerge: + def test_simple_override(self): + base = {"a": 1, "b": 2} + override = {"b": 3, "c": 4} + result = _deep_merge(base, override) + assert result == {"a": 1, "b": 3, "c": 4} + + def test_nested_merge(self): + base = {"plot": {"colors": ["red"], "width": 1}} + override = {"plot": {"width": 2}} + result = _deep_merge(base, override) + assert result["plot"]["colors"] == ["red"] + assert result["plot"]["width"] == 2 + + def test_list_replacement(self): + """Lists are replaced entirely, not merged.""" + base = {"items": [1, 2, 3]} + override = {"items": [4, 5]} + result = _deep_merge(base, override) + assert result["items"] == [4, 5] + + def test_deep_nested(self): + base = {"a": {"b": {"c": 1, "d": 2}}} + override = {"a": {"b": {"c": 99}}} + result = _deep_merge(base, override) + assert result["a"]["b"]["c"] == 99 + assert result["a"]["b"]["d"] == 2 + + +class TestConfigLoad: + def test_from_defaults(self): + config = Config.from_defaults() + assert config.plot.line_width == 1.5 + assert len(config.plot.colors) == 10 + assert config.transforms.default_cfc is None + assert config.protocols.default == "euro_ncap" + + def test_load_without_session(self): + config = Config.load() + assert isinstance(config.plot, PlotConfig) + assert isinstance(config.transforms, TransformConfig) + assert isinstance(config.criteria, CriteriaConfig) + assert isinstance(config.protocols, ProtocolConfig) + assert isinstance(config.session, SessionConfig) + assert isinstance(config.web, WebConfig) + + def test_load_with_nonexistent_session(self, tmp_path): + config = Config.load(session_path=tmp_path / "nonexistent") + # Should still load package defaults + assert config.plot.line_width == 1.5 + + def test_session_override(self, tmp_path): + # Create a session config with overrides + session_dir = tmp_path / ".impakt" + session_dir.mkdir() + session_config = session_dir / "config.yaml" + session_config.write_text( + yaml.dump( + { + "plot": {"line_width": 3.0}, + "transforms": {"default_cfc": 600}, + } + ), + encoding="utf-8", + ) + + config = Config.load(session_path=tmp_path) + assert config.plot.line_width == 3.0 + assert config.transforms.default_cfc == 600 + # Non-overridden values still come from defaults + assert len(config.plot.colors) == 10 + + def test_to_dict(self): + config = Config.from_defaults() + d = config.to_dict() + assert "plot" in d + assert "transforms" in d + assert "criteria" in d + assert "protocols" in d + assert d["plot"]["line_width"] == 1.5 + + def test_to_yaml(self): + config = Config.from_defaults() + yaml_str = config.to_yaml() + assert "line_width" in yaml_str + assert "default_cfc" in yaml_str + # Should be parseable + parsed = yaml.safe_load(yaml_str) + assert parsed["plot"]["line_width"] == 1.5 + + def test_repr(self): + config = Config.from_defaults() + r = repr(config) + assert "Config(" in r + assert "cfc=" in r + + +class TestConfigSave: + def test_save_session(self, tmp_path): + config = Config.from_defaults() + config.plot.line_width = 4.0 + config.transforms.default_cfc = 1000 + + path = config.save_session(tmp_path) + assert path.exists() + assert (tmp_path / ".impakt" / "config.yaml").exists() + + # Reload and verify + reloaded = Config.load(session_path=tmp_path) + assert reloaded.plot.line_width == 4.0 + assert reloaded.transforms.default_cfc == 1000 + + def test_save_copies_protocols(self, tmp_path): + config = Config.from_defaults() + config.save_session(tmp_path) + + proto_dir = tmp_path / ".impakt" / "protocols" + assert proto_dir.exists() + # Should have at least euro_ncap and iihs + yaml_files = list(proto_dir.glob("*.yaml")) + assert len(yaml_files) >= 2 + + def test_save_user(self, tmp_path, monkeypatch): + # Redirect user dir to tmp + import impakt.config.model as cm + + monkeypatch.setattr(cm, "_USER_DIR", tmp_path) + monkeypatch.setattr(cm, "_USER_CONFIG", tmp_path / "config.yaml") + + config = Config.from_defaults() + config.plot.line_width = 5.0 + path = config.save_user() + assert path.exists() + + # Read it back + text = path.read_text() + assert "5.0" in text + + def test_init_user_dir(self, tmp_path, monkeypatch): + import impakt.config.model as cm + + monkeypatch.setattr(cm, "_USER_DIR", tmp_path / "test_impakt") + monkeypatch.setattr(cm, "_USER_CONFIG", tmp_path / "test_impakt" / "config.yaml") + + user_dir = Config.init_user_dir() + assert user_dir.exists() + assert (user_dir / "config.yaml").exists() + assert (user_dir / "templates").exists() + assert (user_dir / "corridors").exists() + assert (user_dir / "protocols").exists() + + +class TestConfigRoundTrip: + """Verify that save -> load preserves all values.""" + + def test_full_round_trip(self, tmp_path): + # Create config with non-default values + config = Config.from_defaults() + config.plot.line_width = 2.5 + config.plot.focus_color = "#ff0000" + config.transforms.default_cfc = 180 + config.transforms.default_y_align = True + config.criteria.chest_deflection_max_peak_mm = 200.0 + config.protocols.default = "iihs" + config.web.cursor_poll_interval_ms = 50 + + # Save + config.save_session(tmp_path) + + # Reload + reloaded = Config.load(session_path=tmp_path) + assert reloaded.plot.line_width == 2.5 + assert reloaded.plot.focus_color == "#ff0000" + assert reloaded.transforms.default_cfc == 180 + assert reloaded.transforms.default_y_align is True + assert reloaded.criteria.chest_deflection_max_peak_mm == 200.0 + assert reloaded.protocols.default == "iihs" + assert reloaded.web.cursor_poll_interval_ms == 50 + + +class TestConfigInSession: + """Verify Config integrates with Session.""" + + def test_session_has_config(self): + from impakt import Session + + s = Session.open("tests/fixtures/sample_mme") + assert s.config is not None + assert s.config.plot.line_width == 1.5 + + def test_session_save_config(self, tmp_path): + import shutil + + # Copy fixture to tmp so we can write .impakt/ + test_dir = tmp_path / "test_session" + shutil.copytree("tests/fixtures/sample_mme", test_dir) + + from impakt import Session + + s = Session.open(test_dir) + s.config.transforms.default_cfc = 600 + result = s.save_config() + assert result is not None + assert (test_dir / ".impakt" / "config.yaml").exists() + + # Reopen and verify + s2 = Session.open(test_dir) + assert s2.config.transforms.default_cfc == 600