Layered Configs

This commit is contained in:
2026-04-11 15:20:27 -04:00
parent a7625dc973
commit 08e33f083a
11 changed files with 1378 additions and 1209 deletions

1279
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,8 @@
**Date:** 2026-04-11 **Date:** 2026-04-11
**Version:** 0.1.0 **Version:** 0.1.0
**Tests:** 240 passing (69.7% coverage) **Tests:** 258 passing (70.4% coverage)
**Source:** ~10,300 lines (py) | ~2,700 lines tests **Quality:** 83.7/100 (Grade B+) -- see `docs/QA-*.md`
**Quality:** 78.3/100 (Grade B) -- see `docs/QA-*.md`
**Tooling:** uv (Python 3.12.12), hatchling build backend, ruff, mypy strict **Tooling:** uv (Python 3.12.12), hatchling build backend, ruff, mypy strict
--- ---
@@ -14,7 +13,7 @@
```bash ```bash
cd /Users/noise/Code/impakt cd /Users/noise/Code/impakt
uv sync --dev # install all dependencies 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 info tests/mme_data/3239 # show test metadata
uv run impakt serve tests/mme_data/3239 # launch web UI on :8050 uv run impakt serve tests/mme_data/3239 # launch web UI on :8050
``` ```
@@ -23,7 +22,8 @@ Scripting:
```python ```python
from impakt import Session from impakt import Session
s = Session.open("tests/mme_data/3239") 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 pyproject.toml # PEP 621 + uv dependency-groups
uv.lock # lockfile uv.lock # lockfile
.gitignore .gitignore
README.md # Architecture docs with 16 Mermaid diagrams README.md # Architecture docs with Mermaid diagrams
BRAINSTORM.md # 80+ feature ideas BRAINSTORM.md # 80+ feature ideas
docs/ docs/
STATUS.md # <-- you are here STATUS.md # <-- you are here
QA-*.md # Quality assessment scorecards
research/
landscape.md # Competitive landscape (15+ tools)
src/impakt/ src/impakt/
__init__.py # exports Session, Template __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 channel/ # Data model layer
code.py # ISO channel code parser (14-char + 16-char auto-detect) code.py # ISO channel code parser (14-char + 16-char auto-detect)
model.py # Channel, ChannelGroup, TestData, TestMetadata model.py # Channel, ChannelGroup, TestData, TestMetadata
@@ -48,13 +57,15 @@ impakt/
lookup.py # ISO naming lookup tables (150+ entries) lookup.py # ISO naming lookup tables (150+ entries)
io/ # I/O layer io/ # I/O layer
reader.py # ReaderProtocol, ReaderRegistry 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 transform/ # Signal processing
base.py # Transform protocol, TransformChain base.py # Transform protocol, TransformChain (serializable)
cfc.py # SAE J211 CFC filter (60/180/600/1000) cfc.py # SAE J211 CFC filter (60/180/600/1000)
align.py # X-align (time-zero), Y-align (offset) align.py # X-align (time-zero), Y-align (offset)
resultant.py # Vector magnitude from X/Y/Z 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 resample.py # Trim, Resample
criteria/ # Injury criteria criteria/ # Injury criteria
base.py # CriterionResult, InjuryCriterion protocol base.py # CriterionResult, InjuryCriterion protocol
@@ -64,15 +75,16 @@ impakt/
chest.py # Chest deflection, Viscous criterion chest.py # Chest deflection, Viscous criterion
femur.py # Femur load femur.py # Femur load
tibia.py # Tibia index tibia.py # Tibia index
protocol/ # Rating protocols protocol/ # Rating protocols (YAML thresholds)
base.py # ProtocolResult, BodyRegionScore, Color, Rating base.py # ProtocolResult, BodyRegionScore, Color, Rating
euro_ncap.py # Euro NCAP (color/points/stars, versioned) euro_ncap.py # Euro NCAP (loads thresholds from YAML, fallback to Python)
us_ncap.py # US NCAP (injury probability/stars) us_ncap.py # US NCAP (logistic injury risk)
iihs.py # IIHS (G/A/M/P) iihs.py # IIHS (loads thresholds from YAML)
plot/ # Visualization engine thresholds/ # Versioned YAML threshold files
engine.py # PlotEngine (Plotly), cursor_values() plot/ # Visualization engine (single rendering path)
spec.py # PlotSpec, ChannelRef, Corridor, CursorValues engine.py # PlotEngine: render(PlotSpec), resample, focus
cursor.py # Dual X-cursor logic spec.py # PlotSpec, ChannelRef, Corridor, focus_index
cursor.py # Cursor value computation
export.py # PNG/SVG/PDF/HTML export export.py # PNG/SVG/PDF/HTML export
template/ # Templates & sessions template/ # Templates & sessions
model.py # TemplateSpec, SessionState (YAML serializable) model.py # TemplateSpec, SessionState (YAML serializable)
@@ -80,246 +92,135 @@ impakt/
session.py # SessionManager (.impakt/ per test) session.py # SessionManager (.impakt/ per test)
report/ # Report generation report/ # Report generation
engine.py # PDF/HTML via WeasyPrint + Jinja2 engine.py # PDF/HTML via WeasyPrint + Jinja2
templates/ # 3 Jinja2 HTML templates templates/ # 3 Jinja2 HTML report templates
plot_sheet.html
injury_summary.html
protocol_report.html
web/ # Dash web application web/ # Dash web application
app.py # App factory: create_app(), serve() app.py # App factory: create_app(), serve()
state.py # AppState: multi-test state, templates, sessions, corridors state.py # AppState: holds Sessions, Config, resampler state
layout.py # Top-level layout: Data tab + Analysis tab layout.py # Two-tab layout: Data + Analysis
components/ # Reusable layout components components/ # 10 reusable layout components
header.py # Navbar, test info panel, open/overlay modals header.py # Navbar, test info panel, open/overlay modals
channel_grid.py # Flat sortable DataTable with wildcard filter + facets channel_grid.py # Flat sortable DataTable with wildcard filter + facets
channel_values.py # Combined cursor + statistics table (live hover values) channel_values.py # Combined statistics + cursor table
transforms.py # CFC/align/resultant controls + per-channel overrides transforms.py # CFC/align/resultant + per-channel overrides
plot_grid.py # Multi-pane plot area (1x1, 2x1, 1x2, 2x2, 3x1) plot_grid.py # Multi-pane plot area (1x1 through 3x1)
criteria.py # Auto-compute criteria, protocol scoring, results display criteria.py # Auto-compute criteria, protocol scoring display
corridors.py # Corridor upload (CSV) and management corridors.py # Corridor upload (CSV) and management
templates.py # Template library browser, save/apply/delete templates.py # Template library browser, save/apply/delete
math_builder.py # Math expression builder with variable binding math_builder.py # Math expression builder with variable binding
report.py # Export panel (PNG/SVG/PDF, CSV, protocol report) report.py # Export panel (CSV, PNG/SVG/PDF, protocol report)
callbacks/ # Feature-specific callback modules callbacks/ # 9 feature-specific callback modules
__init__.py # Registration hub: register_callbacks()
channel_callbacks.py # Selection, filtering, badges, per-channel overrides 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) 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 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 math_callbacks.py # Expression evaluation, derived channel injection
file_callbacks.py # Open test / add overlay modals file_callbacks.py # Open test / add overlay modals
export_callbacks.py # CSV export, report generation export_callbacks.py # CSV export, report generation
assets/ # Browser-side static files assets/ # Browser-side static files
style.css # Custom CSS (compact layout, splitter, scrollbars) style.css # Custom CSS
splitter.js # Draggable panel splitter (pure JS, no deps) splitter.js # Draggable panel splitter
cursor_tracker.js # Live cursor tracking (mousemove -> pixel-to-data-X) cursor_tracker.js # Live cursor tracking (mousemove → data coords)
channel_nav.js # Keyboard navigation for channel grid
plugin/ # Plugin system plugin/ # Plugin system
registry.py # PluginRegistry, discovery (entrypoints + dir) registry.py # PluginRegistry, discovery, reader forwarding
script/ # Scripting API + CLI 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) cli.py # argparse CLI (serve/info/channels/evaluate)
tests/ tests/
conftest.py # Synthetic channel fixtures 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_real_mme.py # 46 tests against 5 real ISO 13499 datasets
test_channel/ # ChannelCode parser, Channel model, TestData test_scripting_api.py # Session, fluent chaining, compute_criteria, evaluate
test_criteria/ # HIC, Nij 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_io/ # MMEReader
test_protocol/ # Euro NCAP scoring test_plot/ # PlotEngine rendering (channels, corridors, focus, compact)
test_transform/ # CFC filter, alignment 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 test_web/ # AppState, app creation, channel grid, channel values, P2 features
fixtures/ fixtures/
generate_mme.py # Synthetic MME generator (26 channels, half-sine) generate_mme.py # Synthetic MME generator (26 channels)
sample_mme/ # Generated synthetic test data sample_mme/ # Generated fixture data
mme_data/ # REAL ISO 13499 test data (5 datasets) mme_data/ # REAL ISO 13499 test data (5 datasets)
3239/ # NHTSA/Calspan, VW Passat frontal, 133 channels 3239/ # NHTSA/Calspan, VW Passat frontal, 133 channels
AK3T02FO/ # BASt, frontal 40% offset, 97 channels AK3T02FO/ # BASt, frontal 40% offset, 97 channels
AK3T02SI/ # BASt, side impact, 97 channels AK3T02SI/ # BASt, side impact, 97 channels
VW1FGS15/ # Volkswagen, pedestrian headform, 10 channels VW1FGS15/ # Volkswagen, pedestrian headform, 10 channels
98_7707/ # UTAC, vehicle-to-vehicle (metadata only) 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 |
|---|---|
| Module | Status | Notes | | Channel code parser | Complete — 14/16-char auto-detect, 150+ ISO codes |
|---|---|---| | MME reader | Complete — real ISO 13499 + simplified INI, 5 real datasets |
| **Channel code parser** | Complete | Auto-detects 14-char (no dummy) vs 16-char (with dummy H3/P3/PC). | | CFC filtering | Complete — SAE J211, all 4 classes |
| **Channel model** | Complete | Immutable channels, auto-grouping X/Y/Z, resultant computation. | | Alignment | Complete — X-align (manual/threshold/trigger), Y-align |
| **MME reader** | Complete | Real ISO 13499 (.mme + .chn index + .NNN data files) + simplified INI. Tested against 5 real datasets. | | Resultant | Complete — from groups or arbitrary channels |
| **CFC filtering** | Complete | SAE J211 compliant. 4th-order Butterworth, zero-phase. All 4 CFC classes. | | Math expressions | Complete — safe eval with numpy |
| **Alignment transforms** | Complete | X-align (manual/threshold/trigger), Y-align (baseline window). | | HIC, 3ms clip, Nij, chest, femur, tibia, viscous | All complete |
| **Resultant** | Complete | From ChannelGroup or arbitrary channels. | | Euro NCAP, US NCAP, IIHS | Complete — YAML thresholds, versioned |
| **Math expressions** | Complete | Safe eval with numpy functions. | | PlotEngine | Complete — single rendering path, resampler, focus, corridors |
| **HIC** | Complete | HIC15/HIC36, cumulative integration, optimal window search. | | Templates | Complete — YAML, library, save/apply/delete |
| **3ms clip** | Complete | Cumulative exceedance method. | | Sessions | Complete — `.impakt/` persistence, auto-save |
| **Nij** | Complete | 4 modes (NTE/NTF/NCE/NCF), per-dummy intercepts. | | Configuration | Complete — 3-layer YAML, typed sections, save/load |
| **Chest deflection** | Complete | Peak sternal displacement with unit/sanity validation. | | Plugin system | Complete — entry points, directory, API discovery, reader forwarding |
| **Viscous criterion** | Complete | V(t)*C(t) with chest depth per dummy type. | | CLI | Complete — serve/info/channels/evaluate |
| **Femur load** | Complete | Left/right, unit conversion. | | Web UI | Functional — two tabs, channel grid, cursor tracking, criteria, templates, export |
| **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.
--- ---
## 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. 1. **Immutable channels** — transforms return new Channel objects
2. **`.impakt/` subfolder** — session state + config alongside test data
### Priority 3 — Performance & Rendering 3. **3-layer config** — package defaults → user → session (YAML)
4. **AppState holds Sessions** — web UI routes through the scripting API
Goal: handle large datasets (500+ channels, 100kHz sample rates) without lag. 5. **PlotEngine is the single rendering path** — both scripts and web build PlotSpec
6. **TransformChain used in web layer** — serializable, reproducible pipelines
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. 7. **Custom JS cursor tracking** — mousemove + Plotly axis p2d for full-area coverage
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. 8. **Protocol thresholds in YAML** — user-editable, copied to session on save
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. 9. **Plugin readers forwarded to IO registry** — discoverable by Session.open()
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/`.
--- ---
## Test Data Available ## Next Steps
| Dataset | Lab | Type | Channels | Good for testing | **Priority 3 features:**
|---|---|---|---|---| 1. Annotations — text on plots, measurement lines, highlight regions
| `fixtures/sample_mme/` | Synthetic | Frontal barrier | 26 | Unit tests, known values | 2. Comparison mode — delta channels, side-by-side tests, synced cursors
| `mme_data/3239/` | NHTSA/Calspan | Frontal barrier (VW Passat) | 133 | Full pipeline, real data | 3. Report builder — template-based multi-page PDF composition
| `mme_data/AK3T02FO/` | BASt | Frontal 40% offset | 97 | Multi-occupant | 4. Keyboard shortcuts — Ctrl+O, Ctrl+S, R reset zoom, C cursor lock
| `mme_data/AK3T02SI/` | BASt | Side impact | 97 | Side impact protocols |
| `mme_data/VW1FGS15/` | Volkswagen | Pedestrian headform | 10 | Impactor codes (D0) | **Quality targets:**
| `mme_data/98_7707/` | UTAC | Vehicle-to-vehicle | 0 | Metadata-only | - 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 ## 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 Dev: pytest, pytest-cov, ruff, mypy
Optional: nptdms (TDMS reader plugin) 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/`

