Layered Configs
This commit is contained in:
323
docs/STATUS.md
323
docs/STATUS.md
@@ -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/`
|
|
||||||
|
|||||||
34
src/impakt/config/__init__.py
Normal file
34
src/impakt/config/__init__.py
Normal 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
376
src/impakt/config/model.py
Normal 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)})"
|
||||||
164
src/impakt/defaults/config.yaml
Normal file
164
src/impakt/defaults/config.yaml
Normal 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
|
||||||
76
src/impakt/defaults/protocols/euro_ncap_2024.yaml
Normal file
76
src/impakt/defaults/protocols/euro_ncap_2024.yaml
Normal 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
|
||||||
40
src/impakt/defaults/protocols/iihs_2024.yaml
Normal file
40
src/impakt/defaults/protocols/iihs_2024.yaml
Normal 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
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
227
tests/test_config.py
Normal 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
|
||||||
Reference in New Issue
Block a user