diff --git a/docs/STATUS.md b/docs/STATUS.md index 48de0af..91c0ca1 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,8 +2,8 @@ **Date:** 2026-04-10 **Version:** 0.1.0 -**Tests:** 171 passing -**Source:** ~9,200 lines Python | ~1,900 lines tests | ~3,400 lines web (py+js+css) | ~190 lines HTML templates +**Tests:** 181 passing +**Source:** ~11,000 lines (py+js+css+html) | ~2,150 lines tests **Tooling:** uv (Python 3.12.12), hatchling build backend --- @@ -13,7 +13,7 @@ ```bash cd /Users/noise/Code/impakt uv sync --dev # install all dependencies -uv run pytest tests/ # run all 171 tests +uv run pytest tests/ # run all 181 tests uv run impakt info tests/mme_data/3239 # show test metadata uv run impakt serve tests/mme_data/3239 # launch web UI on :8050 ``` @@ -85,22 +85,28 @@ impakt/ protocol_report.html web/ # Dash web application app.py # App factory: create_app(), serve() - state.py # AppState: server-side multi-test state manager - layout.py # Top-level layout: tabs, flex splitter, component assembly + state.py # AppState: multi-test state, templates, sessions, corridors + layout.py # Top-level layout: Data tab + Analysis tab components/ # Reusable layout components header.py # Navbar, test info panel, open/overlay modals channel_grid.py # Flat sortable DataTable with wildcard filter + facets - transforms.py # CFC/align/resultant control panel + channel_values.py # Combined cursor + statistics table (live hover values) + transforms.py # CFC/align/resultant controls + per-channel overrides plot_grid.py # Multi-pane plot area (1x1, 2x1, 1x2, 2x2, 3x1) - cursors.py # Cursor values grid (live hover + X1/X2 locked) criteria.py # Auto-compute criteria, protocol scoring, results display + corridors.py # Corridor upload (CSV) and management + templates.py # Template library browser, save/apply/delete + math_builder.py # Math expression builder with variable binding report.py # Export panel (PNG/SVG/PDF, CSV, protocol report) callbacks/ # Feature-specific callback modules __init__.py # Registration hub: register_callbacks() - channel_callbacks.py # Channel selection, filtering, badge display - plot_callbacks.py # Plot rendering, transform pipeline - cursor_callbacks.py # Live cursor grid updates via polled JS hover + channel_callbacks.py # Selection, filtering, badges, per-channel overrides + plot_callbacks.py # Plot rendering, transform pipeline, corridor display + cursor_callbacks.py # Channel values table (live hover + X1/X2) criteria_callbacks.py # Compute All button, protocol scoring + template_callbacks.py # Apply/save/delete templates, session auto-save + corridor_callbacks.py # CSV upload, corridor state management + math_callbacks.py # Expression evaluation, derived channel injection file_callbacks.py # Open test / add overlay modals export_callbacks.py # CSV export, report generation assets/ # Browser-side static files @@ -121,7 +127,7 @@ impakt/ test_io/ # MMEReader test_protocol/ # Euro NCAP scoring test_transform/ # CFC filter, alignment - test_web/ # AppState, app creation, channel grid, criteria auto-compute + test_web/ # AppState, app creation, channel grid, channel values, P2 features fixtures/ generate_mme.py # Synthetic MME generator (26 channels, half-sine) sample_mme/ # Generated synthetic test data @@ -168,41 +174,28 @@ impakt/ ### Web UI -- Current State -The web UI has been through a major overhaul and is now functional for daily use: +Fully functional for daily crash test analysis: -**Layout:** -- Two tabs: **Data** (channels + plot + cursor grid) and **Analysis** (criteria + export) -- Draggable splitter between left panel and plot area (pure JS, no deps) -- Left panel resizable from 200px to 600px - -**Channel Grid (left panel):** -- Flat sortable DataTable showing: #, ISO Code, Description, Unit, Min, Max -- Wildcard filter bar (`*HEAD*AC*`, `11*FO*Z*`, or partial text auto-wrapped) -- Facet dropdowns: body region, measurement type, direction -- Multi-select checkboxes, selected channels shown as color-coded badges -- Columns sortable and resizable (CSS resize) - -**Plot Area (right side):** -- Multi-pane layout presets (1x1, 2x1, 1x2, 2x2, 3x1) -- Transform controls: CFC filter, Y-align, X-align (manual/threshold), resultant toggle -- X1/X2 reference lines drawn on plot (red dashed / blue dashed) - -**Cursor Values Grid (below plot):** -- Live updating: vertical crosshair and cursor values track mouse movement anywhere in plot area -- Custom JS cursor tracker (mousemove -> Plotly axis p2d/p2l -> data coordinates) -- Polled via dcc.Interval (80ms) -> clientside callback -> server-side interpolation -- Columns: Channel, Unit, Cursor (live), X1 (locked), X2 (locked) +**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:** -- Auto-detect channels by ISO naming and compute HIC15, 3ms clip, Nij, chest defl, femur, tibia -- Protocol scoring: Euro NCAP / US NCAP / IIHS with color-coded results table and star ratings -- CSV export of plotted channel data (with transforms applied) -- Protocol report generation (HTML) +- **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 -**File Management:** -- Open Test / Add Overlay buttons with modal dialogs -- Test info panel showing all loaded tests with metadata -- Multi-test support in channel grid and plot labels +**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: @@ -211,20 +204,20 @@ The web UI has been through a major overhaul and is now functional for daily use 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 (which only fires near data points) with raw mousemove + pixel-to-data conversion using Plotly's internal axis API. -7. **uv for package management** -- dev deps in `[dependency-groups]`. +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. --- -## Next Steps (Priority 2) +## Next Steps (Priority 3) -These are the next features to build, now that Priority 1 is complete: - -1. **Template management UI** -- template browser, apply/save/edit from UI, session auto-save -2. **Enhanced transform controls** -- per-channel CFC, per-channel X/Y-align -3. **Corridor management** -- load from CSV, draw on plot, corridor library -4. **Channel inspector** -- tabular data view, statistics (min/max/RMS/peak time) -5. **Math expression builder** -- formula input with autocomplete, live preview +1. **Annotations** -- text on plots, measurement lines, highlight regions +2. **Comparison mode** -- side-by-side tests, delta plots, synced cursors +3. **Report builder** -- drag-and-drop report composer, PDF preview +4. **Keyboard shortcuts** -- Ctrl+O, Ctrl+S, 1-9 pane switch, F fullscreen, R reset zoom +5. **Consider Python/WASM frontend** -- NiceGUI or Solara for pure-Python UI (no JS) --- @@ -253,6 +246,7 @@ Optional: nptdms (for future TDMS reader plugin) 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. Functional for now. -4. **Cursor poll interval (80ms)** -- adds slight latency to cursor grid updates. Could use WebSocket for lower latency in future. -5. **Chest deflection auto-detect** skips DS channels with peak > 150mm (avoids steering column displacement). May miss some legitimate high-deflection data. +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. **Dead files** -- `cursors.py` and `inspector.py` were replaced by `channel_values.py` and deleted; `inspector_callbacks.py` deleted. If old `.pyc` files cause issues, run `find src -name __pycache__ -type d | xargs rm -rf`. diff --git a/src/impakt/__pycache__/__init__.cpython-314.pyc b/src/impakt/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index bf0cc3b..0000000 Binary files a/src/impakt/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/channel/__pycache__/__init__.cpython-314.pyc b/src/impakt/channel/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 55360f3..0000000 Binary files a/src/impakt/channel/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/channel/__pycache__/code.cpython-314.pyc b/src/impakt/channel/__pycache__/code.cpython-314.pyc deleted file mode 100644 index 1621bf4..0000000 Binary files a/src/impakt/channel/__pycache__/code.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/channel/__pycache__/group.cpython-314.pyc b/src/impakt/channel/__pycache__/group.cpython-314.pyc deleted file mode 100644 index 640e334..0000000 Binary files a/src/impakt/channel/__pycache__/group.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/channel/__pycache__/lookup.cpython-314.pyc b/src/impakt/channel/__pycache__/lookup.cpython-314.pyc deleted file mode 100644 index a573ccd..0000000 Binary files a/src/impakt/channel/__pycache__/lookup.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/channel/__pycache__/model.cpython-314.pyc b/src/impakt/channel/__pycache__/model.cpython-314.pyc deleted file mode 100644 index 5c31f23..0000000 Binary files a/src/impakt/channel/__pycache__/model.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/__init__.cpython-314.pyc b/src/impakt/criteria/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 2420729..0000000 Binary files a/src/impakt/criteria/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/base.cpython-314.pyc b/src/impakt/criteria/__pycache__/base.cpython-314.pyc deleted file mode 100644 index ba928fb..0000000 Binary files a/src/impakt/criteria/__pycache__/base.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/chest.cpython-314.pyc b/src/impakt/criteria/__pycache__/chest.cpython-314.pyc deleted file mode 100644 index de560ec..0000000 Binary files a/src/impakt/criteria/__pycache__/chest.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/clip3ms.cpython-314.pyc b/src/impakt/criteria/__pycache__/clip3ms.cpython-314.pyc deleted file mode 100644 index c23a306..0000000 Binary files a/src/impakt/criteria/__pycache__/clip3ms.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/femur.cpython-314.pyc b/src/impakt/criteria/__pycache__/femur.cpython-314.pyc deleted file mode 100644 index 166a558..0000000 Binary files a/src/impakt/criteria/__pycache__/femur.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/hic.cpython-314.pyc b/src/impakt/criteria/__pycache__/hic.cpython-314.pyc deleted file mode 100644 index 0e8449c..0000000 Binary files a/src/impakt/criteria/__pycache__/hic.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/nij.cpython-314.pyc b/src/impakt/criteria/__pycache__/nij.cpython-314.pyc deleted file mode 100644 index 85755af..0000000 Binary files a/src/impakt/criteria/__pycache__/nij.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/criteria/__pycache__/tibia.cpython-314.pyc b/src/impakt/criteria/__pycache__/tibia.cpython-314.pyc deleted file mode 100644 index e94b2a8..0000000 Binary files a/src/impakt/criteria/__pycache__/tibia.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/io/__pycache__/__init__.cpython-314.pyc b/src/impakt/io/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 14776dc..0000000 Binary files a/src/impakt/io/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/io/__pycache__/mme.cpython-314.pyc b/src/impakt/io/__pycache__/mme.cpython-314.pyc deleted file mode 100644 index 45fbcf1..0000000 Binary files a/src/impakt/io/__pycache__/mme.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/io/__pycache__/reader.cpython-314.pyc b/src/impakt/io/__pycache__/reader.cpython-314.pyc deleted file mode 100644 index acf5164..0000000 Binary files a/src/impakt/io/__pycache__/reader.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/plot/__pycache__/__init__.cpython-314.pyc b/src/impakt/plot/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index b7cc0bd..0000000 Binary files a/src/impakt/plot/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/plot/__pycache__/cursor.cpython-314.pyc b/src/impakt/plot/__pycache__/cursor.cpython-314.pyc deleted file mode 100644 index 1f983c3..0000000 Binary files a/src/impakt/plot/__pycache__/cursor.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/plot/__pycache__/engine.cpython-314.pyc b/src/impakt/plot/__pycache__/engine.cpython-314.pyc deleted file mode 100644 index 09be67c..0000000 Binary files a/src/impakt/plot/__pycache__/engine.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/plot/__pycache__/export.cpython-314.pyc b/src/impakt/plot/__pycache__/export.cpython-314.pyc deleted file mode 100644 index ba87f58..0000000 Binary files a/src/impakt/plot/__pycache__/export.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/plot/__pycache__/spec.cpython-314.pyc b/src/impakt/plot/__pycache__/spec.cpython-314.pyc deleted file mode 100644 index 3f8bcfd..0000000 Binary files a/src/impakt/plot/__pycache__/spec.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/protocol/__pycache__/__init__.cpython-314.pyc b/src/impakt/protocol/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 264f9fd..0000000 Binary files a/src/impakt/protocol/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/protocol/__pycache__/base.cpython-314.pyc b/src/impakt/protocol/__pycache__/base.cpython-314.pyc deleted file mode 100644 index b468e76..0000000 Binary files a/src/impakt/protocol/__pycache__/base.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/protocol/__pycache__/euro_ncap.cpython-314.pyc b/src/impakt/protocol/__pycache__/euro_ncap.cpython-314.pyc deleted file mode 100644 index 888b83a..0000000 Binary files a/src/impakt/protocol/__pycache__/euro_ncap.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/protocol/__pycache__/iihs.cpython-314.pyc b/src/impakt/protocol/__pycache__/iihs.cpython-314.pyc deleted file mode 100644 index a563241..0000000 Binary files a/src/impakt/protocol/__pycache__/iihs.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/protocol/__pycache__/us_ncap.cpython-314.pyc b/src/impakt/protocol/__pycache__/us_ncap.cpython-314.pyc deleted file mode 100644 index a2273cf..0000000 Binary files a/src/impakt/protocol/__pycache__/us_ncap.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/script/__pycache__/__init__.cpython-314.pyc b/src/impakt/script/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 4d8e15a..0000000 Binary files a/src/impakt/script/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/script/__pycache__/api.cpython-314.pyc b/src/impakt/script/__pycache__/api.cpython-314.pyc deleted file mode 100644 index 3541f7c..0000000 Binary files a/src/impakt/script/__pycache__/api.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/template/__pycache__/__init__.cpython-314.pyc b/src/impakt/template/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 7bf8d16..0000000 Binary files a/src/impakt/template/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/template/__pycache__/library.cpython-314.pyc b/src/impakt/template/__pycache__/library.cpython-314.pyc deleted file mode 100644 index 74c7ddf..0000000 Binary files a/src/impakt/template/__pycache__/library.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/template/__pycache__/model.cpython-314.pyc b/src/impakt/template/__pycache__/model.cpython-314.pyc deleted file mode 100644 index dbf3e94..0000000 Binary files a/src/impakt/template/__pycache__/model.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/template/__pycache__/session.cpython-314.pyc b/src/impakt/template/__pycache__/session.cpython-314.pyc deleted file mode 100644 index c591b56..0000000 Binary files a/src/impakt/template/__pycache__/session.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/transform/__pycache__/__init__.cpython-314.pyc b/src/impakt/transform/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index e07a2bb..0000000 Binary files a/src/impakt/transform/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/transform/__pycache__/align.cpython-314.pyc b/src/impakt/transform/__pycache__/align.cpython-314.pyc deleted file mode 100644 index b90e9fd..0000000 Binary files a/src/impakt/transform/__pycache__/align.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/transform/__pycache__/base.cpython-314.pyc b/src/impakt/transform/__pycache__/base.cpython-314.pyc deleted file mode 100644 index 55f59d9..0000000 Binary files a/src/impakt/transform/__pycache__/base.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/transform/__pycache__/cfc.cpython-314.pyc b/src/impakt/transform/__pycache__/cfc.cpython-314.pyc deleted file mode 100644 index 7920e88..0000000 Binary files a/src/impakt/transform/__pycache__/cfc.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/transform/__pycache__/math_expr.cpython-314.pyc b/src/impakt/transform/__pycache__/math_expr.cpython-314.pyc deleted file mode 100644 index 964c077..0000000 Binary files a/src/impakt/transform/__pycache__/math_expr.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/transform/__pycache__/resample.cpython-314.pyc b/src/impakt/transform/__pycache__/resample.cpython-314.pyc deleted file mode 100644 index d04abf4..0000000 Binary files a/src/impakt/transform/__pycache__/resample.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/transform/__pycache__/resultant.cpython-314.pyc b/src/impakt/transform/__pycache__/resultant.cpython-314.pyc deleted file mode 100644 index 950af49..0000000 Binary files a/src/impakt/transform/__pycache__/resultant.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/web/__pycache__/__init__.cpython-314.pyc b/src/impakt/web/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index bf9fda4..0000000 Binary files a/src/impakt/web/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/web/__pycache__/app.cpython-314.pyc b/src/impakt/web/__pycache__/app.cpython-314.pyc deleted file mode 100644 index 6942a62..0000000 Binary files a/src/impakt/web/__pycache__/app.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/web/__pycache__/callbacks.cpython-314.pyc b/src/impakt/web/__pycache__/callbacks.cpython-314.pyc deleted file mode 100644 index 27f410c..0000000 Binary files a/src/impakt/web/__pycache__/callbacks.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/web/__pycache__/layout.cpython-314.pyc b/src/impakt/web/__pycache__/layout.cpython-314.pyc deleted file mode 100644 index 19f7217..0000000 Binary files a/src/impakt/web/__pycache__/layout.cpython-314.pyc and /dev/null differ diff --git a/src/impakt/web/app.py b/src/impakt/web/app.py index b51b939..a724bc6 100644 --- a/src/impakt/web/app.py +++ b/src/impakt/web/app.py @@ -71,6 +71,13 @@ def create_app( external_stylesheets=[dbc.themes.FLATLY], title=title, suppress_callback_exceptions=True, + # Prevent browser from caching old layouts + serve_locally=True, + meta_tags=[ + {"http-equiv": "Cache-Control", "content": "no-cache, no-store, must-revalidate"}, + {"http-equiv": "Pragma", "content": "no-cache"}, + {"http-equiv": "Expires", "content": "0"}, + ], ) app.layout = build_layout(app_state, template_names) diff --git a/src/impakt/web/callbacks/__init__.py b/src/impakt/web/callbacks/__init__.py index af91ed7..31dad88 100644 --- a/src/impakt/web/callbacks/__init__.py +++ b/src/impakt/web/callbacks/__init__.py @@ -10,11 +10,14 @@ from __future__ import annotations import dash from impakt.web.callbacks.channel_callbacks import register_channel_callbacks +from impakt.web.callbacks.corridor_callbacks import register_corridor_callbacks from impakt.web.callbacks.criteria_callbacks import register_criteria_callbacks from impakt.web.callbacks.cursor_callbacks import register_cursor_callbacks from impakt.web.callbacks.export_callbacks import register_export_callbacks from impakt.web.callbacks.file_callbacks import register_file_callbacks +from impakt.web.callbacks.math_callbacks import register_math_callbacks from impakt.web.callbacks.plot_callbacks import register_plot_callbacks +from impakt.web.callbacks.template_callbacks import register_template_callbacks from impakt.web.state import AppState @@ -26,3 +29,6 @@ def register_callbacks(app: dash.Dash, app_state: AppState) -> None: register_criteria_callbacks(app, app_state) register_file_callbacks(app, app_state) register_export_callbacks(app, app_state) + register_template_callbacks(app, app_state) + register_corridor_callbacks(app, app_state) + register_math_callbacks(app, app_state) diff --git a/src/impakt/web/callbacks/channel_callbacks.py b/src/impakt/web/callbacks/channel_callbacks.py index 01bf6db..9d8d16d 100644 --- a/src/impakt/web/callbacks/channel_callbacks.py +++ b/src/impakt/web/callbacks/channel_callbacks.py @@ -2,9 +2,10 @@ Handles: - DataTable row selection -> updates selected channels store -- Wildcard filter + facet dropdowns -> filters table rows -- Selected channels badge display -- Filter clear button +- Filter/facet changes -> filters table rows AND recomputes selected_rows + indices to preserve selection across filter changes +- Selected channels badge display with consistent colors +- Per-channel CFC override controls """ from __future__ import annotations @@ -12,13 +13,15 @@ from __future__ import annotations from typing import Any import dash -from dash import Input, Output, State, html, no_update +from dash import ALL, Input, Output, State, html, no_update from dash.exceptions import PreventUpdate +from impakt.plot.engine import DEFAULT_COLORS from impakt.web.components.channel_grid import ( build_selected_channels_badges, filter_rows, ) +from impakt.web.components.transforms import build_per_channel_override_rows from impakt.web.state import AppState @@ -28,21 +31,47 @@ def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None: @app.callback( Output("selected-channels-store", "data"), [Input("channel-grid", "selected_rows")], - [State("channel-grid", "data")], + [ + State("channel-grid", "data"), + State("selected-channels-store", "data"), + ], ) def sync_selection_to_store( selected_row_indices: list[int] | None, - current_data: list[dict[str, Any]] | None, + visible_data: list[dict[str, Any]] | None, + prev_selected: list[str] | None, ) -> list[str]: - """When user checks/unchecks rows in the DataTable, update the store.""" - if not selected_row_indices or not current_data: - return [] + """When user checks/unchecks rows, merge with existing selection. - keys = [] - for idx in selected_row_indices: - if 0 <= idx < len(current_data): - keys.append(current_data[idx]["key"]) - return keys + The key insight: selected_rows are indices into the *currently visible* + data (after filtering). We need to: + 1. Determine which visible rows are now checked + 2. Keep any previously-selected rows that aren't visible (filtered out) + 3. Remove any visible rows that were unchecked + """ + if visible_data is None: + return prev_selected or [] + + prev = set(prev_selected or []) + visible_keys = {row["key"] for row in visible_data} + + # Keys currently checked in the visible table + checked_keys: set[str] = set() + if selected_row_indices: + for idx in selected_row_indices: + if 0 <= idx < len(visible_data): + checked_keys.add(visible_data[idx]["key"]) + + # Start with previously selected keys that are NOT visible + # (i.e., they were selected before a filter was applied — preserve them) + result = [k for k in (prev_selected or []) if k not in visible_keys] + + # Add the checked visible keys (in visible order) + for row in visible_data: + if row["key"] in checked_keys: + result.append(row["key"]) + + return result @app.callback( Output("selected-channels-badges", "children"), @@ -52,40 +81,74 @@ def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None: return build_selected_channels_badges(selected_keys or [], app_state) @app.callback( - Output("channel-grid", "data"), + [ + Output("channel-grid", "data"), + Output("channel-grid", "selected_rows"), + Output("channel-grid", "style_data_conditional"), + ], [ Input("channel-filter-input", "value"), Input("channel-filter-clear", "n_clicks"), Input("facet-body", "value"), Input("facet-meas", "value"), Input("facet-direction", "value"), + Input("selected-channels-store", "data"), ], [State("channel-grid-all-rows", "data")], ) - def apply_filters( + def apply_filters_and_selection( pattern: str | None, clear_clicks: int | None, body: str | None, meas: str | None, direction: str | None, + selected_keys: list[str] | None, all_rows: list[dict[str, Any]] | None, - ) -> list[dict[str, Any]]: - """Filter the channel grid based on wildcard pattern and facets.""" + ) -> tuple[list[dict[str, Any]], list[int], list[dict]]: + """Filter rows AND recompute selected_rows + color styling.""" if not all_rows: - return [] + return [], [], [] - # If clear was clicked, return all rows trigger = dash.ctx.triggered_id if trigger == "channel-filter-clear": - return all_rows + filtered = all_rows + else: + filtered = filter_rows( + all_rows, + pattern=pattern or "", + body=body or "", + meas=meas or "", + direction=direction or "", + ) - return filter_rows( - all_rows, - pattern=pattern or "", - body=body or "", - meas=meas or "", - direction=direction or "", - ) + # Build a lookup of selected keys to their color index + selected_set = set(selected_keys or []) + selected_list = list(selected_keys or []) + color_map: dict[str, int] = {} + for i, key in enumerate(selected_list): + color_map[key] = i + + # Compute selected_rows indices in the filtered data + selected_indices = [] + for idx, row in enumerate(filtered): + if row["key"] in selected_set: + selected_indices.append(idx) + + # Build style_data_conditional for coloring selected rows + style_cond: list[dict] = [] + for idx, row in enumerate(filtered): + if row["key"] in color_map: + ci = color_map[row["key"]] + color = DEFAULT_COLORS[ci % len(DEFAULT_COLORS)] + style_cond.append( + { + "if": {"row_index": idx}, + "backgroundColor": f"{color}18", # Very light tint + "borderLeft": f"3px solid {color}", + } + ) + + return filtered, selected_indices, style_cond @app.callback( [ @@ -100,3 +163,33 @@ def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None: def clear_filters(n_clicks: int | None) -> tuple[str, str, str, str]: """Clear all filter inputs.""" return "", "", "", "" + + @app.callback( + Output("per-channel-overrides", "children"), + [Input("selected-channels-store", "data")], + ) + def update_per_channel_overrides(selected_keys: list[str] | None) -> list: + """Show per-channel CFC override controls for selected channels.""" + return build_per_channel_override_rows( + selected_keys or [], + app_state.channel_overrides, + ) + + @app.callback( + Output("channel-overrides-store", "data"), + [Input({"type": "ch-cfc-override", "index": ALL}, "value")], + [State({"type": "ch-cfc-override", "index": ALL}, "id")], + prevent_initial_call=True, + ) + def sync_per_channel_overrides( + values: list[str], + ids: list[dict[str, str]], + ) -> dict[str, dict[str, str]]: + """Sync per-channel CFC overrides to app state.""" + for item_id, value in zip(ids, values): + key = item_id["index"] + if value: + app_state.channel_overrides[key] = {"cfc": value} + else: + app_state.channel_overrides.pop(key, None) + return dict(app_state.channel_overrides) diff --git a/src/impakt/web/callbacks/corridor_callbacks.py b/src/impakt/web/callbacks/corridor_callbacks.py new file mode 100644 index 0000000..8a141b6 --- /dev/null +++ b/src/impakt/web/callbacks/corridor_callbacks.py @@ -0,0 +1,149 @@ +"""Corridor management callbacks. + +Handles: +- CSV upload -> parse and store corridor data +- Toggle corridor visibility +- Remove corridor +""" + +from __future__ import annotations + +import base64 +import io +from typing import Any + +import dash +import numpy as np +from dash import Input, Output, State, html +from dash.exceptions import PreventUpdate + +from impakt.web.state import AppState + + +def register_corridor_callbacks(app: dash.Dash, app_state: AppState) -> None: + """Register corridor management callbacks.""" + + @app.callback( + [ + Output("corridors-store", "data"), + Output("corridor-status", "children"), + Output("active-corridors-list", "children"), + ], + [Input("corridor-upload", "contents")], + [ + State("corridor-upload", "filename"), + State("corridor-name-input", "value"), + State("corridors-store", "data"), + ], + prevent_initial_call=True, + ) + def upload_corridor( + contents: str | None, + filename: str | None, + corridor_name: str | None, + current_corridors: list[dict[str, Any]] | None, + ) -> tuple[list[dict[str, Any]], Any, list]: + if contents is None: + raise PreventUpdate + + # Parse the uploaded CSV + try: + content_type, content_string = contents.split(",", 1) + decoded = base64.b64decode(content_string).decode("utf-8") + + # Parse CSV: expect time, lower, upper columns + lines = decoded.strip().splitlines() + + # Skip header if present + data_lines = [] + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + # Try to parse first value as float to detect header + parts = stripped.split(",") + try: + float(parts[0].strip()) + data_lines.append(stripped) + except ValueError: + continue # Skip header + + if not data_lines: + return ( + current_corridors or [], + html.Div("No data found in CSV", className="text-danger small"), + _build_corridor_list(current_corridors or []), + ) + + # Parse values + time_vals = [] + lower_vals = [] + upper_vals = [] + for line in data_lines: + parts = [p.strip() for p in line.split(",")] + if len(parts) >= 3: + time_vals.append(float(parts[0])) + lower_vals.append(float(parts[1])) + upper_vals.append(float(parts[2])) + + if not time_vals: + return ( + current_corridors or [], + html.Div("Could not parse CSV data", className="text-danger small"), + _build_corridor_list(current_corridors or []), + ) + + name = corridor_name or (filename or "corridor").rsplit(".", 1)[0] + + corridor = { + "name": name, + "time": time_vals, + "lower": lower_vals, + "upper": upper_vals, + "visible": True, + } + + # Add to state + app_state.corridors.append(corridor) + updated = list(current_corridors or []) + [corridor] + + return ( + updated, + html.Div( + f"Loaded '{name}' ({len(time_vals)} points)", className="text-success small" + ), + _build_corridor_list(updated), + ) + + except Exception as e: + return ( + current_corridors or [], + html.Div(f"Upload failed: {e}", className="text-danger small"), + _build_corridor_list(current_corridors or []), + ) + + +def _build_corridor_list(corridors: list[dict[str, Any]]) -> list: + """Build the active corridors display list.""" + if not corridors: + return [html.Div("No corridors loaded", className="text-muted", style={"fontSize": "10px"})] + + items = [] + for i, c in enumerate(corridors): + items.append( + html.Div( + [ + html.Span( + c["name"], + style={"fontSize": "11px", "fontWeight": "500"}, + ), + html.Span( + f" ({len(c['time'])} pts)", + style={"fontSize": "10px", "color": "#999"}, + ), + ], + className="mb-1", + ) + ) + + return items diff --git a/src/impakt/web/callbacks/cursor_callbacks.py b/src/impakt/web/callbacks/cursor_callbacks.py index b97f37e..1da6e6d 100644 --- a/src/impakt/web/callbacks/cursor_callbacks.py +++ b/src/impakt/web/callbacks/cursor_callbacks.py @@ -1,12 +1,8 @@ -"""Cursor value callbacks. +"""Channel values table callbacks. -Updates the cursor grid DataTable as the user moves the mouse over the plot. - -The JS cursor_tracker.js writes the hover X position to -window.__impakt_hover_x on every mousemove. A clientside callback -polls this value via dcc.Interval and writes it to the cursor-hover-x -store. The server-side callback then reads the store and computes -interpolated Y values for all plotted channels. +Updates the combined channel values DataTable with statistics and +live cursor values as the user moves the mouse over the plot. +Row colors match the plot trace colors. """ from __future__ import annotations @@ -14,19 +10,19 @@ from __future__ import annotations from typing import Any import dash -from dash import ClientsideFunction, Input, Output, State, clientside_callback, html +from dash import Input, Output, State, html from dash.exceptions import PreventUpdate +from impakt.plot.engine import DEFAULT_COLORS from impakt.web.callbacks.plot_callbacks import _resolve_channels -from impakt.web.components.cursors import build_cursor_grid_data +from impakt.web.components.channel_values import build_channel_values_data from impakt.web.state import AppState def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None: - """Register cursor-related callbacks.""" + """Register channel values table callbacks.""" # Clientside callback: reads window.__impakt_hover_x (set by cursor_tracker.js) - # and writes it to the cursor-hover-x store on each Interval tick. app.clientside_callback( """ function(n_intervals) { @@ -42,28 +38,30 @@ def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None: ) @app.callback( - Output("cursor-grid", "data"), + Output("channel-values-grid", "data"), [ Input("cursor-hover-x", "data"), Input("cursor-x1", "value"), Input("cursor-x2", "value"), Input("selected-channels-store", "data"), + Input("cfc-select", "value"), + Input("y-align-check", "value"), + Input("channel-overrides-store", "data"), ], [ - State("cfc-select", "value"), - State("y-align-check", "value"), State("x-align-method", "value"), State("x-align-value", "value"), State("show-resultant", "value"), ], ) - def update_cursor_grid( + def update_channel_values_data( hover_x: float | None, cursor_x1: float | None, cursor_x2: float | None, selected_keys: list[str] | None, cfc_value: str, y_align: bool, + channel_overrides: dict | None, x_align_method: str, x_align_value: float | None, show_resultant: bool, @@ -71,7 +69,6 @@ def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None: if not selected_keys: return [] - # Resolve channels with current transforms channels = _resolve_channels( selected_keys, app_state, @@ -88,4 +85,58 @@ def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None: x1 = cursor_x1 if cursor_x1 is not None else 0.0 x2 = cursor_x2 if cursor_x2 is not None else 0.1 - return build_cursor_grid_data(channels, hover_x, x1, x2) + return build_channel_values_data(channels, hover_x, x1, x2) + + @app.callback( + Output("channel-values-grid", "style_data_conditional"), + [Input("selected-channels-store", "data")], + [ + State("cfc-select", "value"), + State("y-align-check", "value"), + State("x-align-method", "value"), + State("x-align-value", "value"), + State("show-resultant", "value"), + ], + ) + def update_channel_values_style( + selected_keys: list[str] | None, + cfc_value: str, + y_align: bool, + x_align_method: str, + x_align_value: float | None, + show_resultant: bool, + ) -> list[dict]: + if not selected_keys: + return [] + + channels = _resolve_channels( + selected_keys, + app_state, + cfc_value, + y_align, + x_align_method or "none", + x_align_value, + show_resultant, + ) + + style_cond: list[dict] = [] + for i in range(len(channels)): + color = DEFAULT_COLORS[i % len(DEFAULT_COLORS)] + style_cond.append( + { + "if": {"row_index": i}, + "backgroundColor": f"{color}18", + "borderLeft": f"3px solid {color}", + } + ) + + # Highlight cursor column + style_cond.append( + { + "if": {"column_id": "cursor"}, + "color": "#0d6efd", + "fontWeight": "500", + } + ) + + return style_cond diff --git a/src/impakt/web/callbacks/math_callbacks.py b/src/impakt/web/callbacks/math_callbacks.py new file mode 100644 index 0000000..9a10221 --- /dev/null +++ b/src/impakt/web/callbacks/math_callbacks.py @@ -0,0 +1,125 @@ +"""Math expression builder callbacks. + +Handles: +- Compute button -> evaluate expression with bound channels +- Add result as a derived channel to AppState +- Plot the derived channel +""" + +from __future__ import annotations + +from typing import Any + +import dash +from dash import Input, Output, State, html +from dash.exceptions import PreventUpdate + +from impakt.transform.math_expr import math_expr +from impakt.web.state import AppState + + +def register_math_callbacks(app: dash.Dash, app_state: AppState) -> None: + """Register math expression builder callbacks.""" + + @app.callback( + [ + Output("math-status", "children"), + Output("selected-channels-store", "data", allow_duplicate=True), + ], + [Input("math-compute-btn", "n_clicks")], + [ + State("math-expression", "value"), + State("math-var-a", "value"), + State("math-var-b", "value"), + State("math-var-c", "value"), + State("math-result-name", "value"), + State("math-result-unit", "value"), + State("selected-channels-store", "data"), + ], + prevent_initial_call=True, + ) + def compute_math_expression( + n_clicks: int | None, + expression: str | None, + var_a_key: str | None, + var_b_key: str | None, + var_c_key: str | None, + result_name: str | None, + result_unit: str | None, + current_selected: list[str] | None, + ) -> tuple[Any, list[str]]: + if not n_clicks or not expression: + raise PreventUpdate + + expression = expression.strip() + if not expression: + return html.Div( + "Enter an expression", className="text-warning small" + ), current_selected or [] + + # Resolve variable channels + channels: dict[str, Any] = {} + var_map = {"a": var_a_key, "b": var_b_key, "c": var_c_key} + + for var_name, key in var_map.items(): + if not key: + continue + if "::" in key: + test_id, ch_name = key.split("::", 1) + elif app_state.primary_test: + test_id = app_state.primary_test.test_id + ch_name = key + else: + continue + + ch = app_state.get_channel(test_id, ch_name) + if ch is not None: + channels[var_name] = ch + + if not channels: + return ( + html.Div("Bind at least one variable to a channel", className="text-warning small"), + current_selected or [], + ) + + # Compute + name = result_name or "derived" + unit = result_unit or "" + + try: + result_ch = math_expr( + expression=expression, + channels=channels, + name=name, + unit=unit, + ) + + # Store the derived channel in the primary test's data + primary = app_state.primary_test + if primary: + primary.data._channels[name] = result_ch + + # Add to selected channels + result_key = f"{primary.test_id}::{name}" + updated_selected = list(current_selected or []) + if result_key not in updated_selected: + updated_selected.append(result_key) + + return ( + html.Div( + f"Computed '{name}': peak={result_ch.peak:.4f} {unit}", + className="text-success small", + ), + updated_selected, + ) + else: + return ( + html.Div("No test loaded", className="text-danger small"), + current_selected or [], + ) + + except Exception as e: + return ( + html.Div(f"Error: {e}", className="text-danger small"), + current_selected or [], + ) diff --git a/src/impakt/web/callbacks/plot_callbacks.py b/src/impakt/web/callbacks/plot_callbacks.py index e8449b9..9add249 100644 --- a/src/impakt/web/callbacks/plot_callbacks.py +++ b/src/impakt/web/callbacks/plot_callbacks.py @@ -35,7 +35,11 @@ def _resolve_channels( x_align_value: float | None, show_resultant: bool, ) -> list[tuple[str, Channel]]: - """Resolve selected channel keys to Channel objects with transforms applied.""" + """Resolve selected channel keys to Channel objects with transforms applied. + + Per-channel overrides (from app_state.channel_overrides) take precedence + over the global CFC setting. + """ channels: list[tuple[str, Channel]] = [] for key in selected_keys: @@ -51,10 +55,14 @@ def _resolve_channels( if ch is None: continue - # Apply CFC filter - if cfc_value != "none": + # Determine CFC: per-channel override takes precedence over global + override = app_state.channel_overrides.get(key, {}) + ch_cfc = override.get("cfc", "") + effective_cfc = ch_cfc if ch_cfc else cfc_value + + if effective_cfc and effective_cfc != "none": try: - ch = CFCFilter(cfc_class=int(cfc_value)).apply(ch) + ch = CFCFilter(cfc_class=int(effective_cfc)).apply(ch) except (ValueError, Exception): pass @@ -105,6 +113,7 @@ def _build_figure( cursor_x1: float | None, cursor_x2: float | None, cfc_value: str, + corridors: list[dict] | None = None, ) -> go.Figure: """Build a Plotly figure from resolved channels.""" fig = go.Figure() @@ -143,6 +152,41 @@ def _build_figure( ) ) + # Add corridor fills + if corridors: + for corridor in corridors: + if not corridor.get("visible", True): + continue + c_time = corridor["time"] + c_upper = corridor["upper"] + c_lower = corridor["lower"] + c_name = corridor.get("name", "Corridor") + + # Upper bound + fig.add_trace( + go.Scatter( + x=c_time, + y=c_upper, + mode="lines", + line=dict(color="rgba(100,100,255,0.4)", width=1, dash="dash"), + showlegend=False, + name=f"{c_name} upper", + ) + ) + # Lower bound with fill to upper + fig.add_trace( + go.Scatter( + x=c_time, + y=c_lower, + mode="lines", + line=dict(color="rgba(100,100,255,0.4)", width=1, dash="dash"), + fill="tonexty", + fillcolor="rgba(100,100,255,0.1)", + showlegend=True, + name=c_name, + ) + ) + # Add X1/X2 cursor lines if cursor_x1 is not None: fig.add_vline( @@ -171,43 +215,12 @@ def _build_figure( y_label = channels[0][1].unit if channels else "" fig.update_layout( - xaxis_title="Time (s)", - yaxis_title=y_label, + xaxis_title=dict(text="Time (s)", font=dict(size=10, color="#999")), + yaxis_title=dict(text=y_label, font=dict(size=10, color="#999")), template="plotly_white", hovermode=False, - showlegend=True, - legend=dict( - orientation="h", - yanchor="bottom", - y=-0.18, - xanchor="center", - x=0.5, - font=dict(size=10), - ), - margin=dict(l=55, r=15, t=10, b=55), - ) - - # Vertical spike line (crosshair) follows the mouse across the plot area - fig.update_xaxes( - showspikes=True, - spikemode="across", - spikesnap="cursor", - spikethickness=1, - spikecolor="rgba(0,0,0,0.25)", - spikedash="dot", - ) - fig.update_yaxes( - showspikes=False, - ) - - # Show spike line (vertical crosshair) on hover - fig.update_xaxes( - showspikes=True, - spikemode="across", - spikesnap="cursor", - spikethickness=1, - spikecolor="rgba(0,0,0,0.3)", - spikedash="dot", + showlegend=False, + margin=dict(l=45, r=8, t=4, b=28), ) return fig @@ -226,6 +239,8 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None: Input("x-align-method", "value"), Input("cursor-x1", "value"), Input("cursor-x2", "value"), + Input("channel-overrides-store", "data"), + Input("corridors-store", "data"), ], [ State("x-align-value", "value"), @@ -239,6 +254,8 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None: x_align_method: str, cursor_x1: float | None, cursor_x2: float | None, + channel_overrides: dict | None, + corridors_data: list[dict] | None, x_align_value: float | None, ) -> go.Figure: if not selected_keys: @@ -254,4 +271,10 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None: show_resultant, ) - return _build_figure(channels, cursor_x1, cursor_x2, cfc_value) + return _build_figure( + channels, + cursor_x1, + cursor_x2, + cfc_value, + corridors=corridors_data, + ) diff --git a/src/impakt/web/callbacks/template_callbacks.py b/src/impakt/web/callbacks/template_callbacks.py new file mode 100644 index 0000000..e83f315 --- /dev/null +++ b/src/impakt/web/callbacks/template_callbacks.py @@ -0,0 +1,152 @@ +"""Template management callbacks. + +Handles: +- Apply template (resolve patterns, set channels, set transforms) +- Save current view as template +- Delete template +- Session auto-save +""" + +from __future__ import annotations + +from typing import Any + +import dash +from dash import Input, Output, State, html, no_update +from dash.exceptions import PreventUpdate + +from impakt.web.state import AppState + + +def register_template_callbacks(app: dash.Dash, app_state: AppState) -> None: + """Register template management callbacks.""" + + @app.callback( + [ + Output("selected-channels-store", "data", allow_duplicate=True), + Output("cfc-select", "value", allow_duplicate=True), + Output("template-status", "children", allow_duplicate=True), + ], + [Input("apply-template-btn", "n_clicks")], + [State("template-library-select", "value")], + prevent_initial_call=True, + ) + def apply_template( + n_clicks: int | None, + template_name: str | None, + ) -> tuple[list[str], str, Any]: + if not n_clicks or not template_name: + raise PreventUpdate + + try: + resolved_keys, transforms = app_state.apply_template(template_name) + cfc = transforms.get("cfc", "none") + + return ( + resolved_keys, + cfc, + html.Div( + f"Applied '{template_name}' — {len(resolved_keys)} channels", + className="text-success small", + ), + ) + except FileNotFoundError: + return ( + no_update, + no_update, + html.Div(f"Template '{template_name}' not found", className="text-danger small"), + ) + except Exception as e: + return ( + no_update, + no_update, + html.Div(f"Error: {e}", className="text-danger small"), + ) + + @app.callback( + Output("template-status", "children"), + [Input("save-template-btn", "n_clicks")], + [ + State("save-template-name", "value"), + State("save-template-desc", "value"), + State("selected-channels-store", "data"), + State("cfc-select", "value"), + State("cursor-x1", "value"), + State("cursor-x2", "value"), + State("protocol-select", "value"), + ], + prevent_initial_call=True, + ) + def save_template( + n_clicks: int | None, + name: str | None, + description: str | None, + selected_keys: list[str] | None, + cfc_value: str, + x1: float | None, + x2: float | None, + protocol: str, + ) -> Any: + if not n_clicks: + raise PreventUpdate + + if not name or not name.strip(): + return html.Div("Enter a template name", className="text-warning small") + + if not selected_keys: + return html.Div("No channels selected to save", className="text-warning small") + + try: + template = app_state.save_as_template( + name=name.strip(), + description=(description or "").strip(), + selected_keys=selected_keys, + cfc_value=cfc_value, + x1=x1, + x2=x2, + protocol=protocol, + ) + return html.Div( + f"Saved '{template.name}' ({len(selected_keys)} channels)", + className="text-success small", + ) + except Exception as e: + return html.Div(f"Save failed: {e}", className="text-danger small") + + @app.callback( + Output("template-status", "children", allow_duplicate=True), + [Input("delete-template-btn", "n_clicks")], + [State("template-library-select", "value")], + prevent_initial_call=True, + ) + def delete_template(n_clicks: int | None, template_name: str | None) -> Any: + if not n_clicks or not template_name: + raise PreventUpdate + + if app_state.template_library.delete(template_name): + return html.Div(f"Deleted '{template_name}'", className="text-success small") + else: + return html.Div(f"Template '{template_name}' not found", className="text-warning small") + + # Session auto-save: save on channel selection or CFC change + @app.callback( + Output("session-store", "data"), + [ + Input("selected-channels-store", "data"), + Input("cfc-select", "value"), + ], + prevent_initial_call=True, + ) + def auto_save_session( + selected_keys: list[str] | None, + cfc_value: str, + ) -> dict[str, Any]: + if not selected_keys: + selected_keys = [] + + try: + app_state.save_session(selected_keys, cfc_value) + except Exception: + pass # Don't let save failures break the UI + + return {"saved": True, "channels": len(selected_keys)} diff --git a/src/impakt/web/components/channel_grid.py b/src/impakt/web/components/channel_grid.py index a1c53b3..88f4279 100644 --- a/src/impakt/web/components/channel_grid.py +++ b/src/impakt/web/components/channel_grid.py @@ -260,14 +260,7 @@ def build_channel_grid(app_state: AppState) -> dbc.Card: {"if": {"column_id": "max"}, "width": "65px", "textAlign": "right"}, {"if": {"column_id": "test_id"}, "width": "60px"}, ], - style_data_conditional=[ - { - "if": {"state": "selected"}, - "backgroundColor": "#e8f4fd", - "border": "none", - "borderBottom": "1px solid #b8daff", - }, - ], + style_data_conditional=[], style_as_list_view=True, fixed_rows={"headers": True}, ), diff --git a/src/impakt/web/components/cursors.py b/src/impakt/web/components/channel_values.py similarity index 56% rename from src/impakt/web/components/cursors.py rename to src/impakt/web/components/channel_values.py index 1938b30..f49a818 100644 --- a/src/impakt/web/components/cursors.py +++ b/src/impakt/web/components/channel_values.py @@ -1,40 +1,41 @@ -"""Cursor values grid component. +"""Combined channel values table. -Displays a DataTable below the plot showing interpolated Y values for all -plotted channels at three X positions: - -- **Cursor**: The live mouse position (updates as the user moves the mouse - over the plot). -- **X1**: A locked reference position (set by the user via input or click). -- **X2**: A second locked reference position. - -This replaces the old button-driven cursor panel with a live-updating grid. +A single DataTable showing all channel information for plotted channels: +- #: sequential channel number +- ISO Code: fixed-width channel code +- Description: flex column, fills remaining space +- Unit: engineering unit +- Min @ Time, Max @ Time: peak statistics with time of occurrence +- X1, X2: interpolated values at locked cursor positions +- Cursor: live interpolated value at mouse hover position """ from __future__ import annotations from typing import Any +import numpy as np + import dash_bootstrap_components as dbc from dash import dash_table, dcc, html from impakt.channel.model import Channel -def build_cursor_panel() -> dbc.Card: - """Build the cursor values grid panel.""" +def build_channel_values_panel() -> dbc.Card: + """Build the combined channel values panel.""" return dbc.Card( [ dbc.CardHeader( [ - html.Span("Cursor Values", className="fw-bold"), + html.Span("Channel Values", className="fw-bold"), html.Span( - " — hover over plot", + " — hover over plot for live cursor", className="text-muted", style={"fontSize": "10px", "fontWeight": "normal"}, ), ], - className="py-2", + className="py-1", ), dbc.CardBody( [ @@ -100,22 +101,29 @@ def build_cursor_panel() -> dbc.Card: width=6, ), ], - className="mb-2", + className="mb-1", ), - # Cursor values DataTable + # Combined DataTable dash_table.DataTable( - id="cursor-grid", + id="channel-values-grid", columns=[ - {"name": "Channel", "id": "channel"}, + {"name": "#", "id": "ch_num", "type": "numeric"}, + {"name": "ISO Code", "id": "iso_code"}, + {"name": "Description", "id": "description"}, {"name": "Unit", "id": "unit"}, - {"name": "Cursor", "id": "cursor"}, - {"name": "X1", "id": "x1"}, - {"name": "X2", "id": "x2"}, + {"name": "Min", "id": "min", "type": "numeric"}, + {"name": "@ Time", "id": "min_time"}, + {"name": "Max", "id": "max", "type": "numeric"}, + {"name": "@ Time", "id": "max_time"}, + {"name": "X1", "id": "x1", "type": "numeric"}, + {"name": "X2", "id": "x2", "type": "numeric"}, + {"name": "Cursor", "id": "cursor", "type": "numeric"}, ], data=[], style_table={ "overflowY": "auto", - "maxHeight": "200px", + "overflowX": "auto", + "maxHeight": "250px", }, style_header={ "backgroundColor": "#f8f9fa", @@ -123,77 +131,109 @@ def build_cursor_panel() -> dbc.Card: "fontSize": "10px", "padding": "3px 6px", "borderBottom": "2px solid #dee2e6", + "whiteSpace": "nowrap", }, + # Force table-layout:fixed so explicit widths are respected + # and Description gets whatever space remains. + css=[ + { + "selector": ("#channel-values-grid table"), + "rule": "table-layout: fixed; width: 100%;", + } + ], style_cell={ "fontSize": "11px", "fontFamily": "'SF Mono', 'Menlo', 'Monaco', monospace", "padding": "2px 6px", "border": "none", "borderBottom": "1px solid #f0f0f0", + "overflow": "hidden", + "textOverflow": "ellipsis", + "whiteSpace": "nowrap", }, style_cell_conditional=[ + # Fixed-width columns (percentages sum to ~74%, leaving ~26% for Description) { - "if": {"column_id": "channel"}, + "if": {"column_id": "ch_num"}, + "width": "3%", + "textAlign": "right", + "color": "#999", + }, + {"if": {"column_id": "iso_code"}, "width": "5%"}, + { + "if": {"column_id": "description"}, "fontFamily": "inherit", "textAlign": "left", - "minWidth": "100px", - "overflow": "hidden", - "textOverflow": "ellipsis", - "whiteSpace": "nowrap", }, - {"if": {"column_id": "unit"}, "width": "45px", "textAlign": "center"}, - {"if": {"column_id": "cursor"}, "textAlign": "right", "width": "80px"}, - {"if": {"column_id": "x1"}, "textAlign": "right", "width": "80px"}, - {"if": {"column_id": "x2"}, "textAlign": "right", "width": "80px"}, - ], - style_data_conditional=[ + {"if": {"column_id": "unit"}, "width": "2%", "textAlign": "center"}, + {"if": {"column_id": "min"}, "width": "8%", "textAlign": "right"}, { - "if": {"column_id": "cursor"}, - "color": "#0d6efd", - "fontWeight": "500", + "if": {"column_id": "min_time"}, + "width": "7%", + "textAlign": "right", + "color": "#999", }, + {"if": {"column_id": "max"}, "width": "8%", "textAlign": "right"}, + { + "if": {"column_id": "max_time"}, + "width": "7%", + "textAlign": "right", + "color": "#999", + }, + {"if": {"column_id": "x1"}, "width": "8%", "textAlign": "right"}, + {"if": {"column_id": "x2"}, "width": "8%", "textAlign": "right"}, + {"if": {"column_id": "cursor"}, "width": "8%", "textAlign": "right"}, ], + style_data_conditional=[], style_as_list_view=True, fixed_rows={"headers": True}, + sort_action="native", ), # Interval that polls the JS-side hover X position dcc.Interval(id="cursor-poll-interval", interval=80, n_intervals=0), # Hidden store for the current hover X position dcc.Store(id="cursor-hover-x", data=None), ], - className="py-2", + className="py-1 px-2", ), ] ) -def build_cursor_grid_data( +def build_channel_values_data( channels: list[tuple[str, Channel]], hover_x: float | None, x1: float, x2: float, -) -> list[dict[str, str]]: - """Build the cursor grid rows from resolved channels. +) -> list[dict[str, Any]]: + """Build combined channel values rows. - Args: - channels: List of (label, channel) tuples. - hover_x: Current mouse hover X position (None if not hovering). - x1: Locked X1 position. - x2: Locked X2 position. - - Returns: - List of row dicts for the DataTable. + Each row contains identity, statistics, and cursor values for one channel. """ - rows: list[dict[str, str]] = [] - for label, ch in channels: + rows: list[dict[str, Any]] = [] + for idx, (label, ch) in enumerate(channels): + data = ch.data + + min_val = float(np.min(data)) + min_idx = int(np.argmin(data)) + max_val = float(np.max(data)) + max_idx = int(np.argmax(data)) + cursor_val = f"{ch.value_at(hover_x):.4f}" if hover_x is not None else "" + rows.append( { - "channel": label, + "ch_num": idx + 1, + "iso_code": ch.name, + "description": label, "unit": ch.unit, - "cursor": cursor_val, + "min": f"{min_val:.4f}", + "min_time": f"{float(ch.time[min_idx]):.4f}", + "max": f"{max_val:.4f}", + "max_time": f"{float(ch.time[max_idx]):.4f}", "x1": f"{ch.value_at(x1):.4f}", "x2": f"{ch.value_at(x2):.4f}", + "cursor": cursor_val, } ) return rows diff --git a/src/impakt/web/components/corridors.py b/src/impakt/web/components/corridors.py new file mode 100644 index 0000000..354b614 --- /dev/null +++ b/src/impakt/web/components/corridors.py @@ -0,0 +1,76 @@ +"""Corridor management component. + +Provides: +- Upload corridor from CSV file +- List active corridors with toggle visibility +- Corridor pass/fail indicator +""" + +from __future__ import annotations + +from typing import Any + +import dash_bootstrap_components as dbc +from dash import dcc, html + + +def build_corridor_panel() -> dbc.Card: + """Build the corridor management panel.""" + return dbc.Card( + [ + dbc.CardHeader("Corridors", className="fw-bold py-2"), + dbc.CardBody( + [ + dbc.Label("Load Corridor CSV", size="sm", className="mb-1"), + html.Div( + [ + html.Span( + "Format: time, lower, upper (one header row)", + className="text-muted", + style={"fontSize": "10px"}, + ), + ], + className="mb-1", + ), + dcc.Upload( + id="corridor-upload", + children=dbc.Button( + "Upload CSV", + color="outline-primary", + size="sm", + className="w-100", + style={"fontSize": "11px"}, + ), + multiple=False, + accept=".csv,.txt", + className="mb-2", + ), + dbc.Input( + id="corridor-name-input", + placeholder="Corridor name", + size="sm", + className="mb-2", + style={"fontSize": "12px"}, + ), + html.Hr(style={"margin": "8px 0"}), + # Active corridors list + dbc.Label("Active Corridors", size="sm", className="mb-1"), + html.Div( + id="active-corridors-list", + children=[ + html.Div( + "No corridors loaded", + className="text-muted", + style={"fontSize": "10px"}, + ), + ], + ), + html.Div(id="corridor-status", className="mt-2"), + # Hidden store for corridor data + dcc.Store(id="corridors-store", data=[]), + ], + className="py-2", + ), + ], + className="mb-2", + ) diff --git a/src/impakt/web/components/math_builder.py b/src/impakt/web/components/math_builder.py new file mode 100644 index 0000000..9b27e02 --- /dev/null +++ b/src/impakt/web/components/math_builder.py @@ -0,0 +1,165 @@ +"""Math expression builder component. + +Allows users to create derived channels from mathematical expressions. +Variables are bound to selected channels via dropdowns. + +Example: sqrt(ax**2 + az**2) with ax=Head Accel X, az=Head Accel Z +""" + +from __future__ import annotations + +from typing import Any + +import dash_bootstrap_components as dbc +from dash import dcc, html + +from impakt.web.state import AppState + + +def build_math_panel(app_state: AppState) -> dbc.Card: + """Build the math expression builder panel.""" + + # Build channel options for variable binding + channel_options = [{"label": "—", "value": ""}] + for loaded in app_state.tests: + prefix = f"[{loaded.test_id}] " if len(app_state.tests) > 1 else "" + for ch in loaded.data: + label = ch.code.short_label if ch.code.is_valid else ch.name + channel_options.append( + { + "label": f"{prefix}{label}", + "value": f"{loaded.test_id}::{ch.name}", + } + ) + + return dbc.Card( + [ + dbc.CardHeader("Math Expression", className="fw-bold py-2"), + dbc.CardBody( + [ + dbc.Label("Expression", size="sm", className="mb-1"), + dbc.Textarea( + id="math-expression", + placeholder="sqrt(a**2 + b**2)\na + b * 0.5\nabs(a) - abs(b)", + size="sm", + className="mb-2", + style={"fontSize": "12px", "fontFamily": "monospace", "height": "60px"}, + ), + # Variable bindings + dbc.Label("Variables", size="sm", className="mb-1"), + dbc.Row( + [ + dbc.Col( + html.Span( + "a =", style={"fontSize": "12px", "fontFamily": "monospace"} + ), + width=2, + ), + dbc.Col( + dbc.Select( + id="math-var-a", + options=channel_options, + value="", + size="sm", + style={"fontSize": "11px"}, + ), + width=10, + ), + ], + className="mb-1", + ), + dbc.Row( + [ + dbc.Col( + html.Span( + "b =", style={"fontSize": "12px", "fontFamily": "monospace"} + ), + width=2, + ), + dbc.Col( + dbc.Select( + id="math-var-b", + options=channel_options, + value="", + size="sm", + style={"fontSize": "11px"}, + ), + width=10, + ), + ], + className="mb-1", + ), + dbc.Row( + [ + dbc.Col( + html.Span( + "c =", style={"fontSize": "12px", "fontFamily": "monospace"} + ), + width=2, + ), + dbc.Col( + dbc.Select( + id="math-var-c", + options=channel_options, + value="", + size="sm", + style={"fontSize": "11px"}, + ), + width=10, + ), + ], + className="mb-2", + ), + # Result name and unit + dbc.Row( + [ + dbc.Col( + dbc.Input( + id="math-result-name", + placeholder="Result name", + size="sm", + style={"fontSize": "12px"}, + ), + width=7, + ), + dbc.Col( + dbc.Input( + id="math-result-unit", + placeholder="Unit", + size="sm", + style={"fontSize": "12px"}, + ), + width=5, + ), + ], + className="mb-2", + ), + dbc.Button( + "Compute & Plot", + id="math-compute-btn", + color="primary", + size="sm", + className="w-100", + style={"fontSize": "11px"}, + ), + html.Div(id="math-status", className="mt-2"), + html.Div( + [ + html.Hr(style={"margin": "8px 0"}), + html.Span( + "Available functions: ", + style={"fontSize": "10px", "fontWeight": "bold"}, + ), + html.Span( + "abs, sqrt, sin, cos, tan, exp, log, log10, max, min, " + "clip, sign, cumsum, diff, gradient, mean, std, pi, e", + style={"fontSize": "10px", "color": "#666"}, + ), + ] + ), + ], + className="py-2", + ), + ], + className="mb-2", + ) diff --git a/src/impakt/web/components/plot_grid.py b/src/impakt/web/components/plot_grid.py index 9534cb3..7e1fcaa 100644 --- a/src/impakt/web/components/plot_grid.py +++ b/src/impakt/web/components/plot_grid.py @@ -103,10 +103,10 @@ def _build_single_pane(pane_idx: int, total_panes: int) -> dbc.Card: clear_on_unhover=False, ), ], - className="p-2", + className="p-0", ), ], - className="mb-2", + className="mb-1", ) diff --git a/src/impakt/web/components/templates.py b/src/impakt/web/components/templates.py new file mode 100644 index 0000000..8875c07 --- /dev/null +++ b/src/impakt/web/components/templates.py @@ -0,0 +1,110 @@ +"""Template management component. + +Provides: +- Template library browser (list of available templates) +- Apply template button +- Save current view as template (name + description) +- Active template indicator +- Session auto-save status +""" + +from __future__ import annotations + +from typing import Any + +import dash_bootstrap_components as dbc +from dash import dcc, html + +from impakt.web.state import AppState + + +def build_template_panel(app_state: AppState) -> dbc.Card: + """Build the template management panel.""" + template_names = app_state.template_names + active = app_state.active_template + + # Active template indicator + active_display = html.Div( + [ + html.Span("Active: ", style={"fontSize": "11px", "color": "#666"}), + html.Span( + active.name if active else "None", + style={"fontSize": "11px", "fontWeight": "bold"}, + ), + html.Span( + f" v{active.version}" if active else "", + style={"fontSize": "10px", "color": "#999"}, + ), + ], + className="mb-2", + ) + + return dbc.Card( + [ + dbc.CardHeader("Templates", className="fw-bold py-2"), + dbc.CardBody( + [ + active_display, + # Template library browser + dbc.Label("Library", size="sm", className="mb-1"), + dbc.Select( + id="template-library-select", + options=[{"label": n, "value": n} for n in template_names], + placeholder="Select a template...", + size="sm", + className="mb-2", + style={"fontSize": "12px"}, + ), + dbc.ButtonGroup( + [ + dbc.Button( + "Apply", + id="apply-template-btn", + color="primary", + size="sm", + style={"fontSize": "11px"}, + disabled=not template_names, + ), + dbc.Button( + "Delete", + id="delete-template-btn", + color="outline-danger", + size="sm", + style={"fontSize": "11px"}, + disabled=not template_names, + ), + ], + className="mb-3 w-100", + ), + html.Hr(style={"margin": "8px 0"}), + # Save current view as template + dbc.Label("Save Current View", size="sm", className="mb-1"), + dbc.Input( + id="save-template-name", + placeholder="Template name", + size="sm", + className="mb-1", + style={"fontSize": "12px"}, + ), + dbc.Textarea( + id="save-template-desc", + placeholder="Description (optional)", + size="sm", + className="mb-2", + style={"fontSize": "12px", "height": "50px"}, + ), + dbc.Button( + "Save as Template", + id="save-template-btn", + color="success", + size="sm", + className="w-100 mb-2", + style={"fontSize": "11px"}, + ), + html.Div(id="template-status", className="mt-1"), + ], + className="py-2", + ), + ], + className="mb-2", + ) diff --git a/src/impakt/web/components/transforms.py b/src/impakt/web/components/transforms.py index 8b7404d..31479d5 100644 --- a/src/impakt/web/components/transforms.py +++ b/src/impakt/web/components/transforms.py @@ -1,9 +1,12 @@ -"""Transform controls panel component.""" +"""Transform controls panel component. + +Provides global transform defaults and per-channel override capability. +""" from __future__ import annotations import dash_bootstrap_components as dbc -from dash import html +from dash import dcc, html def build_transform_panel() -> dbc.Card: @@ -13,8 +16,8 @@ def build_transform_panel() -> dbc.Card: dbc.CardHeader("Transforms", className="fw-bold py-2"), dbc.CardBody( [ - # CFC Filter - dbc.Label("CFC Filter", size="sm", className="mb-1"), + # Global CFC Filter + dbc.Label("CFC Filter (global)", size="sm", className="mb-1"), dbc.Select( id="cfc-select", options=[ @@ -62,11 +65,98 @@ def build_transform_panel() -> dbc.Card: placeholder="Time offset (s) or threshold", size="sm", step=0.001, - className="mb-1", - style={"display": "none"}, + className="mb-2", ), + html.Hr(style={"margin": "8px 0"}), + # Per-channel overrides + dbc.Label("Per-Channel Overrides", size="sm", className="mb-1"), + html.Div( + id="per-channel-overrides", + children=[ + html.Div( + "Select channels to set individual filters", + className="text-muted", + style={"fontSize": "10px"}, + ), + ], + ), + # Hidden store for per-channel override data + dcc.Store(id="channel-overrides-store", data={}), ], className="py-2", ), ] ) + + +def build_per_channel_override_rows( + selected_keys: list[str], + overrides: dict[str, dict[str, str]], +) -> list: + """Build per-channel override controls for selected channels. + + Shows a compact row per selected channel with a CFC dropdown override. + """ + if not selected_keys: + return [ + html.Div("No channels selected", className="text-muted", style={"fontSize": "10px"}) + ] + + rows = [] + for key in selected_keys[:10]: # Limit to 10 to avoid overwhelming the panel + ch_name = key.split("::")[-1] if "::" in key else key + # Truncate long names + display_name = ch_name if len(ch_name) <= 18 else ch_name[:16] + ".." + + current_cfc = overrides.get(key, {}).get("cfc", "") + + rows.append( + html.Div( + [ + html.Span( + display_name, + style={ + "fontSize": "10px", + "fontFamily": "monospace", + "width": "130px", + "display": "inline-block", + "overflow": "hidden", + "textOverflow": "ellipsis", + "whiteSpace": "nowrap", + "verticalAlign": "middle", + }, + ), + dbc.Select( + id={"type": "ch-cfc-override", "index": key}, + options=[ + {"label": "—", "value": ""}, + {"label": "60", "value": "60"}, + {"label": "180", "value": "180"}, + {"label": "600", "value": "600"}, + {"label": "1000", "value": "1000"}, + ], + value=current_cfc, + size="sm", + style={ + "fontSize": "10px", + "width": "70px", + "display": "inline-block", + "verticalAlign": "middle", + "marginLeft": "4px", + }, + ), + ], + className="mb-1", + ) + ) + + if len(selected_keys) > 10: + rows.append( + html.Div( + f"+{len(selected_keys) - 10} more", + className="text-muted", + style={"fontSize": "10px"}, + ) + ) + + return rows diff --git a/src/impakt/web/layout.py b/src/impakt/web/layout.py index d2df5fe..23a4740 100644 --- a/src/impakt/web/layout.py +++ b/src/impakt/web/layout.py @@ -14,8 +14,8 @@ import dash_bootstrap_components as dbc from dash import dcc, html from impakt.web.components.channel_grid import build_channel_grid +from impakt.web.components.channel_values import build_channel_values_panel from impakt.web.components.criteria import build_criteria_panel -from impakt.web.components.cursors import build_cursor_panel from impakt.web.components.header import ( build_header, build_open_test_modal, @@ -23,7 +23,10 @@ from impakt.web.components.header import ( build_test_info_panel, ) from impakt.web.components.plot_grid import build_plot_grid +from impakt.web.components.corridors import build_corridor_panel +from impakt.web.components.math_builder import build_math_panel from impakt.web.components.report import build_report_panel +from impakt.web.components.templates import build_template_panel from impakt.web.components.transforms import build_transform_panel from impakt.web.state import AppState @@ -61,11 +64,11 @@ def _build_data_tab(app_state: AppState) -> html.Div: "zIndex": "10", }, ), - # === Right side: Plot area + Cursor grid (full width) === + # === Right side: Plot area + Channel values table (full width) === html.Div( [ build_plot_grid("1x1"), - build_cursor_panel(), + build_channel_values_panel(), ], style={ "flex": "1", @@ -86,24 +89,22 @@ def _build_data_tab(app_state: AppState) -> html.Div: def _build_analysis_tab(app_state: AppState) -> html.Div: - """Build the Analysis tab: criteria + protocol scoring + export.""" + """Build the Analysis tab: criteria, corridors, math, templates, export.""" return html.Div( [ dbc.Row( [ - dbc.Col( - [ - build_criteria_panel(), - ], - width=6, - ), - dbc.Col( - [ - build_report_panel(), - ], - width=6, - ), - ] + dbc.Col([build_criteria_panel()], width=4), + dbc.Col([build_math_panel(app_state)], width=4), + dbc.Col([build_template_panel(app_state)], width=4), + ], + className="mb-2", + ), + dbc.Row( + [ + dbc.Col([build_corridor_panel()], width=6), + dbc.Col([build_report_panel()], width=6), + ], ), ], style={"padding": "8px"}, diff --git a/src/impakt/web/state.py b/src/impakt/web/state.py index 1ebc71a..cf80de2 100644 --- a/src/impakt/web/state.py +++ b/src/impakt/web/state.py @@ -19,6 +19,9 @@ from typing import Any from impakt.channel.model import Channel, ChannelGroup, TestData from impakt.io.reader import get_registry +from impakt.template.library import TemplateLibrary +from impakt.template.model import PlotDefinition, SessionState, TemplateSpec +from impakt.template.session import SessionManager from impakt.transform.align import YAlign from impakt.transform.cfc import CFCFilter @@ -64,6 +67,13 @@ class AppState: self._tests: dict[str, LoadedTest] = {} self._test_order: list[str] = [] self._color_counter: int = 0 + self._template_library = TemplateLibrary() + self._active_template: TemplateSpec | None = None + self._session_managers: dict[str, SessionManager] = {} + # Per-channel transform overrides: {channel_key: {"cfc": "600", "y_align": True}} + self.channel_overrides: dict[str, dict[str, Any]] = {} + # Active corridors: list of {name, time, lower, upper, visible} + self.corridors: list[dict[str, Any]] = [] def load_test(self, path: str | Path) -> LoadedTest: """Load a test from a path and add it to the state. @@ -221,6 +231,158 @@ class AppState: return items + # ----- Template & Session ----- + + @property + def template_library(self) -> TemplateLibrary: + return self._template_library + + @property + def active_template(self) -> TemplateSpec | None: + return self._active_template + + @property + def template_names(self) -> list[str]: + return self._template_library.list() + + def apply_template( + self, + name: str, + selected_keys: list[str] | None = None, + ) -> tuple[list[str], dict[str, str]]: + """Apply a template by name. + + Resolves the template's channel patterns against the primary test, + sets the active template, and returns the resolved channel keys and + transform settings. + + Returns: + (selected_channel_keys, transform_settings) + """ + template = self._template_library.get(name) + self._active_template = template + + # Persist to session if primary test has a path + primary = self.primary_test + if primary and primary.data.path: + mgr = self._get_session_manager(primary.data.path) + mgr.apply_template(template) + + # Resolve channel patterns from the template + resolved_keys: list[str] = [] + if primary: + for plot_def in template.plots: + for pattern in plot_def.channel_patterns: + matches = primary.data.find(pattern) + for ch in matches: + key = f"{primary.test_id}::{ch.name}" + if key not in resolved_keys: + resolved_keys.append(key) + + # Transform settings + transforms: dict[str, str] = {} + if template.default_cfc is not None: + transforms["cfc"] = str(template.default_cfc) + else: + transforms["cfc"] = "none" + + logger.info( + "Applied template '%s' — resolved %d channels", + name, + len(resolved_keys), + ) + return resolved_keys, transforms + + def save_as_template( + self, + name: str, + description: str, + selected_keys: list[str], + cfc_value: str, + x1: float | None, + x2: float | None, + protocol: str = "", + ) -> TemplateSpec: + """Capture the current UI state as a new template. + + Converts selected channels back to patterns and stores the + current filter/cursor/protocol settings. + """ + # Convert selected keys to channel patterns + patterns: list[str] = [] + for key in selected_keys: + if "::" in key: + _, ch_name = key.split("::", 1) + else: + ch_name = key + # Use the raw channel name as a pattern (exact match) + if ch_name not in patterns: + patterns.append(ch_name) + + # Build plot definition + plot = PlotDefinition( + title=name, + channel_patterns=patterns, + x_cursors=(x1, x2) if x1 is not None and x2 is not None else None, + ) + + # Build transforms list + transforms: list[dict[str, Any]] = [] + if cfc_value and cfc_value != "none": + transforms.append({"type": "cfc_filter", "cfc_class": int(cfc_value)}) + if transforms: + plot.transforms = transforms + + template = TemplateSpec( + name=name, + description=description, + plots=[plot], + default_cfc=int(cfc_value) if cfc_value and cfc_value != "none" else None, + protocol=protocol, + ) + + self._template_library.save(template) + self._active_template = template + + logger.info("Saved template '%s' with %d channel patterns", name, len(patterns)) + return template + + def save_session(self, selected_keys: list[str], cfc_value: str, **overrides: Any) -> None: + """Auto-save current state to the session for the primary test.""" + primary = self.primary_test + if not primary or not primary.data.path: + return + + mgr = self._get_session_manager(primary.data.path) + mgr.state.overrides = { + "selected_channels": selected_keys, + "cfc": cfc_value, + **overrides, + } + mgr.save() + + def load_session_state(self) -> dict[str, Any] | None: + """Load saved session state for the primary test, if any.""" + primary = self.primary_test + if not primary or not primary.data.path: + return None + + mgr = self._get_session_manager(primary.data.path) + if not mgr.has_session: + return None + + return { + "selected_channels": mgr.state.overrides.get("selected_channels", []), + "cfc": mgr.state.overrides.get("cfc", "none"), + "template": mgr.state.template_name, + } + + def _get_session_manager(self, path: Path) -> SessionManager: + key = str(path) + if key not in self._session_managers: + self._session_managers[key] = SessionManager(path) + return self._session_managers[key] + @property def is_empty(self) -> bool: return len(self._tests) == 0 @@ -231,4 +393,5 @@ class AppState: def __repr__(self) -> str: test_info = ", ".join(f"{t.test_id}({t.channel_count}ch)" for t in self.tests) - return f"AppState([{test_info}])" + tmpl = f", template={self._active_template.name}" if self._active_template else "" + return f"AppState([{test_info}]{tmpl})" diff --git a/tests/__pycache__/__init__.cpython-314.pyc b/tests/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 60180f6..0000000 Binary files a/tests/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 1a77bec..0000000 Binary files a/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_integration.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/test_integration.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 29f0455..0000000 Binary files a/tests/__pycache__/test_integration.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_real_mme.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/test_real_mme.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 61dec3e..0000000 Binary files a/tests/__pycache__/test_real_mme.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_channel/__pycache__/__init__.cpython-314.pyc b/tests/test_channel/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index bf87c74..0000000 Binary files a/tests/test_channel/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/tests/test_channel/__pycache__/test_code.cpython-314-pytest-9.0.3.pyc b/tests/test_channel/__pycache__/test_code.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 1b24a4d..0000000 Binary files a/tests/test_channel/__pycache__/test_code.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_channel/__pycache__/test_model.cpython-314-pytest-9.0.3.pyc b/tests/test_channel/__pycache__/test_model.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index e9dcd0f..0000000 Binary files a/tests/test_channel/__pycache__/test_model.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_criteria/__pycache__/__init__.cpython-314.pyc b/tests/test_criteria/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index b4a5060..0000000 Binary files a/tests/test_criteria/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/tests/test_criteria/__pycache__/test_hic.cpython-314-pytest-9.0.3.pyc b/tests/test_criteria/__pycache__/test_hic.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 1ae2780..0000000 Binary files a/tests/test_criteria/__pycache__/test_hic.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_criteria/__pycache__/test_nij.cpython-314-pytest-9.0.3.pyc b/tests/test_criteria/__pycache__/test_nij.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 3f30a3f..0000000 Binary files a/tests/test_criteria/__pycache__/test_nij.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_io/__pycache__/__init__.cpython-314.pyc b/tests/test_io/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 9e85d85..0000000 Binary files a/tests/test_io/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/tests/test_io/__pycache__/test_mme.cpython-314-pytest-9.0.3.pyc b/tests/test_io/__pycache__/test_mme.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 8190ddc..0000000 Binary files a/tests/test_io/__pycache__/test_mme.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_protocol/__pycache__/__init__.cpython-314.pyc b/tests/test_protocol/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index d6487eb..0000000 Binary files a/tests/test_protocol/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/tests/test_protocol/__pycache__/test_euro_ncap.cpython-314-pytest-9.0.3.pyc b/tests/test_protocol/__pycache__/test_euro_ncap.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 1eb206f..0000000 Binary files a/tests/test_protocol/__pycache__/test_euro_ncap.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_transform/__pycache__/__init__.cpython-314.pyc b/tests/test_transform/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 557c19d..0000000 Binary files a/tests/test_transform/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/tests/test_transform/__pycache__/test_align.cpython-314-pytest-9.0.3.pyc b/tests/test_transform/__pycache__/test_align.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 82ed7b0..0000000 Binary files a/tests/test_transform/__pycache__/test_align.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_transform/__pycache__/test_cfc.cpython-314-pytest-9.0.3.pyc b/tests/test_transform/__pycache__/test_cfc.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index c473708..0000000 Binary files a/tests/test_transform/__pycache__/test_cfc.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/test_web/test_p2_features.py b/tests/test_web/test_p2_features.py new file mode 100644 index 0000000..f2bd0cf --- /dev/null +++ b/tests/test_web/test_p2_features.py @@ -0,0 +1,230 @@ +"""Tests for Priority 2 web UI features. + +Covers: templates, per-channel overrides, corridors, channel values, math expressions. +""" + +from pathlib import Path +from typing import Any + +import numpy as np +import pytest + +from impakt.io.mme import MMEReader +from impakt.web.state import AppState + +FIXTURE_DATA = Path(__file__).parent.parent / "fixtures" / "sample_mme" +MME_DATA = Path(__file__).parent.parent / "mme_data" + + +class TestTemplateManagement: + def test_save_and_apply_template(self, tmp_path): + from impakt.template.library import TemplateLibrary + + state = AppState() + state._template_library = TemplateLibrary(tmp_path / "templates") + state.load_test(FIXTURE_DATA) + + # Save current state as template + selected = [ + "IMPAKT_SYNTH_001::11HEAD0000ACXA", + "IMPAKT_SYNTH_001::11HEAD0000ACYA", + "IMPAKT_SYNTH_001::11HEAD0000ACZA", + ] + template = state.save_as_template( + name="Test Head Analysis", + description="Head acceleration channels", + selected_keys=selected, + cfc_value="1000", + x1=0.0, + x2=0.05, + ) + + assert template.name == "Test Head Analysis" + assert template.default_cfc == 1000 + assert len(template.plots) == 1 + assert len(template.plots[0].channel_patterns) == 3 + + # Apply the template + resolved_keys, transforms = state.apply_template("test_head_analysis") + assert len(resolved_keys) == 3 + assert transforms["cfc"] == "1000" + assert state.active_template is not None + assert state.active_template.name == "Test Head Analysis" + + def test_template_names(self, tmp_path): + from impakt.template.library import TemplateLibrary + + state = AppState() + state._template_library = TemplateLibrary(tmp_path / "templates") + assert state.template_names == [] + + state.load_test(FIXTURE_DATA) + state.save_as_template( + name="Test", + description="", + selected_keys=["IMPAKT_SYNTH_001::11HEAD0000ACXA"], + cfc_value="none", + x1=None, + x2=None, + ) + assert "test" in state.template_names + + +class TestPerChannelOverrides: + def test_set_and_read_override(self): + state = AppState() + state.load_test(FIXTURE_DATA) + + key = "IMPAKT_SYNTH_001::11HEAD0000ACXA" + state.channel_overrides[key] = {"cfc": "600"} + + assert state.channel_overrides[key]["cfc"] == "600" + + def test_override_clears(self): + state = AppState() + state.channel_overrides["key"] = {"cfc": "180"} + state.channel_overrides.pop("key", None) + assert "key" not in state.channel_overrides + + +class TestCorridors: + def test_add_corridor(self): + state = AppState() + corridor = { + "name": "Test Corridor", + "time": [0.0, 0.01, 0.02, 0.03], + "lower": [-10, -20, -20, -10], + "upper": [10, 20, 20, 10], + "visible": True, + } + state.corridors.append(corridor) + assert len(state.corridors) == 1 + assert state.corridors[0]["name"] == "Test Corridor" + + +class TestChannelValues: + def test_build_channel_values_data(self): + from impakt.web.components.channel_values import build_channel_values_data + + state = AppState() + state.load_test(FIXTURE_DATA) + + channels = [] + for name in ["11HEAD0000ACXA", "11HEAD0000ACYA"]: + ch = state.get_channel("IMPAKT_SYNTH_001", name) + if ch: + channels.append((ch.code.short_label, ch)) + + rows = build_channel_values_data(channels, hover_x=0.05, x1=0.0, x2=0.1) + assert len(rows) == 2 + + for row in rows: + assert "ch_num" in row + assert "iso_code" in row + assert "description" in row + assert "unit" in row + assert "min" in row + assert "min_time" in row + assert "max" in row + assert "max_time" in row + assert "x1" in row + assert "x2" in row + assert "cursor" in row + + # Channel numbers should be sequential starting at 1 + assert rows[0]["ch_num"] == 1 + assert rows[1]["ch_num"] == 2 + + # Values should be parseable as floats + for row in rows: + float(row["min"]) + float(row["max"]) + float(row["x1"]) + float(row["x2"]) + float(row["cursor"]) + + def test_channel_values_no_hover(self): + from impakt.web.components.channel_values import build_channel_values_data + + state = AppState() + state.load_test(FIXTURE_DATA) + + ch = state.get_channel("IMPAKT_SYNTH_001", "11HEAD0000ACXA") + rows = build_channel_values_data([("Head X", ch)], hover_x=None, x1=0.0, x2=0.1) + assert len(rows) == 1 + assert rows[0]["cursor"] == "" # No hover = empty cursor column + + @pytest.mark.skipif(not (MME_DATA / "3239").exists(), reason="Real data not available") + def test_channel_values_real_data(self): + from impakt.web.components.channel_values import build_channel_values_data + + state = AppState() + state.load_test(MME_DATA / "3239") + + ch = state.get_channel("3239", "11HEAD0000H3ACXP") + assert ch is not None + + rows = build_channel_values_data([("Head Accel X", ch)], hover_x=0.05, x1=0.0, x2=0.05) + assert len(rows) == 1 + # Min should be significant (frontal crash head X accel) + assert abs(float(rows[0]["min"])) > 100 + + +class TestMathExpression: + def test_math_expr_in_state(self): + from impakt.transform.math_expr import math_expr + + state = AppState() + state.load_test(FIXTURE_DATA) + + ch_x = state.get_channel("IMPAKT_SYNTH_001", "11HEAD0000ACXA") + ch_z = state.get_channel("IMPAKT_SYNTH_001", "11HEAD0000ACZA") + assert ch_x is not None and ch_z is not None + + result = math_expr( + expression="sqrt(a**2 + b**2)", + channels={"a": ch_x, "b": ch_z}, + name="head_xz_resultant", + unit="g", + ) + + assert result.name == "head_xz_resultant" + assert result.unit == "g" + assert result.peak > 0 + assert len(result.data) == len(ch_x.data) + + # Store in primary test + primary = state.primary_test + primary.data._channels["head_xz_resultant"] = result + + # Should be retrievable + retrieved = state.get_channel("IMPAKT_SYNTH_001", "head_xz_resultant") + assert retrieved is not None + assert retrieved.peak == result.peak + + +class TestSessionAutoSave: + def test_save_and_load_session(self, tmp_path): + # Create a minimal MME fixture in tmp_path + import shutil + + test_dir = tmp_path / "test_session" + shutil.copytree(FIXTURE_DATA, test_dir) + + state = AppState() + state.load_test(test_dir) + + # Save session + selected = ["IMPAKT_SYNTH_001::11HEAD0000ACXA"] + state.save_session(selected, "600") + + # Verify .impakt directory was created + impakt_dir = test_dir / ".impakt" + assert impakt_dir.exists() + assert (impakt_dir / "session.yaml").exists() + + # Load session state + session_data = state.load_session_state() + assert session_data is not None + assert session_data["cfc"] == "600" + assert session_data["selected_channels"] == selected