bookmark - UI P2

This commit is contained in:
2026-04-10 16:54:40 -04:00
parent 1617ee94b9
commit 48ab8c28b9
80 changed files with 1696 additions and 228 deletions

View File

@@ -2,8 +2,8 @@
**Date:** 2026-04-10
**Version:** 0.1.0
**Tests:** 171 passing
**Source:** ~9,200 lines Python | ~1,900 lines tests | ~3,400 lines web (py+js+css) | ~190 lines HTML templates
**Tests:** 181 passing
**Source:** ~11,000 lines (py+js+css+html) | ~2,150 lines tests
**Tooling:** uv (Python 3.12.12), hatchling build backend
---
@@ -13,7 +13,7 @@
```bash
cd /Users/noise/Code/impakt
uv sync --dev # install all dependencies
uv run pytest tests/ # run all 171 tests
uv run pytest tests/ # run all 181 tests
uv run impakt info tests/mme_data/3239 # show test metadata
uv run impakt serve tests/mme_data/3239 # launch web UI on :8050
```
@@ -85,22 +85,28 @@ impakt/
protocol_report.html
web/ # Dash web application
app.py # App factory: create_app(), serve()
state.py # AppState: server-side multi-test state manager
layout.py # Top-level layout: tabs, flex splitter, component assembly
state.py # AppState: multi-test state, templates, sessions, corridors
layout.py # Top-level layout: Data tab + Analysis tab
components/ # Reusable layout components
header.py # Navbar, test info panel, open/overlay modals
channel_grid.py # Flat sortable DataTable with wildcard filter + facets
transforms.py # CFC/align/resultant control panel
channel_values.py # Combined cursor + statistics table (live hover values)
transforms.py # CFC/align/resultant controls + per-channel overrides
plot_grid.py # Multi-pane plot area (1x1, 2x1, 1x2, 2x2, 3x1)
cursors.py # Cursor values grid (live hover + X1/X2 locked)
criteria.py # Auto-compute criteria, protocol scoring, results display
corridors.py # Corridor upload (CSV) and management
templates.py # Template library browser, save/apply/delete
math_builder.py # Math expression builder with variable binding
report.py # Export panel (PNG/SVG/PDF, CSV, protocol report)
callbacks/ # Feature-specific callback modules
__init__.py # Registration hub: register_callbacks()
channel_callbacks.py # Channel selection, filtering, badge display
plot_callbacks.py # Plot rendering, transform pipeline
cursor_callbacks.py # Live cursor grid updates via polled JS hover
channel_callbacks.py # Selection, filtering, badges, per-channel overrides
plot_callbacks.py # Plot rendering, transform pipeline, corridor display
cursor_callbacks.py # Channel values table (live hover + X1/X2)
criteria_callbacks.py # Compute All button, protocol scoring
template_callbacks.py # Apply/save/delete templates, session auto-save
corridor_callbacks.py # CSV upload, corridor state management
math_callbacks.py # Expression evaluation, derived channel injection
file_callbacks.py # Open test / add overlay modals
export_callbacks.py # CSV export, report generation
assets/ # Browser-side static files
@@ -121,7 +127,7 @@ impakt/
test_io/ # MMEReader
test_protocol/ # Euro NCAP scoring
test_transform/ # CFC filter, alignment
test_web/ # AppState, app creation, channel grid, criteria auto-compute
test_web/ # AppState, app creation, channel grid, channel values, P2 features
fixtures/
generate_mme.py # Synthetic MME generator (26 channels, half-sine)
sample_mme/ # Generated synthetic test data
@@ -168,41 +174,28 @@ impakt/
### Web UI -- Current State
The web UI has been through a major overhaul and is now functional for daily use:
Fully functional for daily crash test analysis:
**Layout:**
- Two tabs: **Data** (channels + plot + cursor grid) and **Analysis** (criteria + export)
- Draggable splitter between left panel and plot area (pure JS, no deps)
- Left panel resizable from 200px to 600px
**Channel Grid (left panel):**
- Flat sortable DataTable showing: #, ISO Code, Description, Unit, Min, Max
- Wildcard filter bar (`*HEAD*AC*`, `11*FO*Z*`, or partial text auto-wrapped)
- Facet dropdowns: body region, measurement type, direction
- Multi-select checkboxes, selected channels shown as color-coded badges
- Columns sortable and resizable (CSS resize)
**Plot Area (right side):**
- Multi-pane layout presets (1x1, 2x1, 1x2, 2x2, 3x1)
- Transform controls: CFC filter, Y-align, X-align (manual/threshold), resultant toggle
- X1/X2 reference lines drawn on plot (red dashed / blue dashed)
**Cursor Values Grid (below plot):**
- Live updating: vertical crosshair and cursor values track mouse movement anywhere in plot area
- Custom JS cursor tracker (mousemove -> Plotly axis p2d/p2l -> data coordinates)
- Polled via dcc.Interval (80ms) -> clientside callback -> server-side interpolation
- Columns: Channel, Unit, Cursor (live), X1 (locked), X2 (locked)
**Data Tab:**
- **Left panel** (resizable via draggable splitter): channel grid + transform controls
- **Channel grid**: flat sortable DataTable (#, ISO Code, Description, Unit, Min, Max), wildcard filter bar, facet dropdowns (body region, measurement, direction), multi-select with selection persisted across filtering, selected rows colored with plot trace colors (tinted background + left border)
- **Transform controls**: global CFC filter, Y-align, X-align (manual/threshold), resultant toggle, per-channel CFC overrides
- **Plot area** (fills remaining width): no legend (info in tables), tight margins, compact axis labels, X1/X2 vertical reference lines
- **Channel Values table** (directly below plot, minimal gap): combined statistics + cursor in one table. Columns: #, ISO Code, Description, Unit, Min, @Time, Max, @Time, X1, X2, Cursor. `table-layout: fixed` with percentage widths — Description fills remaining space. Rows colored with same plot trace colors. Cursor column updates live on mouse hover via custom JS tracker.
**Analysis Tab:**
- Auto-detect channels by ISO naming and compute HIC15, 3ms clip, Nij, chest defl, femur, tibia
- Protocol scoring: Euro NCAP / US NCAP / IIHS with color-coded results table and star ratings
- CSV export of plotted channel data (with transforms applied)
- Protocol report generation (HTML)
- **Injury Criteria**: auto-detect channels by ISO naming, compute HIC15/3ms clip/Nij/chest defl/femur/tibia, protocol scoring (Euro NCAP/US NCAP/IIHS) with color-coded results and star ratings
- **Math Expression Builder**: formula input, 3 variable bindings (a/b/c mapped to channel dropdowns), result injected into test data and auto-plotted
- **Template Management**: library browser, apply (resolves channel patterns + sets CFC), save current view as template, delete, session auto-save
- **Corridors**: CSV upload (time/lower/upper), rendered as filled band on plot
- **Export**: CSV of plotted data (with transforms), PNG/SVG/PDF buttons, protocol report generation
**File Management:**
- Open Test / Add Overlay buttons with modal dialogs
- Test info panel showing all loaded tests with metadata
- Multi-test support in channel grid and plot labels
**Consistent Color System:**
Every selected channel has a stable color index (position in selection order). The same color appears in:
- Plot traces
- Channel grid rows (tinted background + solid left border)
- Selected badges (colored dot)
- Channel Values table rows (tinted background + solid left border)
### Key design decisions:
@@ -211,20 +204,20 @@ The web UI has been through a major overhaul and is now functional for daily use
3. **Template/session split** -- templates are global recipes; sessions are per-test instances.
4. **AppState is server-side** -- numpy arrays stay in Python memory; Dash stores hold only lightweight keys.
5. **Channel keys use `test_id::channel_name`** -- enables multi-test overlay.
6. **Custom JS cursor tracking** -- bypasses Plotly's hover system (which only fires near data points) with raw mousemove + pixel-to-data conversion using Plotly's internal axis API.
7. **uv for package management** -- dev deps in `[dependency-groups]`.
6. **Custom JS cursor tracking** -- bypasses Plotly's hover system with raw mousemove + pixel-to-data conversion.
7. **table-layout: fixed** on Channel Values -- percentage widths respected, Description column fills remaining space.
8. **Browser cache prevention** -- meta tags with Cache-Control: no-cache to prevent stale layout issues during development.
9. **Separate single-output callbacks** for DataTable properties -- avoids Dash KeyError when DataTable internally requests individual properties.
---
## Next Steps (Priority 2)
## Next Steps (Priority 3)
These are the next features to build, now that Priority 1 is complete:
1. **Template management UI** -- template browser, apply/save/edit from UI, session auto-save
2. **Enhanced transform controls** -- per-channel CFC, per-channel X/Y-align
3. **Corridor management** -- load from CSV, draw on plot, corridor library
4. **Channel inspector** -- tabular data view, statistics (min/max/RMS/peak time)
5. **Math expression builder** -- formula input with autocomplete, live preview
1. **Annotations** -- text on plots, measurement lines, highlight regions
2. **Comparison mode** -- side-by-side tests, delta plots, synced cursors
3. **Report builder** -- drag-and-drop report composer, PDF preview
4. **Keyboard shortcuts** -- Ctrl+O, Ctrl+S, 1-9 pane switch, F fullscreen, R reset zoom
5. **Consider Python/WASM frontend** -- NiceGUI or Solara for pure-Python UI (no JS)
---
@@ -253,6 +246,7 @@ Optional: nptdms (for future TDMS reader plugin)
1. **VehicleInfo.year parsed as 0** for real MME data (.mme format embeds year in vehicle name string).
2. **Speed displayed as raw float** (55.900001530350906 km/h) -- should round.
3. **DataTable deprecation warning** -- Dash recommends migrating to dash-ag-grid. Functional for now.
4. **Cursor poll interval (80ms)** -- adds slight latency to cursor grid updates. Could use WebSocket for lower latency in future.
5. **Chest deflection auto-detect** skips DS channels with peak > 150mm (avoids steering column displacement). May miss some legitimate high-deflection data.
3. **DataTable deprecation warning** -- Dash recommends migrating to dash-ag-grid.
4. **Cursor poll interval (80ms)** -- slight latency in cursor grid updates.
5. **Chest deflection auto-detect** skips DS channels with peak > 150mm to avoid steering column displacement.
6. **Dead files** -- `cursors.py` and `inspector.py` were replaced by `channel_values.py` and deleted; `inspector_callbacks.py` deleted. If old `.pyc` files cause issues, run `find src -name __pycache__ -type d | xargs rm -rf`.

View File

@@ -71,6 +71,13 @@ def create_app(
external_stylesheets=[dbc.themes.FLATLY],
title=title,
suppress_callback_exceptions=True,
# Prevent browser from caching old layouts
serve_locally=True,
meta_tags=[
{"http-equiv": "Cache-Control", "content": "no-cache, no-store, must-revalidate"},
{"http-equiv": "Pragma", "content": "no-cache"},
{"http-equiv": "Expires", "content": "0"},
],
)
app.layout = build_layout(app_state, template_names)

View File

@@ -10,11 +10,14 @@ from __future__ import annotations
import dash
from impakt.web.callbacks.channel_callbacks import register_channel_callbacks
from impakt.web.callbacks.corridor_callbacks import register_corridor_callbacks
from impakt.web.callbacks.criteria_callbacks import register_criteria_callbacks
from impakt.web.callbacks.cursor_callbacks import register_cursor_callbacks
from impakt.web.callbacks.export_callbacks import register_export_callbacks
from impakt.web.callbacks.file_callbacks import register_file_callbacks
from impakt.web.callbacks.math_callbacks import register_math_callbacks
from impakt.web.callbacks.plot_callbacks import register_plot_callbacks
from impakt.web.callbacks.template_callbacks import register_template_callbacks
from impakt.web.state import AppState
@@ -26,3 +29,6 @@ def register_callbacks(app: dash.Dash, app_state: AppState) -> None:
register_criteria_callbacks(app, app_state)
register_file_callbacks(app, app_state)
register_export_callbacks(app, app_state)
register_template_callbacks(app, app_state)
register_corridor_callbacks(app, app_state)
register_math_callbacks(app, app_state)

View File

@@ -2,9 +2,10 @@
Handles:
- DataTable row selection -> updates selected channels store
- Wildcard filter + facet dropdowns -> filters table rows
- Selected channels badge display
- Filter clear button
- Filter/facet changes -> filters table rows AND recomputes selected_rows
indices to preserve selection across filter changes
- Selected channels badge display with consistent colors
- Per-channel CFC override controls
"""
from __future__ import annotations
@@ -12,13 +13,15 @@ from __future__ import annotations
from typing import Any
import dash
from dash import Input, Output, State, html, no_update
from dash import ALL, Input, Output, State, html, no_update
from dash.exceptions import PreventUpdate
from impakt.plot.engine import DEFAULT_COLORS
from impakt.web.components.channel_grid import (
build_selected_channels_badges,
filter_rows,
)
from impakt.web.components.transforms import build_per_channel_override_rows
from impakt.web.state import AppState
@@ -28,21 +31,47 @@ def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None:
@app.callback(
Output("selected-channels-store", "data"),
[Input("channel-grid", "selected_rows")],
[State("channel-grid", "data")],
[
State("channel-grid", "data"),
State("selected-channels-store", "data"),
],
)
def sync_selection_to_store(
selected_row_indices: list[int] | None,
current_data: list[dict[str, Any]] | None,
visible_data: list[dict[str, Any]] | None,
prev_selected: list[str] | None,
) -> list[str]:
"""When user checks/unchecks rows in the DataTable, update the store."""
if not selected_row_indices or not current_data:
return []
"""When user checks/unchecks rows, merge with existing selection.
keys = []
for idx in selected_row_indices:
if 0 <= idx < len(current_data):
keys.append(current_data[idx]["key"])
return keys
The key insight: selected_rows are indices into the *currently visible*
data (after filtering). We need to:
1. Determine which visible rows are now checked
2. Keep any previously-selected rows that aren't visible (filtered out)
3. Remove any visible rows that were unchecked
"""
if visible_data is None:
return prev_selected or []
prev = set(prev_selected or [])
visible_keys = {row["key"] for row in visible_data}
# Keys currently checked in the visible table
checked_keys: set[str] = set()
if selected_row_indices:
for idx in selected_row_indices:
if 0 <= idx < len(visible_data):
checked_keys.add(visible_data[idx]["key"])
# Start with previously selected keys that are NOT visible
# (i.e., they were selected before a filter was applied — preserve them)
result = [k for k in (prev_selected or []) if k not in visible_keys]
# Add the checked visible keys (in visible order)
for row in visible_data:
if row["key"] in checked_keys:
result.append(row["key"])
return result
@app.callback(
Output("selected-channels-badges", "children"),
@@ -52,40 +81,74 @@ def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None:
return build_selected_channels_badges(selected_keys or [], app_state)
@app.callback(
Output("channel-grid", "data"),
[
Output("channel-grid", "data"),
Output("channel-grid", "selected_rows"),
Output("channel-grid", "style_data_conditional"),
],
[
Input("channel-filter-input", "value"),
Input("channel-filter-clear", "n_clicks"),
Input("facet-body", "value"),
Input("facet-meas", "value"),
Input("facet-direction", "value"),
Input("selected-channels-store", "data"),
],
[State("channel-grid-all-rows", "data")],
)
def apply_filters(
def apply_filters_and_selection(
pattern: str | None,
clear_clicks: int | None,
body: str | None,
meas: str | None,
direction: str | None,
selected_keys: list[str] | None,
all_rows: list[dict[str, Any]] | None,
) -> list[dict[str, Any]]:
"""Filter the channel grid based on wildcard pattern and facets."""
) -> tuple[list[dict[str, Any]], list[int], list[dict]]:
"""Filter rows AND recompute selected_rows + color styling."""
if not all_rows:
return []
return [], [], []
# If clear was clicked, return all rows
trigger = dash.ctx.triggered_id
if trigger == "channel-filter-clear":
return all_rows
filtered = all_rows
else:
filtered = filter_rows(
all_rows,
pattern=pattern or "",
body=body or "",
meas=meas or "",
direction=direction or "",
)
return filter_rows(
all_rows,
pattern=pattern or "",
body=body or "",
meas=meas or "",
direction=direction or "",
)
# Build a lookup of selected keys to their color index
selected_set = set(selected_keys or [])
selected_list = list(selected_keys or [])
color_map: dict[str, int] = {}
for i, key in enumerate(selected_list):
color_map[key] = i
# Compute selected_rows indices in the filtered data
selected_indices = []
for idx, row in enumerate(filtered):
if row["key"] in selected_set:
selected_indices.append(idx)
# Build style_data_conditional for coloring selected rows
style_cond: list[dict] = []
for idx, row in enumerate(filtered):
if row["key"] in color_map:
ci = color_map[row["key"]]
color = DEFAULT_COLORS[ci % len(DEFAULT_COLORS)]
style_cond.append(
{
"if": {"row_index": idx},
"backgroundColor": f"{color}18", # Very light tint
"borderLeft": f"3px solid {color}",
}
)
return filtered, selected_indices, style_cond
@app.callback(
[
@@ -100,3 +163,33 @@ def register_channel_callbacks(app: dash.Dash, app_state: AppState) -> None:
def clear_filters(n_clicks: int | None) -> tuple[str, str, str, str]:
"""Clear all filter inputs."""
return "", "", "", ""
@app.callback(
Output("per-channel-overrides", "children"),
[Input("selected-channels-store", "data")],
)
def update_per_channel_overrides(selected_keys: list[str] | None) -> list:
"""Show per-channel CFC override controls for selected channels."""
return build_per_channel_override_rows(
selected_keys or [],
app_state.channel_overrides,
)
@app.callback(
Output("channel-overrides-store", "data"),
[Input({"type": "ch-cfc-override", "index": ALL}, "value")],
[State({"type": "ch-cfc-override", "index": ALL}, "id")],
prevent_initial_call=True,
)
def sync_per_channel_overrides(
values: list[str],
ids: list[dict[str, str]],
) -> dict[str, dict[str, str]]:
"""Sync per-channel CFC overrides to app state."""
for item_id, value in zip(ids, values):
key = item_id["index"]
if value:
app_state.channel_overrides[key] = {"cfc": value}
else:
app_state.channel_overrides.pop(key, None)
return dict(app_state.channel_overrides)

View 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

View File

@@ -1,12 +1,8 @@
"""Cursor value callbacks.
"""Channel values table callbacks.
Updates the cursor grid DataTable as the user moves the mouse over the plot.
The JS cursor_tracker.js writes the hover X position to
window.__impakt_hover_x on every mousemove. A clientside callback
polls this value via dcc.Interval and writes it to the cursor-hover-x
store. The server-side callback then reads the store and computes
interpolated Y values for all plotted channels.
Updates the combined channel values DataTable with statistics and
live cursor values as the user moves the mouse over the plot.
Row colors match the plot trace colors.
"""
from __future__ import annotations
@@ -14,19 +10,19 @@ from __future__ import annotations
from typing import Any
import dash
from dash import ClientsideFunction, Input, Output, State, clientside_callback, html
from dash import Input, Output, State, html
from dash.exceptions import PreventUpdate
from impakt.plot.engine import DEFAULT_COLORS
from impakt.web.callbacks.plot_callbacks import _resolve_channels
from impakt.web.components.cursors import build_cursor_grid_data
from impakt.web.components.channel_values import build_channel_values_data
from impakt.web.state import AppState
def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None:
"""Register cursor-related callbacks."""
"""Register channel values table callbacks."""
# Clientside callback: reads window.__impakt_hover_x (set by cursor_tracker.js)
# and writes it to the cursor-hover-x store on each Interval tick.
app.clientside_callback(
"""
function(n_intervals) {
@@ -42,28 +38,30 @@ def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None:
)
@app.callback(
Output("cursor-grid", "data"),
Output("channel-values-grid", "data"),
[
Input("cursor-hover-x", "data"),
Input("cursor-x1", "value"),
Input("cursor-x2", "value"),
Input("selected-channels-store", "data"),
Input("cfc-select", "value"),
Input("y-align-check", "value"),
Input("channel-overrides-store", "data"),
],
[
State("cfc-select", "value"),
State("y-align-check", "value"),
State("x-align-method", "value"),
State("x-align-value", "value"),
State("show-resultant", "value"),
],
)
def update_cursor_grid(
def update_channel_values_data(
hover_x: float | None,
cursor_x1: float | None,
cursor_x2: float | None,
selected_keys: list[str] | None,
cfc_value: str,
y_align: bool,
channel_overrides: dict | None,
x_align_method: str,
x_align_value: float | None,
show_resultant: bool,
@@ -71,7 +69,6 @@ def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None:
if not selected_keys:
return []
# Resolve channels with current transforms
channels = _resolve_channels(
selected_keys,
app_state,
@@ -88,4 +85,58 @@ def register_cursor_callbacks(app: dash.Dash, app_state: AppState) -> None:
x1 = cursor_x1 if cursor_x1 is not None else 0.0
x2 = cursor_x2 if cursor_x2 is not None else 0.1
return build_cursor_grid_data(channels, hover_x, x1, x2)
return build_channel_values_data(channels, hover_x, x1, x2)
@app.callback(
Output("channel-values-grid", "style_data_conditional"),
[Input("selected-channels-store", "data")],
[
State("cfc-select", "value"),
State("y-align-check", "value"),
State("x-align-method", "value"),
State("x-align-value", "value"),
State("show-resultant", "value"),
],
)
def update_channel_values_style(
selected_keys: list[str] | None,
cfc_value: str,
y_align: bool,
x_align_method: str,
x_align_value: float | None,
show_resultant: bool,
) -> list[dict]:
if not selected_keys:
return []
channels = _resolve_channels(
selected_keys,
app_state,
cfc_value,
y_align,
x_align_method or "none",
x_align_value,
show_resultant,
)
style_cond: list[dict] = []
for i in range(len(channels)):
color = DEFAULT_COLORS[i % len(DEFAULT_COLORS)]
style_cond.append(
{
"if": {"row_index": i},
"backgroundColor": f"{color}18",
"borderLeft": f"3px solid {color}",
}
)
# Highlight cursor column
style_cond.append(
{
"if": {"column_id": "cursor"},
"color": "#0d6efd",
"fontWeight": "500",
}
)
return style_cond

View 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 [],
)

View File

@@ -35,7 +35,11 @@ def _resolve_channels(
x_align_value: float | None,
show_resultant: bool,
) -> list[tuple[str, Channel]]:
"""Resolve selected channel keys to Channel objects with transforms applied."""
"""Resolve selected channel keys to Channel objects with transforms applied.
Per-channel overrides (from app_state.channel_overrides) take precedence
over the global CFC setting.
"""
channels: list[tuple[str, Channel]] = []
for key in selected_keys:
@@ -51,10 +55,14 @@ def _resolve_channels(
if ch is None:
continue
# Apply CFC filter
if cfc_value != "none":
# Determine CFC: per-channel override takes precedence over global
override = app_state.channel_overrides.get(key, {})
ch_cfc = override.get("cfc", "")
effective_cfc = ch_cfc if ch_cfc else cfc_value
if effective_cfc and effective_cfc != "none":
try:
ch = CFCFilter(cfc_class=int(cfc_value)).apply(ch)
ch = CFCFilter(cfc_class=int(effective_cfc)).apply(ch)
except (ValueError, Exception):
pass
@@ -105,6 +113,7 @@ def _build_figure(
cursor_x1: float | None,
cursor_x2: float | None,
cfc_value: str,
corridors: list[dict] | None = None,
) -> go.Figure:
"""Build a Plotly figure from resolved channels."""
fig = go.Figure()
@@ -143,6 +152,41 @@ def _build_figure(
)
)
# Add corridor fills
if corridors:
for corridor in corridors:
if not corridor.get("visible", True):
continue
c_time = corridor["time"]
c_upper = corridor["upper"]
c_lower = corridor["lower"]
c_name = corridor.get("name", "Corridor")
# Upper bound
fig.add_trace(
go.Scatter(
x=c_time,
y=c_upper,
mode="lines",
line=dict(color="rgba(100,100,255,0.4)", width=1, dash="dash"),
showlegend=False,
name=f"{c_name} upper",
)
)
# Lower bound with fill to upper
fig.add_trace(
go.Scatter(
x=c_time,
y=c_lower,
mode="lines",
line=dict(color="rgba(100,100,255,0.4)", width=1, dash="dash"),
fill="tonexty",
fillcolor="rgba(100,100,255,0.1)",
showlegend=True,
name=c_name,
)
)
# Add X1/X2 cursor lines
if cursor_x1 is not None:
fig.add_vline(
@@ -171,43 +215,12 @@ def _build_figure(
y_label = channels[0][1].unit if channels else ""
fig.update_layout(
xaxis_title="Time (s)",
yaxis_title=y_label,
xaxis_title=dict(text="Time (s)", font=dict(size=10, color="#999")),
yaxis_title=dict(text=y_label, font=dict(size=10, color="#999")),
template="plotly_white",
hovermode=False,
showlegend=True,
legend=dict(
orientation="h",
yanchor="bottom",
y=-0.18,
xanchor="center",
x=0.5,
font=dict(size=10),
),
margin=dict(l=55, r=15, t=10, b=55),
)
# Vertical spike line (crosshair) follows the mouse across the plot area
fig.update_xaxes(
showspikes=True,
spikemode="across",
spikesnap="cursor",
spikethickness=1,
spikecolor="rgba(0,0,0,0.25)",
spikedash="dot",
)
fig.update_yaxes(
showspikes=False,
)
# Show spike line (vertical crosshair) on hover
fig.update_xaxes(
showspikes=True,
spikemode="across",
spikesnap="cursor",
spikethickness=1,
spikecolor="rgba(0,0,0,0.3)",
spikedash="dot",
showlegend=False,
margin=dict(l=45, r=8, t=4, b=28),
)
return fig
@@ -226,6 +239,8 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None:
Input("x-align-method", "value"),
Input("cursor-x1", "value"),
Input("cursor-x2", "value"),
Input("channel-overrides-store", "data"),
Input("corridors-store", "data"),
],
[
State("x-align-value", "value"),
@@ -239,6 +254,8 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None:
x_align_method: str,
cursor_x1: float | None,
cursor_x2: float | None,
channel_overrides: dict | None,
corridors_data: list[dict] | None,
x_align_value: float | None,
) -> go.Figure:
if not selected_keys:
@@ -254,4 +271,10 @@ def register_plot_callbacks(app: dash.Dash, app_state: AppState) -> None:
show_resultant,
)
return _build_figure(channels, cursor_x1, cursor_x2, cfc_value)
return _build_figure(
channels,
cursor_x1,
cursor_x2,
cfc_value,
corridors=corridors_data,
)

View 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)}

View File

@@ -260,14 +260,7 @@ def build_channel_grid(app_state: AppState) -> dbc.Card:
{"if": {"column_id": "max"}, "width": "65px", "textAlign": "right"},
{"if": {"column_id": "test_id"}, "width": "60px"},
],
style_data_conditional=[
{
"if": {"state": "selected"},
"backgroundColor": "#e8f4fd",
"border": "none",
"borderBottom": "1px solid #b8daff",
},
],
style_data_conditional=[],
style_as_list_view=True,
fixed_rows={"headers": True},
),

View File

@@ -1,40 +1,41 @@
"""Cursor values grid component.
"""Combined channel values table.
Displays a DataTable below the plot showing interpolated Y values for all
plotted channels at three X positions:
- **Cursor**: The live mouse position (updates as the user moves the mouse
over the plot).
- **X1**: A locked reference position (set by the user via input or click).
- **X2**: A second locked reference position.
This replaces the old button-driven cursor panel with a live-updating grid.
A single DataTable showing all channel information for plotted channels:
- #: sequential channel number
- ISO Code: fixed-width channel code
- Description: flex column, fills remaining space
- Unit: engineering unit
- Min @ Time, Max @ Time: peak statistics with time of occurrence
- X1, X2: interpolated values at locked cursor positions
- Cursor: live interpolated value at mouse hover position
"""
from __future__ import annotations
from typing import Any
import numpy as np
import dash_bootstrap_components as dbc
from dash import dash_table, dcc, html
from impakt.channel.model import Channel
def build_cursor_panel() -> dbc.Card:
"""Build the cursor values grid panel."""
def build_channel_values_panel() -> dbc.Card:
"""Build the combined channel values panel."""
return dbc.Card(
[
dbc.CardHeader(
[
html.Span("Cursor Values", className="fw-bold"),
html.Span("Channel Values", className="fw-bold"),
html.Span(
" — hover over plot",
" — hover over plot for live cursor",
className="text-muted",
style={"fontSize": "10px", "fontWeight": "normal"},
),
],
className="py-2",
className="py-1",
),
dbc.CardBody(
[
@@ -100,22 +101,29 @@ def build_cursor_panel() -> dbc.Card:
width=6,
),
],
className="mb-2",
className="mb-1",
),
# Cursor values DataTable
# Combined DataTable
dash_table.DataTable(
id="cursor-grid",
id="channel-values-grid",
columns=[
{"name": "Channel", "id": "channel"},
{"name": "#", "id": "ch_num", "type": "numeric"},
{"name": "ISO Code", "id": "iso_code"},
{"name": "Description", "id": "description"},
{"name": "Unit", "id": "unit"},
{"name": "Cursor", "id": "cursor"},
{"name": "X1", "id": "x1"},
{"name": "X2", "id": "x2"},
{"name": "Min", "id": "min", "type": "numeric"},
{"name": "@ Time", "id": "min_time"},
{"name": "Max", "id": "max", "type": "numeric"},
{"name": "@ Time", "id": "max_time"},
{"name": "X1", "id": "x1", "type": "numeric"},
{"name": "X2", "id": "x2", "type": "numeric"},
{"name": "Cursor", "id": "cursor", "type": "numeric"},
],
data=[],
style_table={
"overflowY": "auto",
"maxHeight": "200px",
"overflowX": "auto",
"maxHeight": "250px",
},
style_header={
"backgroundColor": "#f8f9fa",
@@ -123,77 +131,109 @@ def build_cursor_panel() -> dbc.Card:
"fontSize": "10px",
"padding": "3px 6px",
"borderBottom": "2px solid #dee2e6",
"whiteSpace": "nowrap",
},
# Force table-layout:fixed so explicit widths are respected
# and Description gets whatever space remains.
css=[
{
"selector": ("#channel-values-grid table"),
"rule": "table-layout: fixed; width: 100%;",
}
],
style_cell={
"fontSize": "11px",
"fontFamily": "'SF Mono', 'Menlo', 'Monaco', monospace",
"padding": "2px 6px",
"border": "none",
"borderBottom": "1px solid #f0f0f0",
"overflow": "hidden",
"textOverflow": "ellipsis",
"whiteSpace": "nowrap",
},
style_cell_conditional=[
# Fixed-width columns (percentages sum to ~74%, leaving ~26% for Description)
{
"if": {"column_id": "channel"},
"if": {"column_id": "ch_num"},
"width": "3%",
"textAlign": "right",
"color": "#999",
},
{"if": {"column_id": "iso_code"}, "width": "5%"},
{
"if": {"column_id": "description"},
"fontFamily": "inherit",
"textAlign": "left",
"minWidth": "100px",
"overflow": "hidden",
"textOverflow": "ellipsis",
"whiteSpace": "nowrap",
},
{"if": {"column_id": "unit"}, "width": "45px", "textAlign": "center"},
{"if": {"column_id": "cursor"}, "textAlign": "right", "width": "80px"},
{"if": {"column_id": "x1"}, "textAlign": "right", "width": "80px"},
{"if": {"column_id": "x2"}, "textAlign": "right", "width": "80px"},
],
style_data_conditional=[
{"if": {"column_id": "unit"}, "width": "2%", "textAlign": "center"},
{"if": {"column_id": "min"}, "width": "8%", "textAlign": "right"},
{
"if": {"column_id": "cursor"},
"color": "#0d6efd",
"fontWeight": "500",
"if": {"column_id": "min_time"},
"width": "7%",
"textAlign": "right",
"color": "#999",
},
{"if": {"column_id": "max"}, "width": "8%", "textAlign": "right"},
{
"if": {"column_id": "max_time"},
"width": "7%",
"textAlign": "right",
"color": "#999",
},
{"if": {"column_id": "x1"}, "width": "8%", "textAlign": "right"},
{"if": {"column_id": "x2"}, "width": "8%", "textAlign": "right"},
{"if": {"column_id": "cursor"}, "width": "8%", "textAlign": "right"},
],
style_data_conditional=[],
style_as_list_view=True,
fixed_rows={"headers": True},
sort_action="native",
),
# Interval that polls the JS-side hover X position
dcc.Interval(id="cursor-poll-interval", interval=80, n_intervals=0),
# Hidden store for the current hover X position
dcc.Store(id="cursor-hover-x", data=None),
],
className="py-2",
className="py-1 px-2",
),
]
)
def build_cursor_grid_data(
def build_channel_values_data(
channels: list[tuple[str, Channel]],
hover_x: float | None,
x1: float,
x2: float,
) -> list[dict[str, str]]:
"""Build the cursor grid rows from resolved channels.
) -> list[dict[str, Any]]:
"""Build combined channel values rows.
Args:
channels: List of (label, channel) tuples.
hover_x: Current mouse hover X position (None if not hovering).
x1: Locked X1 position.
x2: Locked X2 position.
Returns:
List of row dicts for the DataTable.
Each row contains identity, statistics, and cursor values for one channel.
"""
rows: list[dict[str, str]] = []
for label, ch in channels:
rows: list[dict[str, Any]] = []
for idx, (label, ch) in enumerate(channels):
data = ch.data
min_val = float(np.min(data))
min_idx = int(np.argmin(data))
max_val = float(np.max(data))
max_idx = int(np.argmax(data))
cursor_val = f"{ch.value_at(hover_x):.4f}" if hover_x is not None else ""
rows.append(
{
"channel": label,
"ch_num": idx + 1,
"iso_code": ch.name,
"description": label,
"unit": ch.unit,
"cursor": cursor_val,
"min": f"{min_val:.4f}",
"min_time": f"{float(ch.time[min_idx]):.4f}",
"max": f"{max_val:.4f}",
"max_time": f"{float(ch.time[max_idx]):.4f}",
"x1": f"{ch.value_at(x1):.4f}",
"x2": f"{ch.value_at(x2):.4f}",
"cursor": cursor_val,
}
)
return rows

View 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",
)

View 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",
)

View File

@@ -103,10 +103,10 @@ def _build_single_pane(pane_idx: int, total_panes: int) -> dbc.Card:
clear_on_unhover=False,
),
],
className="p-2",
className="p-0",
),
],
className="mb-2",
className="mb-1",
)

View 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",
)

View File

@@ -1,9 +1,12 @@
"""Transform controls panel component."""
"""Transform controls panel component.
Provides global transform defaults and per-channel override capability.
"""
from __future__ import annotations
import dash_bootstrap_components as dbc
from dash import html
from dash import dcc, html
def build_transform_panel() -> dbc.Card:
@@ -13,8 +16,8 @@ def build_transform_panel() -> dbc.Card:
dbc.CardHeader("Transforms", className="fw-bold py-2"),
dbc.CardBody(
[
# CFC Filter
dbc.Label("CFC Filter", size="sm", className="mb-1"),
# Global CFC Filter
dbc.Label("CFC Filter (global)", size="sm", className="mb-1"),
dbc.Select(
id="cfc-select",
options=[
@@ -62,11 +65,98 @@ def build_transform_panel() -> dbc.Card:
placeholder="Time offset (s) or threshold",
size="sm",
step=0.001,
className="mb-1",
style={"display": "none"},
className="mb-2",
),
html.Hr(style={"margin": "8px 0"}),
# Per-channel overrides
dbc.Label("Per-Channel Overrides", size="sm", className="mb-1"),
html.Div(
id="per-channel-overrides",
children=[
html.Div(
"Select channels to set individual filters",
className="text-muted",
style={"fontSize": "10px"},
),
],
),
# Hidden store for per-channel override data
dcc.Store(id="channel-overrides-store", data={}),
],
className="py-2",
),
]
)
def build_per_channel_override_rows(
selected_keys: list[str],
overrides: dict[str, dict[str, str]],
) -> list:
"""Build per-channel override controls for selected channels.
Shows a compact row per selected channel with a CFC dropdown override.
"""
if not selected_keys:
return [
html.Div("No channels selected", className="text-muted", style={"fontSize": "10px"})
]
rows = []
for key in selected_keys[:10]: # Limit to 10 to avoid overwhelming the panel
ch_name = key.split("::")[-1] if "::" in key else key
# Truncate long names
display_name = ch_name if len(ch_name) <= 18 else ch_name[:16] + ".."
current_cfc = overrides.get(key, {}).get("cfc", "")
rows.append(
html.Div(
[
html.Span(
display_name,
style={
"fontSize": "10px",
"fontFamily": "monospace",
"width": "130px",
"display": "inline-block",
"overflow": "hidden",
"textOverflow": "ellipsis",
"whiteSpace": "nowrap",
"verticalAlign": "middle",
},
),
dbc.Select(
id={"type": "ch-cfc-override", "index": key},
options=[
{"label": "", "value": ""},
{"label": "60", "value": "60"},
{"label": "180", "value": "180"},
{"label": "600", "value": "600"},
{"label": "1000", "value": "1000"},
],
value=current_cfc,
size="sm",
style={
"fontSize": "10px",
"width": "70px",
"display": "inline-block",
"verticalAlign": "middle",
"marginLeft": "4px",
},
),
],
className="mb-1",
)
)
if len(selected_keys) > 10:
rows.append(
html.Div(
f"+{len(selected_keys) - 10} more",
className="text-muted",
style={"fontSize": "10px"},
)
)
return rows

View File

@@ -14,8 +14,8 @@ import dash_bootstrap_components as dbc
from dash import dcc, html
from impakt.web.components.channel_grid import build_channel_grid
from impakt.web.components.channel_values import build_channel_values_panel
from impakt.web.components.criteria import build_criteria_panel
from impakt.web.components.cursors import build_cursor_panel
from impakt.web.components.header import (
build_header,
build_open_test_modal,
@@ -23,7 +23,10 @@ from impakt.web.components.header import (
build_test_info_panel,
)
from impakt.web.components.plot_grid import build_plot_grid
from impakt.web.components.corridors import build_corridor_panel
from impakt.web.components.math_builder import build_math_panel
from impakt.web.components.report import build_report_panel
from impakt.web.components.templates import build_template_panel
from impakt.web.components.transforms import build_transform_panel
from impakt.web.state import AppState
@@ -61,11 +64,11 @@ def _build_data_tab(app_state: AppState) -> html.Div:
"zIndex": "10",
},
),
# === Right side: Plot area + Cursor grid (full width) ===
# === Right side: Plot area + Channel values table (full width) ===
html.Div(
[
build_plot_grid("1x1"),
build_cursor_panel(),
build_channel_values_panel(),
],
style={
"flex": "1",
@@ -86,24 +89,22 @@ def _build_data_tab(app_state: AppState) -> html.Div:
def _build_analysis_tab(app_state: AppState) -> html.Div:
"""Build the Analysis tab: criteria + protocol scoring + export."""
"""Build the Analysis tab: criteria, corridors, math, templates, export."""
return html.Div(
[
dbc.Row(
[
dbc.Col(
[
build_criteria_panel(),
],
width=6,
),
dbc.Col(
[
build_report_panel(),
],
width=6,
),
]
dbc.Col([build_criteria_panel()], width=4),
dbc.Col([build_math_panel(app_state)], width=4),
dbc.Col([build_template_panel(app_state)], width=4),
],
className="mb-2",
),
dbc.Row(
[
dbc.Col([build_corridor_panel()], width=6),
dbc.Col([build_report_panel()], width=6),
],
),
],
style={"padding": "8px"},

View File

@@ -19,6 +19,9 @@ from typing import Any
from impakt.channel.model import Channel, ChannelGroup, TestData
from impakt.io.reader import get_registry
from impakt.template.library import TemplateLibrary
from impakt.template.model import PlotDefinition, SessionState, TemplateSpec
from impakt.template.session import SessionManager
from impakt.transform.align import YAlign
from impakt.transform.cfc import CFCFilter
@@ -64,6 +67,13 @@ class AppState:
self._tests: dict[str, LoadedTest] = {}
self._test_order: list[str] = []
self._color_counter: int = 0
self._template_library = TemplateLibrary()
self._active_template: TemplateSpec | None = None
self._session_managers: dict[str, SessionManager] = {}
# Per-channel transform overrides: {channel_key: {"cfc": "600", "y_align": True}}
self.channel_overrides: dict[str, dict[str, Any]] = {}
# Active corridors: list of {name, time, lower, upper, visible}
self.corridors: list[dict[str, Any]] = []
def load_test(self, path: str | Path) -> LoadedTest:
"""Load a test from a path and add it to the state.
@@ -221,6 +231,158 @@ class AppState:
return items
# ----- Template & Session -----
@property
def template_library(self) -> TemplateLibrary:
return self._template_library
@property
def active_template(self) -> TemplateSpec | None:
return self._active_template
@property
def template_names(self) -> list[str]:
return self._template_library.list()
def apply_template(
self,
name: str,
selected_keys: list[str] | None = None,
) -> tuple[list[str], dict[str, str]]:
"""Apply a template by name.
Resolves the template's channel patterns against the primary test,
sets the active template, and returns the resolved channel keys and
transform settings.
Returns:
(selected_channel_keys, transform_settings)
"""
template = self._template_library.get(name)
self._active_template = template
# Persist to session if primary test has a path
primary = self.primary_test
if primary and primary.data.path:
mgr = self._get_session_manager(primary.data.path)
mgr.apply_template(template)
# Resolve channel patterns from the template
resolved_keys: list[str] = []
if primary:
for plot_def in template.plots:
for pattern in plot_def.channel_patterns:
matches = primary.data.find(pattern)
for ch in matches:
key = f"{primary.test_id}::{ch.name}"
if key not in resolved_keys:
resolved_keys.append(key)
# Transform settings
transforms: dict[str, str] = {}
if template.default_cfc is not None:
transforms["cfc"] = str(template.default_cfc)
else:
transforms["cfc"] = "none"
logger.info(
"Applied template '%s' — resolved %d channels",
name,
len(resolved_keys),
)
return resolved_keys, transforms
def save_as_template(
self,
name: str,
description: str,
selected_keys: list[str],
cfc_value: str,
x1: float | None,
x2: float | None,
protocol: str = "",
) -> TemplateSpec:
"""Capture the current UI state as a new template.
Converts selected channels back to patterns and stores the
current filter/cursor/protocol settings.
"""
# Convert selected keys to channel patterns
patterns: list[str] = []
for key in selected_keys:
if "::" in key:
_, ch_name = key.split("::", 1)
else:
ch_name = key
# Use the raw channel name as a pattern (exact match)
if ch_name not in patterns:
patterns.append(ch_name)
# Build plot definition
plot = PlotDefinition(
title=name,
channel_patterns=patterns,
x_cursors=(x1, x2) if x1 is not None and x2 is not None else None,
)
# Build transforms list
transforms: list[dict[str, Any]] = []
if cfc_value and cfc_value != "none":
transforms.append({"type": "cfc_filter", "cfc_class": int(cfc_value)})
if transforms:
plot.transforms = transforms
template = TemplateSpec(
name=name,
description=description,
plots=[plot],
default_cfc=int(cfc_value) if cfc_value and cfc_value != "none" else None,
protocol=protocol,
)
self._template_library.save(template)
self._active_template = template
logger.info("Saved template '%s' with %d channel patterns", name, len(patterns))
return template
def save_session(self, selected_keys: list[str], cfc_value: str, **overrides: Any) -> None:
"""Auto-save current state to the session for the primary test."""
primary = self.primary_test
if not primary or not primary.data.path:
return
mgr = self._get_session_manager(primary.data.path)
mgr.state.overrides = {
"selected_channels": selected_keys,
"cfc": cfc_value,
**overrides,
}
mgr.save()
def load_session_state(self) -> dict[str, Any] | None:
"""Load saved session state for the primary test, if any."""
primary = self.primary_test
if not primary or not primary.data.path:
return None
mgr = self._get_session_manager(primary.data.path)
if not mgr.has_session:
return None
return {
"selected_channels": mgr.state.overrides.get("selected_channels", []),
"cfc": mgr.state.overrides.get("cfc", "none"),
"template": mgr.state.template_name,
}
def _get_session_manager(self, path: Path) -> SessionManager:
key = str(path)
if key not in self._session_managers:
self._session_managers[key] = SessionManager(path)
return self._session_managers[key]
@property
def is_empty(self) -> bool:
return len(self._tests) == 0
@@ -231,4 +393,5 @@ class AppState:
def __repr__(self) -> str:
test_info = ", ".join(f"{t.test_id}({t.channel_count}ch)" for t in self.tests)
return f"AppState([{test_info}])"
tmpl = f", template={self._active_template.name}" if self._active_template else ""
return f"AppState([{test_info}]{tmpl})"

View 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