View File

@@ -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** — ``<test_dir>/.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"]

376
src/impakt/config/model.py Normal file
View File

@@ -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 ``<test_dir>/.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 (``<test_dir>/.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
``<session_path>/.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 ``<test_path>/.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)})"

View File

@@ -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: <test_dir>/.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. <test_dir>/.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

View File

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

View File

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

View File

@@ -23,7 +23,9 @@ from impakt.plot.spec import Corridor, CursorValues, PlotSpec
logger = logging.getLogger(__name__) 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 = [ DEFAULT_COLORS = [
"#1f77b4", # blue "#1f77b4", # blue
"#ff7f0e", # orange "#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: class PlotEngine:
"""Renders PlotSpec into Plotly figures. """Renders PlotSpec into Plotly figures.

View File

@@ -73,6 +73,11 @@ class Session:
) )
self._template: TemplateSpec | None = None 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 @classmethod
def _discover_plugins(cls) -> None: def _discover_plugins(cls) -> None:
"""Discover and register plugins. Called once on first Session.open().""" """Discover and register plugins. Called once on first Session.open()."""
@@ -133,6 +138,23 @@ class Session:
"""Path to the test data directory.""" """Path to the test data directory."""
return self._data.path 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 @property
def channel_names(self) -> list[str]: def channel_names(self) -> list[str]:
return self._data.channel_names return self._data.channel_names

