bookmark - UI P2
This commit is contained in:
104
docs/STATUS.md
104
docs/STATUS.md
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
**Date:** 2026-04-10
|
**Date:** 2026-04-10
|
||||||
**Version:** 0.1.0
|
**Version:** 0.1.0
|
||||||
**Tests:** 171 passing
|
**Tests:** 181 passing
|
||||||
**Source:** ~9,200 lines Python | ~1,900 lines tests | ~3,400 lines web (py+js+css) | ~190 lines HTML templates
|
**Source:** ~11,000 lines (py+js+css+html) | ~2,150 lines tests
|
||||||
**Tooling:** uv (Python 3.12.12), hatchling build backend
|
**Tooling:** uv (Python 3.12.12), hatchling build backend
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -13,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 171 tests
|
uv run pytest tests/ # run all 181 tests
|
||||||
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
|
||||||
```
|
```
|
||||||
@@ -85,22 +85,28 @@ impakt/
|
|||||||
protocol_report.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: server-side multi-test state manager
|
state.py # AppState: multi-test state, templates, sessions, corridors
|
||||||
layout.py # Top-level layout: tabs, flex splitter, component assembly
|
layout.py # Top-level layout: Data tab + Analysis tab
|
||||||
components/ # Reusable layout components
|
components/ # 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
|
||||||
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)
|
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
|
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)
|
report.py # Export panel (PNG/SVG/PDF, CSV, protocol report)
|
||||||
callbacks/ # Feature-specific callback modules
|
callbacks/ # Feature-specific callback modules
|
||||||
__init__.py # Registration hub: register_callbacks()
|
__init__.py # Registration hub: register_callbacks()
|
||||||
channel_callbacks.py # Channel selection, filtering, badge display
|
channel_callbacks.py # Selection, filtering, badges, per-channel overrides
|
||||||
plot_callbacks.py # Plot rendering, transform pipeline
|
plot_callbacks.py # Plot rendering, transform pipeline, corridor display
|
||||||
cursor_callbacks.py # Live cursor grid updates via polled JS hover
|
cursor_callbacks.py # Channel values table (live hover + X1/X2)
|
||||||
criteria_callbacks.py # Compute All button, protocol scoring
|
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
|
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
|
||||||
@@ -121,7 +127,7 @@ impakt/
|
|||||||
test_io/ # MMEReader
|
test_io/ # MMEReader
|
||||||
test_protocol/ # Euro NCAP scoring
|
test_protocol/ # Euro NCAP scoring
|
||||||
test_transform/ # CFC filter, alignment
|
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/
|
fixtures/
|
||||||
generate_mme.py # Synthetic MME generator (26 channels, half-sine)
|
generate_mme.py # Synthetic MME generator (26 channels, half-sine)
|
||||||
sample_mme/ # Generated synthetic test data
|
sample_mme/ # Generated synthetic test data
|
||||||
@@ -168,41 +174,28 @@ impakt/
|
|||||||
|
|
||||||
### Web UI -- Current State
|
### 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:**
|
**Data Tab:**
|
||||||
- Two tabs: **Data** (channels + plot + cursor grid) and **Analysis** (criteria + export)
|
- **Left panel** (resizable via draggable splitter): channel grid + transform controls
|
||||||
- Draggable splitter between left panel and plot area (pure JS, no deps)
|
- **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)
|
||||||
- Left panel resizable from 200px to 600px
|
- **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 Grid (left panel):**
|
- **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.
|
||||||
- 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)
|
|
||||||
|
|
||||||
**Analysis Tab:**
|
**Analysis Tab:**
|
||||||
- Auto-detect channels by ISO naming and compute HIC15, 3ms clip, Nij, chest defl, femur, tibia
|
- **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
|
||||||
- Protocol scoring: Euro NCAP / US NCAP / IIHS with color-coded results table 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
|
||||||
- CSV export of plotted channel data (with transforms applied)
|
- **Template Management**: library browser, apply (resolves channel patterns + sets CFC), save current view as template, delete, session auto-save
|
||||||
- Protocol report generation (HTML)
|
- **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:**
|
**Consistent Color System:**
|
||||||
- Open Test / Add Overlay buttons with modal dialogs
|
Every selected channel has a stable color index (position in selection order). The same color appears in:
|
||||||
- Test info panel showing all loaded tests with metadata
|
- Plot traces
|
||||||
- Multi-test support in channel grid and plot labels
|
- Channel grid rows (tinted background + solid left border)
|
||||||
|
- Selected badges (colored dot)
|
||||||
|
- Channel Values table rows (tinted background + solid left border)
|
||||||
|
|
||||||
### Key design decisions:
|
### 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.
|
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.
|
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.
|
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.
|
6. **Custom JS cursor tracking** -- bypasses Plotly's hover system with raw mousemove + pixel-to-data conversion.
|
||||||
7. **uv for package management** -- dev deps in `[dependency-groups]`.
|
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. **Annotations** -- text on plots, measurement lines, highlight regions
|
||||||
|
2. **Comparison mode** -- side-by-side tests, delta plots, synced cursors
|
||||||
1. **Template management UI** -- template browser, apply/save/edit from UI, session auto-save
|
3. **Report builder** -- drag-and-drop report composer, PDF preview
|
||||||
2. **Enhanced transform controls** -- per-channel CFC, per-channel X/Y-align
|
4. **Keyboard shortcuts** -- Ctrl+O, Ctrl+S, 1-9 pane switch, F fullscreen, R reset zoom
|
||||||
3. **Corridor management** -- load from CSV, draw on plot, corridor library
|
5. **Consider Python/WASM frontend** -- NiceGUI or Solara for pure-Python UI (no JS)
|
||||||
4. **Channel inspector** -- tabular data view, statistics (min/max/RMS/peak time)
|
|
||||||
5. **Math expression builder** -- formula input with autocomplete, live preview
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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).
|
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.
|
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.
|
3. **DataTable deprecation warning** -- Dash recommends migrating to dash-ag-grid.
|
||||||
4. **Cursor poll interval (80ms)** -- adds slight latency to cursor grid updates. Could use WebSocket for lower latency in future.
|
4. **Cursor poll interval (80ms)** -- slight latency in cursor grid updates.
|
||||||
5. **Chest deflection auto-detect** skips DS channels with peak > 150mm (avoids steering column displacement). May miss some legitimate high-deflection data.
|
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`.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -71,6 +71,13 @@ def create_app(
|
|||||||
external_stylesheets=[dbc.themes.FLATLY],
|
external_stylesheets=[dbc.themes.FLATLY],
|
||||||
title=title,
|
title=title,
|
||||||
suppress_callback_exceptions=True,
|
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)
|
app.layout = build_layout(app_state, template_names)
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ from __future__ import annotations
|
|||||||
import dash
|
import dash
|
||||||
|
|
||||||
from impakt.web.callbacks.channel_callbacks import register_channel_callbacks
|
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.criteria_callbacks import register_criteria_callbacks
|
||||||
from impakt.web.callbacks.cursor_callbacks import register_cursor_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.export_callbacks import register_export_callbacks
|
||||||
from impakt.web.callbacks.file_callbacks import register_file_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.plot_callbacks import register_plot_callbacks
|
||||||
|
from impakt.web.callbacks.template_callbacks import register_template_callbacks
|
||||||
from impakt.web.state import AppState
|
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_criteria_callbacks(app, app_state)
|
||||||
register_file_callbacks(app, app_state)
|
register_file_callbacks(app, app_state)
|
||||||
register_export_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)
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
Handles:
|
Handles:
|
||||||
- DataTable row selection -> updates selected channels store
|
- DataTable row selection -> updates selected channels store
|
||||||
- Wildcard filter + facet dropdowns -> filters table rows
|
- Filter/facet changes -> filters table rows AND recomputes selected_rows
|
||||||
- Selected channels badge display
|
indices to preserve selection across filter changes
|
||||||
- Filter clear button
|
- Selected channels badge display with consistent colors
|
||||||
|
- Per-channel CFC override controls
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,13 +13,15 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import dash
|
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 dash.exceptions import PreventUpdate
|
||||||
|
|
||||||
|
from impakt.plot.engine import DEFAULT_COLORS
|
||||||
from impakt.web.components.channel_grid import (
|
from impakt.web.components.channel_grid import (
|
||||||
build_selected_channels_badges,
|
build_selected_channels_badges,
|
||||||
filter_rows,
|
filter_rows,
|
||||||
)
|
)
|
||||||
|
from impakt.web.components.transforms import build_per_channel_override_rows
|
||||||
from impakt.web.state import AppState
|
from impakt.web.state import AppState
|
||||||
|
|
||||||
|
|
||||||
@@ -28,21 +31,47 @@ def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
@app.callback(
|
@app.callback(
|
||||||
Output("selected-channels-store", "data"),
|
Output("selected-channels-store", "data"),
|
||||||
[Input("channel-grid", "selected_rows")],
|
[Input("channel-grid", "selected_rows")],
|
||||||
[State("channel-grid", "data")],
|
[
|
||||||
|
State("channel-grid", "data"),
|
||||||
|
State("selected-channels-store", "data"),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
def sync_selection_to_store(
|
def sync_selection_to_store(
|
||||||
selected_row_indices: list[int] | None,
|
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]:
|
) -> list[str]:
|
||||||
"""When user checks/unchecks rows in the DataTable, update the store."""
|
"""When user checks/unchecks rows, merge with existing selection.
|
||||||
if not selected_row_indices or not current_data:
|
|
||||||
return []
|
|
||||||
|
|
||||||
keys = []
|
The key insight: selected_rows are indices into the *currently visible*
|
||||||
for idx in selected_row_indices:
|
data (after filtering). We need to:
|
||||||
if 0 <= idx < len(current_data):
|
1. Determine which visible rows are now checked
|
||||||
keys.append(current_data[idx]["key"])
|
2. Keep any previously-selected rows that aren't visible (filtered out)
|
||||||
return keys
|
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(
|
@app.callback(
|
||||||
Output("selected-channels-badges", "children"),
|
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)
|
return build_selected_channels_badges(selected_keys or [], app_state)
|
||||||
|
|
||||||
@app.callback(
|
@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-input", "value"),
|
||||||
Input("channel-filter-clear", "n_clicks"),
|
Input("channel-filter-clear", "n_clicks"),
|
||||||
Input("facet-body", "value"),
|
Input("facet-body", "value"),
|
||||||
Input("facet-meas", "value"),
|
Input("facet-meas", "value"),
|
||||||
Input("facet-direction", "value"),
|
Input("facet-direction", "value"),
|
||||||
|
Input("selected-channels-store", "data"),
|
||||||
],
|
],
|
||||||
[State("channel-grid-all-rows", "data")],
|
[State("channel-grid-all-rows", "data")],
|
||||||
)
|
)
|
||||||
def apply_filters(
|
def apply_filters_and_selection(
|
||||||
pattern: str | None,
|
pattern: str | None,
|
||||||
clear_clicks: int | None,
|
clear_clicks: int | None,
|
||||||
body: str | None,
|
body: str | None,
|
||||||
meas: str | None,
|
meas: str | None,
|
||||||
direction: str | None,
|
direction: str | None,
|
||||||
|
selected_keys: list[str] | None,
|
||||||
all_rows: list[dict[str, Any]] | None,
|
all_rows: list[dict[str, Any]] | None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> tuple[list[dict[str, Any]], list[int], list[dict]]:
|
||||||
"""Filter the channel grid based on wildcard pattern and facets."""
|
"""Filter rows AND recompute selected_rows + color styling."""
|
||||||
if not all_rows:
|
if not all_rows:
|
||||||
return []
|
return [], [], []
|
||||||
|
|
||||||
# If clear was clicked, return all rows
|
|
||||||
trigger = dash.ctx.triggered_id
|
trigger = dash.ctx.triggered_id
|
||||||
if trigger == "channel-filter-clear":
|
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(
|
# Build a lookup of selected keys to their color index
|
||||||
all_rows,
|
selected_set = set(selected_keys or [])
|
||||||
pattern=pattern or "",
|
selected_list = list(selected_keys or [])
|
||||||
body=body or "",
|
color_map: dict[str, int] = {}
|
||||||
meas=meas or "",
|
for i, key in enumerate(selected_list):
|
||||||
direction=direction or "",
|
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(
|
@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]:
|
def clear_filters(n_clicks: int | None) -> tuple[str, str, str, str]:
|
||||||
"""Clear all filter inputs."""
|
"""Clear all filter inputs."""
|
||||||
return "", "", "", ""
|
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)
|
||||||
|
|||||||
149
src/impakt/web/callbacks/corridor_callbacks.py
Normal file
149
src/impakt/web/callbacks/corridor_callbacks.py
Normal file
@@ -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
|
||||||
@@ -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.
|
Updates the combined channel values DataTable with statistics and
|
||||||
|
live cursor values as the user moves the mouse over the plot.
|
||||||
The JS cursor_tracker.js writes the hover X position to
|
Row colors match the plot trace colors.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -14,19 +10,19 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import dash
|
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 dash.exceptions import PreventUpdate
|
||||||
|
|
||||||
|
from impakt.plot.engine import DEFAULT_COLORS
|
||||||
from impakt.web.callbacks.plot_callbacks import _resolve_channels
|
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
|
from impakt.web.state import AppState
|
||||||
|
|
||||||
|
|
||||||
def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
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)
|
# 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(
|
app.clientside_callback(
|
||||||
"""
|
"""
|
||||||
function(n_intervals) {
|
function(n_intervals) {
|
||||||
@@ -42,28 +38,30 @@ def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@app.callback(
|
@app.callback(
|
||||||
Output("cursor-grid", "data"),
|
Output("channel-values-grid", "data"),
|
||||||
[
|
[
|
||||||
Input("cursor-hover-x", "data"),
|
Input("cursor-hover-x", "data"),
|
||||||
Input("cursor-x1", "value"),
|
Input("cursor-x1", "value"),
|
||||||
Input("cursor-x2", "value"),
|
Input("cursor-x2", "value"),
|
||||||
Input("selected-channels-store", "data"),
|
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-method", "value"),
|
||||||
State("x-align-value", "value"),
|
State("x-align-value", "value"),
|
||||||
State("show-resultant", "value"),
|
State("show-resultant", "value"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def update_cursor_grid(
|
def update_channel_values_data(
|
||||||
hover_x: float | None,
|
hover_x: float | None,
|
||||||
cursor_x1: float | None,
|
cursor_x1: float | None,
|
||||||
cursor_x2: float | None,
|
cursor_x2: float | None,
|
||||||
selected_keys: list[str] | None,
|
selected_keys: list[str] | None,
|
||||||
cfc_value: str,
|
cfc_value: str,
|
||||||
y_align: bool,
|
y_align: bool,
|
||||||
|
channel_overrides: dict | None,
|
||||||
x_align_method: str,
|
x_align_method: str,
|
||||||
x_align_value: float | None,
|
x_align_value: float | None,
|
||||||
show_resultant: bool,
|
show_resultant: bool,
|
||||||
@@ -71,7 +69,6 @@ def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
if not selected_keys:
|
if not selected_keys:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Resolve channels with current transforms
|
|
||||||
channels = _resolve_channels(
|
channels = _resolve_channels(
|
||||||
selected_keys,
|
selected_keys,
|
||||||
app_state,
|
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
|
x1 = cursor_x1 if cursor_x1 is not None else 0.0
|
||||||
x2 = cursor_x2 if cursor_x2 is not None else 0.1
|
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
|
||||||
|
|||||||
125
src/impakt/web/callbacks/math_callbacks.py
Normal file
125
src/impakt/web/callbacks/math_callbacks.py
Normal file
@@ -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 [],
|
||||||
|
)
|
||||||
@@ -35,7 +35,11 @@ def _resolve_channels(
|
|||||||
x_align_value: float | None,
|
x_align_value: float | None,
|
||||||
show_resultant: bool,
|
show_resultant: bool,
|
||||||
) -> list[tuple[str, Channel]]:
|
) -> 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]] = []
|
channels: list[tuple[str, Channel]] = []
|
||||||
|
|
||||||
for key in selected_keys:
|
for key in selected_keys:
|
||||||
@@ -51,10 +55,14 @@ def _resolve_channels(
|
|||||||
if ch is None:
|
if ch is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Apply CFC filter
|
# Determine CFC: per-channel override takes precedence over global
|
||||||
if cfc_value != "none":
|
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:
|
try:
|
||||||
ch = CFCFilter(cfc_class=int(cfc_value)).apply(ch)
|
ch = CFCFilter(cfc_class=int(effective_cfc)).apply(ch)
|
||||||
except (ValueError, Exception):
|
except (ValueError, Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -105,6 +113,7 @@ def _build_figure(
|
|||||||
cursor_x1: float | None,
|
cursor_x1: float | None,
|
||||||
cursor_x2: float | None,
|
cursor_x2: float | None,
|
||||||
cfc_value: str,
|
cfc_value: str,
|
||||||
|
corridors: list[dict] | None = None,
|
||||||
) -> go.Figure:
|
) -> go.Figure:
|
||||||
"""Build a Plotly figure from resolved channels."""
|
"""Build a Plotly figure from resolved channels."""
|
||||||
fig = go.Figure()
|
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
|
# Add X1/X2 cursor lines
|
||||||
if cursor_x1 is not None:
|
if cursor_x1 is not None:
|
||||||
fig.add_vline(
|
fig.add_vline(
|
||||||
@@ -171,43 +215,12 @@ def _build_figure(
|
|||||||
y_label = channels[0][1].unit if channels else ""
|
y_label = channels[0][1].unit if channels else ""
|
||||||
|
|
||||||
fig.update_layout(
|
fig.update_layout(
|
||||||
xaxis_title="Time (s)",
|
xaxis_title=dict(text="Time (s)", font=dict(size=10, color="#999")),
|
||||||
yaxis_title=y_label,
|
yaxis_title=dict(text=y_label, font=dict(size=10, color="#999")),
|
||||||
template="plotly_white",
|
template="plotly_white",
|
||||||
hovermode=False,
|
hovermode=False,
|
||||||
showlegend=True,
|
showlegend=False,
|
||||||
legend=dict(
|
margin=dict(l=45, r=8, t=4, b=28),
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return fig
|
return fig
|
||||||
@@ -226,6 +239,8 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
Input("x-align-method", "value"),
|
Input("x-align-method", "value"),
|
||||||
Input("cursor-x1", "value"),
|
Input("cursor-x1", "value"),
|
||||||
Input("cursor-x2", "value"),
|
Input("cursor-x2", "value"),
|
||||||
|
Input("channel-overrides-store", "data"),
|
||||||
|
Input("corridors-store", "data"),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
State("x-align-value", "value"),
|
State("x-align-value", "value"),
|
||||||
@@ -239,6 +254,8 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
x_align_method: str,
|
x_align_method: str,
|
||||||
cursor_x1: float | None,
|
cursor_x1: float | None,
|
||||||
cursor_x2: float | None,
|
cursor_x2: float | None,
|
||||||
|
channel_overrides: dict | None,
|
||||||
|
corridors_data: list[dict] | None,
|
||||||
x_align_value: float | None,
|
x_align_value: float | None,
|
||||||
) -> go.Figure:
|
) -> go.Figure:
|
||||||
if not selected_keys:
|
if not selected_keys:
|
||||||
@@ -254,4 +271,10 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None:
|
|||||||
show_resultant,
|
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,
|
||||||
|
)
|
||||||
|
|||||||
152
src/impakt/web/callbacks/template_callbacks.py
Normal file
152
src/impakt/web/callbacks/template_callbacks.py
Normal file
@@ -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)}
|
||||||
@@ -260,14 +260,7 @@ def build_channel_grid(app_state: AppState) -> dbc.Card:
|
|||||||
{"if": {"column_id": "max"}, "width": "65px", "textAlign": "right"},
|
{"if": {"column_id": "max"}, "width": "65px", "textAlign": "right"},
|
||||||
{"if": {"column_id": "test_id"}, "width": "60px"},
|
{"if": {"column_id": "test_id"}, "width": "60px"},
|
||||||
],
|
],
|
||||||
style_data_conditional=[
|
style_data_conditional=[],
|
||||||
{
|
|
||||||
"if": {"state": "selected"},
|
|
||||||
"backgroundColor": "#e8f4fd",
|
|
||||||
"border": "none",
|
|
||||||
"borderBottom": "1px solid #b8daff",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
style_as_list_view=True,
|
style_as_list_view=True,
|
||||||
fixed_rows={"headers": True},
|
fixed_rows={"headers": True},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,40 +1,41 @@
|
|||||||
"""Cursor values grid component.
|
"""Combined channel values table.
|
||||||
|
|
||||||
Displays a DataTable below the plot showing interpolated Y values for all
|
A single DataTable showing all channel information for plotted channels:
|
||||||
plotted channels at three X positions:
|
- #: sequential channel number
|
||||||
|
- ISO Code: fixed-width channel code
|
||||||
- **Cursor**: The live mouse position (updates as the user moves the mouse
|
- Description: flex column, fills remaining space
|
||||||
over the plot).
|
- Unit: engineering unit
|
||||||
- **X1**: A locked reference position (set by the user via input or click).
|
- Min @ Time, Max @ Time: peak statistics with time of occurrence
|
||||||
- **X2**: A second locked reference position.
|
- X1, X2: interpolated values at locked cursor positions
|
||||||
|
- Cursor: live interpolated value at mouse hover position
|
||||||
This replaces the old button-driven cursor panel with a live-updating grid.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
from dash import dash_table, dcc, html
|
from dash import dash_table, dcc, html
|
||||||
|
|
||||||
from impakt.channel.model import Channel
|
from impakt.channel.model import Channel
|
||||||
|
|
||||||
|
|
||||||
def build_cursor_panel() -> dbc.Card:
|
def build_channel_values_panel() -> dbc.Card:
|
||||||
"""Build the cursor values grid panel."""
|
"""Build the combined channel values panel."""
|
||||||
return dbc.Card(
|
return dbc.Card(
|
||||||
[
|
[
|
||||||
dbc.CardHeader(
|
dbc.CardHeader(
|
||||||
[
|
[
|
||||||
html.Span("Cursor Values", className="fw-bold"),
|
html.Span("Channel Values", className="fw-bold"),
|
||||||
html.Span(
|
html.Span(
|
||||||
" — hover over plot",
|
" — hover over plot for live cursor",
|
||||||
className="text-muted",
|
className="text-muted",
|
||||||
style={"fontSize": "10px", "fontWeight": "normal"},
|
style={"fontSize": "10px", "fontWeight": "normal"},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
className="py-2",
|
className="py-1",
|
||||||
),
|
),
|
||||||
dbc.CardBody(
|
dbc.CardBody(
|
||||||
[
|
[
|
||||||
@@ -100,22 +101,29 @@ def build_cursor_panel() -> dbc.Card:
|
|||||||
width=6,
|
width=6,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
className="mb-2",
|
className="mb-1",
|
||||||
),
|
),
|
||||||
# Cursor values DataTable
|
# Combined DataTable
|
||||||
dash_table.DataTable(
|
dash_table.DataTable(
|
||||||
id="cursor-grid",
|
id="channel-values-grid",
|
||||||
columns=[
|
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": "Unit", "id": "unit"},
|
||||||
{"name": "Cursor", "id": "cursor"},
|
{"name": "Min", "id": "min", "type": "numeric"},
|
||||||
{"name": "X1", "id": "x1"},
|
{"name": "@ Time", "id": "min_time"},
|
||||||
{"name": "X2", "id": "x2"},
|
{"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=[],
|
data=[],
|
||||||
style_table={
|
style_table={
|
||||||
"overflowY": "auto",
|
"overflowY": "auto",
|
||||||
"maxHeight": "200px",
|
"overflowX": "auto",
|
||||||
|
"maxHeight": "250px",
|
||||||
},
|
},
|
||||||
style_header={
|
style_header={
|
||||||
"backgroundColor": "#f8f9fa",
|
"backgroundColor": "#f8f9fa",
|
||||||
@@ -123,77 +131,109 @@ def build_cursor_panel() -> dbc.Card:
|
|||||||
"fontSize": "10px",
|
"fontSize": "10px",
|
||||||
"padding": "3px 6px",
|
"padding": "3px 6px",
|
||||||
"borderBottom": "2px solid #dee2e6",
|
"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={
|
style_cell={
|
||||||
"fontSize": "11px",
|
"fontSize": "11px",
|
||||||
"fontFamily": "'SF Mono', 'Menlo', 'Monaco', monospace",
|
"fontFamily": "'SF Mono', 'Menlo', 'Monaco', monospace",
|
||||||
"padding": "2px 6px",
|
"padding": "2px 6px",
|
||||||
"border": "none",
|
"border": "none",
|
||||||
"borderBottom": "1px solid #f0f0f0",
|
"borderBottom": "1px solid #f0f0f0",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"textOverflow": "ellipsis",
|
||||||
|
"whiteSpace": "nowrap",
|
||||||
},
|
},
|
||||||
style_cell_conditional=[
|
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",
|
"fontFamily": "inherit",
|
||||||
"textAlign": "left",
|
"textAlign": "left",
|
||||||
"minWidth": "100px",
|
|
||||||
"overflow": "hidden",
|
|
||||||
"textOverflow": "ellipsis",
|
|
||||||
"whiteSpace": "nowrap",
|
|
||||||
},
|
},
|
||||||
{"if": {"column_id": "unit"}, "width": "45px", "textAlign": "center"},
|
{"if": {"column_id": "unit"}, "width": "2%", "textAlign": "center"},
|
||||||
{"if": {"column_id": "cursor"}, "textAlign": "right", "width": "80px"},
|
{"if": {"column_id": "min"}, "width": "8%", "textAlign": "right"},
|
||||||
{"if": {"column_id": "x1"}, "textAlign": "right", "width": "80px"},
|
|
||||||
{"if": {"column_id": "x2"}, "textAlign": "right", "width": "80px"},
|
|
||||||
],
|
|
||||||
style_data_conditional=[
|
|
||||||
{
|
{
|
||||||
"if": {"column_id": "cursor"},
|
"if": {"column_id": "min_time"},
|
||||||
"color": "#0d6efd",
|
"width": "7%",
|
||||||
"fontWeight": "500",
|
"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,
|
style_as_list_view=True,
|
||||||
fixed_rows={"headers": True},
|
fixed_rows={"headers": True},
|
||||||
|
sort_action="native",
|
||||||
),
|
),
|
||||||
# Interval that polls the JS-side hover X position
|
# Interval that polls the JS-side hover X position
|
||||||
dcc.Interval(id="cursor-poll-interval", interval=80, n_intervals=0),
|
dcc.Interval(id="cursor-poll-interval", interval=80, n_intervals=0),
|
||||||
# Hidden store for the current hover X position
|
# Hidden store for the current hover X position
|
||||||
dcc.Store(id="cursor-hover-x", data=None),
|
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]],
|
channels: list[tuple[str, Channel]],
|
||||||
hover_x: float | None,
|
hover_x: float | None,
|
||||||
x1: float,
|
x1: float,
|
||||||
x2: float,
|
x2: float,
|
||||||
) -> list[dict[str, str]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Build the cursor grid rows from resolved channels.
|
"""Build combined channel values rows.
|
||||||
|
|
||||||
Args:
|
Each row contains identity, statistics, and cursor values for one channel.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
rows: list[dict[str, str]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
for label, ch in channels:
|
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 ""
|
cursor_val = f"{ch.value_at(hover_x):.4f}" if hover_x is not None else ""
|
||||||
|
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"channel": label,
|
"ch_num": idx + 1,
|
||||||
|
"iso_code": ch.name,
|
||||||
|
"description": label,
|
||||||
"unit": ch.unit,
|
"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}",
|
"x1": f"{ch.value_at(x1):.4f}",
|
||||||
"x2": f"{ch.value_at(x2):.4f}",
|
"x2": f"{ch.value_at(x2):.4f}",
|
||||||
|
"cursor": cursor_val,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return rows
|
return rows
|
||||||
76
src/impakt/web/components/corridors.py
Normal file
76
src/impakt/web/components/corridors.py
Normal file
@@ -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",
|
||||||
|
)
|
||||||
165
src/impakt/web/components/math_builder.py
Normal file
165
src/impakt/web/components/math_builder.py
Normal file
@@ -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",
|
||||||
|
)
|
||||||
@@ -103,10 +103,10 @@ def _build_single_pane(pane_idx: int, total_panes: int) -> dbc.Card:
|
|||||||
clear_on_unhover=False,
|
clear_on_unhover=False,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
className="p-2",
|
className="p-0",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
className="mb-2",
|
className="mb-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
110
src/impakt/web/components/templates.py
Normal file
110
src/impakt/web/components/templates.py
Normal file
@@ -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",
|
||||||
|
)
|
||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
from dash import html
|
from dash import dcc, html
|
||||||
|
|
||||||
|
|
||||||
def build_transform_panel() -> dbc.Card:
|
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.CardHeader("Transforms", className="fw-bold py-2"),
|
||||||
dbc.CardBody(
|
dbc.CardBody(
|
||||||
[
|
[
|
||||||
# CFC Filter
|
# Global CFC Filter
|
||||||
dbc.Label("CFC Filter", size="sm", className="mb-1"),
|
dbc.Label("CFC Filter (global)", size="sm", className="mb-1"),
|
||||||
dbc.Select(
|
dbc.Select(
|
||||||
id="cfc-select",
|
id="cfc-select",
|
||||||
options=[
|
options=[
|
||||||
@@ -62,11 +65,98 @@ def build_transform_panel() -> dbc.Card:
|
|||||||
placeholder="Time offset (s) or threshold",
|
placeholder="Time offset (s) or threshold",
|
||||||
size="sm",
|
size="sm",
|
||||||
step=0.001,
|
step=0.001,
|
||||||
className="mb-1",
|
className="mb-2",
|
||||||
style={"display": "none"},
|
|
||||||
),
|
),
|
||||||
|
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",
|
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
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import dash_bootstrap_components as dbc
|
|||||||
from dash import dcc, html
|
from dash import dcc, html
|
||||||
|
|
||||||
from impakt.web.components.channel_grid import build_channel_grid
|
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.criteria import build_criteria_panel
|
||||||
from impakt.web.components.cursors import build_cursor_panel
|
|
||||||
from impakt.web.components.header import (
|
from impakt.web.components.header import (
|
||||||
build_header,
|
build_header,
|
||||||
build_open_test_modal,
|
build_open_test_modal,
|
||||||
@@ -23,7 +23,10 @@ from impakt.web.components.header import (
|
|||||||
build_test_info_panel,
|
build_test_info_panel,
|
||||||
)
|
)
|
||||||
from impakt.web.components.plot_grid import build_plot_grid
|
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.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.components.transforms import build_transform_panel
|
||||||
from impakt.web.state import AppState
|
from impakt.web.state import AppState
|
||||||
|
|
||||||
@@ -61,11 +64,11 @@ def _build_data_tab(app_state: AppState) -> html.Div:
|
|||||||
"zIndex": "10",
|
"zIndex": "10",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
# === Right side: Plot area + Cursor grid (full width) ===
|
# === Right side: Plot area + Channel values table (full width) ===
|
||||||
html.Div(
|
html.Div(
|
||||||
[
|
[
|
||||||
build_plot_grid("1x1"),
|
build_plot_grid("1x1"),
|
||||||
build_cursor_panel(),
|
build_channel_values_panel(),
|
||||||
],
|
],
|
||||||
style={
|
style={
|
||||||
"flex": "1",
|
"flex": "1",
|
||||||
@@ -86,24 +89,22 @@ def _build_data_tab(app_state: AppState) -> html.Div:
|
|||||||
|
|
||||||
|
|
||||||
def _build_analysis_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(
|
return html.Div(
|
||||||
[
|
[
|
||||||
dbc.Row(
|
dbc.Row(
|
||||||
[
|
[
|
||||||
dbc.Col(
|
dbc.Col([build_criteria_panel()], width=4),
|
||||||
[
|
dbc.Col([build_math_panel(app_state)], width=4),
|
||||||
build_criteria_panel(),
|
dbc.Col([build_template_panel(app_state)], width=4),
|
||||||
],
|
],
|
||||||
width=6,
|
className="mb-2",
|
||||||
),
|
),
|
||||||
dbc.Col(
|
dbc.Row(
|
||||||
[
|
[
|
||||||
build_report_panel(),
|
dbc.Col([build_corridor_panel()], width=6),
|
||||||
],
|
dbc.Col([build_report_panel()], width=6),
|
||||||
width=6,
|
],
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
style={"padding": "8px"},
|
style={"padding": "8px"},
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ from typing import Any
|
|||||||
|
|
||||||
from impakt.channel.model import Channel, ChannelGroup, TestData
|
from impakt.channel.model import Channel, ChannelGroup, TestData
|
||||||
from impakt.io.reader import get_registry
|
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.align import YAlign
|
||||||
from impakt.transform.cfc import CFCFilter
|
from impakt.transform.cfc import CFCFilter
|
||||||
|
|
||||||
@@ -64,6 +67,13 @@ class AppState:
|
|||||||
self._tests: dict[str, LoadedTest] = {}
|
self._tests: dict[str, LoadedTest] = {}
|
||||||
self._test_order: list[str] = []
|
self._test_order: list[str] = []
|
||||||
self._color_counter: int = 0
|
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:
|
def load_test(self, path: str | Path) -> LoadedTest:
|
||||||
"""Load a test from a path and add it to the state.
|
"""Load a test from a path and add it to the state.
|
||||||
@@ -221,6 +231,158 @@ class AppState:
|
|||||||
|
|
||||||
return items
|
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
|
@property
|
||||||
def is_empty(self) -> bool:
|
def is_empty(self) -> bool:
|
||||||
return len(self._tests) == 0
|
return len(self._tests) == 0
|
||||||
@@ -231,4 +393,5 @@ class AppState:
|
|||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
test_info = ", ".join(f"{t.test_id}({t.channel_count}ch)" for t in self.tests)
|
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})"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
230
tests/test_web/test_p2_features.py
Normal file
230
tests/test_web/test_p2_features.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user