View File

@@ -16,6 +16,7 @@ from pathlib import Path
from typing import Any from typing import Any
from impakt.channel.model import Channel from impakt.channel.model import Channel
from impakt.config import Config
from impakt.script.api import Session from impakt.script.api import Session
from impakt.template.library import TemplateLibrary from impakt.template.library import TemplateLibrary
from impakt.template.model import PlotDefinition, TemplateSpec from impakt.template.model import PlotDefinition, TemplateSpec
@@ -40,6 +41,8 @@ class AppState:
# Current FigureResampler instance for zoom/pan resampling. # Current FigureResampler instance for zoom/pan resampling.
# Stored here (not as a module global) so it's per-AppState. # Stored here (not as a module global) so it's per-AppState.
self.current_resampler: Any = None self.current_resampler: Any = None
# Layered configuration (loaded on first test load)
self._config: Config | None = None
# Active corridors # Active corridors
self.corridors: list[dict[str, Any]] = [] self.corridors: list[dict[str, Any]] = []
@@ -261,6 +264,24 @@ class AppState:
result[session.test_id] = test_tree result[session.test_id] = test_tree
return result 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 @property
def is_empty(self) -> bool: def is_empty(self) -> bool:
return len(self._sessions) == 0 return len(self._sessions) == 0

227
tests/test_config.py Normal file
View File

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