diff --git a/server/installer/data-downloader/README.md b/server/installer/data-downloader/README.md index 2e9a1acc..d6460ce0 100644 --- a/server/installer/data-downloader/README.md +++ b/server/installer/data-downloader/README.md @@ -78,3 +78,263 @@ sequenceDiagram Set `POSTGRES_DSN` and `DEFAULT_SEASON_TABLE` to match your deployment so the SQL sent from `backend/server_scanner.py` and `backend/sql.py` queries the correct season table. All services mount `./data` inside the container and the FastAPI layer manages file I/O with atomic writes to keep data consistent between the worker and UI actions. If the rolling lookback produces no sensors, the collector now falls back to the oldest/newest run windows discovered by the date scanner, so no manual date tuning is required. + +--- + +## Analysis workspace + +The Analysis tab is a post-session analytics workspace that lives alongside the existing Past Runs tab in the frontend. It queries TimescaleDB for signal time-series and vehicle state data, renders interactive multi-plot Plotly charts, and provides CSV export. + +### What it does + +- Plots one or more CAN signals on interactive Plotly charts within a run time window. +- Adapts query density to the time range: raw samples for short windows, time-bucketed min/max/avg envelopes for wide windows. +- Groups signals into separate plots with independent dual Y-axes, persisted per season in `localStorage`. +- Renders a vehicle state timeline below the plots showing VCU state, inverter VSM state, and PM100 fault intervals decoded from the CAN bitfields. +- Exports visible series data to CSV. + +### Architecture + +```mermaid +flowchart LR + subgraph Backend ["FastAPI (data-downloader-api)"] + SERIES["/api/series"] + STATES["/api/states"] + TDB[("TimescaleDB")] + SERIES --> TDB + STATES --> TDB + end + + subgraph Frontend ["React / Vite"] + TOOLBAR["AnalysisToolbar\n(run picker, custom range)"] + PICKER["AnalysisSignalPicker\n(DBC-grouped, drag and drop)"] + PLOTS["AnalysisPlotStack\n(Plotly multi-plot)"] + TIMELINE["AnalysisStateTimeline\n(state and fault lanes)"] + HOOK["useSeriesData\n(debounce, cache)"] + WORKSPACE["AnalysisWorkspace\n(orchestrator)"] + + WORKSPACE --> TOOLBAR + WORKSPACE --> PICKER + WORKSPACE --> PLOTS + WORKSPACE --> TIMELINE + WORKSPACE --> HOOK + end + + HOOK -->|"POST /api/series"| SERIES + WORKSPACE -->|"POST /api/states"| STATES +``` + +### Query pipeline + +1. The user selects a run (or enters a custom range) in the toolbar and picks signals from the signal picker. +2. `AnalysisWorkspace` flattens the current plot layout into a deduplicated signal list and passes the range and signals to `useSeriesData`. +3. `useSeriesData` debounces requests by 300 ms, checks a client-side LRU cache (1 M point budget), and calls `POST /api/series`. +4. The backend validates the request, estimates total row count via TimescaleDB `approximate_row_count` or `COUNT(*)`, then runs a raw query or a `time_bucket` envelope query depending on density. A `SET LOCAL statement_timeout = 15000` guard prevents runaway queries. +5. The frontend renders one Plotly chart per plot group, with linked x-axes so zoom on one chart zooms all charts. +6. State timeline data is fetched once per run window (not refetched on zoom) via `POST /api/states`. + +### Density modes + +| Mode | Condition | Columns returned | +|------|-----------|------------------| +| `raw` | estimated rows per signal < 100 000 | `t`, `v` | +| `envelope` | estimated rows per signal >= 100 000 | `t`, `min`, `max`, `avg` | + +The `target_points` parameter (default 4 000, max 20 000) controls the bucket count in envelope mode. The client auto-scales `target_points` based on the number of selected signals to stay within a shared 1 M point budget. + +--- + +## Analytics API endpoints + +These are the endpoints added by the analytics feature. The existing downloader endpoints (`/api/runs`, `/api/sensors`, `/api/query`, etc.) are unchanged. + +### POST /api/series + +Returns time-series data for one or more signals within a time window. + +Request body: + +```json +{ + "season": "wfr26", + "signals": ["Motor_Speed", "Motor_Temp"], + "start": "2026-05-10T14:00:00Z", + "end": "2026-05-10T14:30:00Z", + "target_points": 4000 +} +``` + +Response shape: + +```json +{ + "season": "wfr26", + "start": "2026-05-10T14:00:00+00:00", + "end": "2026-05-10T14:30:00+00:00", + "series": { + "Motor_Speed": { + "mode": "raw", + "resolution_ms": null, + "point_count": 1200, + "t": [1715349600000], + "v": [3200.5] + } + } +} +``` + +Envelope mode returns `min`, `max`, `avg` arrays instead of `v`. + +Limits: + +| Parameter | Constraint | +|-----------|------------| +| `signals` | 1 to 12, unique, valid identifiers | +| `target_points` | 1 to 20 000 | +| Time window | max 7 days | +| Projected total points | max 1 500 000 (rejected before fetch) | + +### POST /api/states + +Returns vehicle state lane segments and decoded PM100 fault intervals. + +Request body: + +```json +{ + "season": "wfr26", + "start": "2026-05-10T14:00:00Z", + "end": "2026-05-10T14:30:00Z" +} +``` + +Response shape: + +```json +{ + "season": "wfr26", + "start": "...", + "end": "...", + "lanes": [ + { + "id": "car", + "signal": "State", + "label": "Car", + "segments": [ + { "start_ms": 0, "end_ms": 5000, "value": 4, "label": "DRIVE" } + ] + } + ], + "faults": [ + { + "name": "Over-current Fault", + "source": "run", + "segments": [{ "start_ms": 4000, "end_ms": 4100 }] + } + ] +} +``` + +State lanes are derived by detecting value transitions with a 5-second gap tolerance. Labels come from the DBC `VAL_` definitions when available. Fault segments are decoded from the PM100 `INV_Post_Fault_Lo/Hi` and `INV_Run_Fault_Lo/Hi` bitfields using the Cascadia Motion CAN protocol fault name tables. + +--- + +## Analytics frontend structure + +``` +frontend/src/ +├── analysis/ +│ ├── analysis-range.ts Run-to-millisecond conversion, cross-season overlap +│ ├── export-csv.ts Series-to-CSV conversion and download trigger +│ ├── plot-layout.ts Pure model: groups, signal toggling, assignment, dual axis +│ ├── plot-traces.ts Plotly trace and layout builders for raw/envelope data +│ ├── series-cache.ts LRU cache with a shared 1M point budget +│ ├── state-timeline.ts Severity classification, segment box geometry, formatting +│ └── use-series-data.ts React hook: debounced fetch, cache, retry, clear +├── components/ +│ ├── AnalysisWorkspace.tsx Top-level orchestrator (range, signals, fetch, state) +│ ├── AnalysisToolbar.tsx Run picker, custom range inputs, export button +│ ├── AnalysisSignalPicker.tsx DBC-grouped signal list, click/drag to assign +│ ├── AnalysisPlotStack.tsx Plotly multi-plot renderer with drop targets +│ ├── AnalysisStateTimeline.tsx Collapsible state and fault lane visualization +│ ├── PlotAssignMenu.tsx Accessible combobox for plot reassignment +│ └── sensor-palette.ts DJB2-hashed subsystem color palette +└── types.ts Shared TypeScript interfaces +``` + +All pure logic modules under `analysis/` are side-effect-free and fully unit-tested. Component modules handle rendering, user interaction, and DOM integration. + +### State management + +Plot layout (which signals belong to which plot, right-axis assignments) is persisted to `localStorage` keyed by season name. On mount, persisted signals are pruned against the season's known signal list to avoid stale references. The layout is serialized after every toggle, assignment, or drag-and-drop. + +The state timeline collapse flag is also stored in `localStorage`. + +--- + +## Analytics backend structure + +``` +backend/ +├── series_queries.py Validation, density estimation, raw/envelope SQL, point budget +├── state_queries.py State lane assembly, PM100 fault bitfield decoding +├── dbc_utils.py DBC loading, signal grouping, signal_choices() for VAL_ labels +├── app.py FastAPI routes including /api/series and /api/states +└── tests/ + ├── test_series_queries.py 12 tests: validation, SQL, envelope shaping + ├── test_series_endpoint.py 4 tests: happy path, errors, 503 + ├── test_state_queries.py 20 tests: segments, gaps, seeds, fault decode + ├── test_states_endpoint.py 4 tests: happy path, unknown table, bad window + └── test_compose_api_env_allowlist.py Compose env hygiene +``` + +### State query internals + +The state query pipeline avoids returning one row per CAN sample. Instead it: + +1. Queries value transitions using `LAG()` window functions with a configurable gap threshold (5 seconds). +2. Seeds the initial state from a 1-hour lookback before the window start. +3. Assembles transitions into contiguous segments, splitting on value changes and time gaps. +4. Labels segments using DBC `VAL_` choice maps when available, falling back to numeric values. +5. Decodes PM100 fault registers by scanning the 32-bit `Post_Fault` and `Run_Fault` words for set bits and mapping them to named faults via the Cascadia Motion fault tables. +6. Merges overlapping fault intervals from the same named fault across multiple registers. + +The result is a few kilobytes per lane regardless of window length. + +--- + +## Testing + +### Backend + +```bash +cd server/installer/data-downloader +.venv/bin/python -m pytest backend/tests/ -v +``` + +48 tests covering series query validation, SQL generation, envelope shaping, state segment assembly (value changes, gap detection, seed injection), PM100 fault decoding (hi/lo word, reserved bits), interval merging, and endpoint integration. + +### Frontend + +```bash +cd server/installer/data-downloader/frontend +npx vitest run +``` + +130 tests across 14 files covering all pure logic modules and every analysis component. Key coverage areas: + +- **AnalysisWorkspace** (21 tests) -- tab switching, season remount, persistence/pruning, refresh overlays, export guards, state timeline wiring, zoom interaction, error recovery +- **useSeriesData** (13 tests) -- debounce, cache hits, error handling, retry, clear +- **plot-traces** (12 tests) -- raw and envelope trace building, relayout parsing, axis configuration +- **series-cache** (8 tests) -- LRU eviction, budget enforcement, oversize rejection +- **state-timeline** (7 tests) -- severity classification, segment box geometry, formatting, fault overlap +- **PlotAssignMenu** (6 tests) -- keyboard navigation, selection, accessibility + +--- + +## Limitations + +- The state timeline tracks only `VCU_State_Info.State` and `M170_Internal_States.INV_VSM_State`. Adding more lanes requires updating `STATE_LANES` in `state_queries.py` and the severity maps in `state-timeline.ts`. +- The PM100 fault tables are hardcoded from the Cascadia Motion CAN protocol. If the inverter firmware changes the fault bit layout, the tables in `state_queries.py` must be updated. +- Signal names must match valid SQL identifiers (`[A-Za-z_][A-Za-z0-9_]*`). Signals with non-identifier names cannot be queried. +- The client-side series cache is in-memory and shared across hook instances. It does not persist across page reloads. diff --git a/server/installer/data-downloader/backend/app.py b/server/installer/data-downloader/backend/app.py index 45034549..cfbe701e 100644 --- a/server/installer/data-downloader/backend/app.py +++ b/server/installer/data-downloader/backend/app.py @@ -15,9 +15,14 @@ from fastapi.responses import HTMLResponse from pydantic import BaseModel -from backend import series_queries +from backend import series_queries, state_queries from backend.config import get_settings -from backend.dbc_utils import group_sensors_by_message, load_dbc_db, refresh_dbc +from backend.dbc_utils import ( + group_sensors_by_message, + load_dbc_db, + refresh_dbc, + signal_choices, +) from backend.services import DataDownloaderService @@ -53,6 +58,12 @@ class SeriesPayload(BaseModel): target_points: int = series_queries.DEFAULT_TARGET_POINTS +class StatesPayload(BaseModel): + season: str + start: datetime + end: datetime + + settings = get_settings() service = DataDownloaderService(settings) logger = logging.getLogger(__name__) @@ -242,6 +253,37 @@ def query_series(payload: SeriesPayload) -> dict: raise HTTPException(status_code=503, detail=f"Database query failed: {exc}") +@app.post("/api/states") +def query_states(payload: StatesPayload) -> dict: + try: + state_queries.validate_states_request(payload.start, payload.end) + if payload.season not in {item["table"] for item in service.get_seasons()}: + raise ValueError(f"unknown season table: {payload.season}") + db, _source = load_dbc_db(settings) + choices = { + lane["signal"]: signal_choices(db, lane["signal"]) + for lane in state_queries.STATE_LANES + } + result = state_queries.execute_states_query( + postgres_dsn=settings.postgres_dsn, + table=payload.season, + start=payload.start, + end=payload.end, + choices_by_signal=choices, + ) + return { + "season": payload.season, + "start": series_queries.normalize_utc(payload.start).isoformat(), + "end": series_queries.normalize_utc(payload.end).isoformat(), + **result, + } + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + logger.exception("States query failed for %s", payload.season) + raise HTTPException(status_code=503, detail=f"Database query failed: {exc}") + + # ── CAN frames batch ingest (for flight-recorder sync) ───────────────────────── # Cache of known signal columns per table, thread-safe diff --git a/server/installer/data-downloader/backend/dbc_utils.py b/server/installer/data-downloader/backend/dbc_utils.py index 11da945f..2803fd6e 100644 --- a/server/installer/data-downloader/backend/dbc_utils.py +++ b/server/installer/data-downloader/backend/dbc_utils.py @@ -260,3 +260,14 @@ def group_sensors_by_message(sensor_names: list[str], db) -> dict: m["signals"].sort() return {"messages": messages, "ungrouped": sorted(ungrouped)} + + +def signal_choices(db, signal_name: str) -> Optional[dict]: + """Return the DBC VAL_ label map for a signal as {int: str}, or None.""" + if db is None: + return None + for message in db.messages: + for signal in message.signals: + if signal.name == signal_name and signal.choices: + return {int(k): str(v) for k, v in signal.choices.items()} + return None diff --git a/server/installer/data-downloader/backend/state_queries.py b/server/installer/data-downloader/backend/state_queries.py new file mode 100644 index 00000000..4b39d044 --- /dev/null +++ b/server/installer/data-downloader/backend/state_queries.py @@ -0,0 +1,341 @@ +"""State and fault timeline queries for the Analysis tab. + +Compresses state machine signals into transition segments and decodes the +PM100 fault bitfields into named fault intervals, so any time window returns +a payload of a few kilobytes at most regardless of window length. +""" +from __future__ import annotations + +from datetime import datetime, timedelta + +import psycopg2 + +from backend.series_queries import ( + MAX_WINDOW_DAYS, + STATEMENT_TIMEOUT_MS, + _epoch_ms, + _quoted, + _table, + normalize_utc, +) + +GAP_SECONDS = 5 +GAP_MS = GAP_SECONDS * 1000 +LOOKBACK_HOURS = 1 + +STATE_LANES = ( + {"id": "car", "signal": "State", "label": "Car", "message": "VCU_State_Info"}, + { + "id": "inverter", + "signal": "INV_VSM_State", + "label": "Inverter", + "message": "M170_Internal_States", + }, +) + +# (source, column, bit offset into the 32-entry name tables, message name) +FAULT_SOURCES = ( + ("post", "INV_Post_Fault_Lo", 0, "M171_Fault_Codes"), + ("post", "INV_Post_Fault_Hi", 16, "M171_Fault_Codes"), + ("run", "INV_Run_Fault_Lo", 0, "M171_Fault_Codes"), + ("run", "INV_Run_Fault_Hi", 16, "M171_Fault_Codes"), +) + +# PM100/CM200 fault bit names (Cascadia Motion CAN protocol). None marks a +# reserved bit that is never reported. The DBC only says "refer to the PM100 +# manual", so these tables are the decode source of truth. +POST_FAULT_NAMES = ( + "Hardware Gate/Desaturation Fault", + "HW Over-current Fault", + "Accelerator Shorted", + "Accelerator Open", + "Current Sensor Low", + "Current Sensor High", + "Module Temperature Low", + "Module Temperature High", + "Control PCB Temperature Low", + "Control PCB Temperature High", + "Gate Drive PCB Temperature Low", + "Gate Drive PCB Temperature High", + "5V Sense Voltage Low", + "5V Sense Voltage High", + "12V Sense Voltage Low", + "12V Sense Voltage High", + "2.5V Sense Voltage Low", + "2.5V Sense Voltage High", + "1.5V Sense Voltage Low", + "1.5V Sense Voltage High", + "DC Bus Voltage High", + "DC Bus Voltage Low", + "Precharge Timeout", + "Precharge Voltage Failure", + "EEPROM Checksum Invalid", + "EEPROM Data Out of Range", + "EEPROM Update Required", + "Hardware DC Bus Over-Voltage", + None, + None, + "Brake Shorted", + "Brake Open", +) + +RUN_FAULT_NAMES = ( + "Motor Over-speed Fault", + "Over-current Fault", + "Over-voltage Fault", + "Inverter Over-temperature Fault", + "Accelerator Input Shorted Fault", + "Accelerator Input Open Fault", + "Direction Command Fault", + "Inverter Response Time-out Fault", + "Hardware Gate/Desaturation Fault", + "Hardware Over-current Fault", + "Under-voltage Fault", + "CAN Command Message Lost Fault", + "Motor Over-temperature Fault", + None, + None, + None, + "Brake Input Shorted Fault", + "Brake Input Open Fault", + "Module A Over-temperature Fault", + "Module B Over-temperature Fault", + "Module C Over-temperature Fault", + "PCB Over-temperature Fault", + "Gate Drive Board 1 Over-temperature Fault", + "Gate Drive Board 2 Over-temperature Fault", + "Gate Drive Board 3 Over-temperature Fault", + "Current Sensor Fault", + None, + None, + None, + None, + "Resolver Not Connected", + None, +) + +FAULT_NAME_TABLES = {"post": POST_FAULT_NAMES, "run": RUN_FAULT_NAMES} + + +def validate_states_request(start: datetime, end: datetime) -> None: + start_utc = normalize_utc(start) + end_utc = normalize_utc(end) + if start_utc >= end_utc: + raise ValueError("start must be before end") + if end_utc - start_utc > timedelta(days=MAX_WINDOW_DAYS): + raise ValueError("window cannot exceed seven days") + + +def build_columns_sql() -> str: + return ( + "SELECT column_name FROM information_schema.columns " + "WHERE table_name = %(table)s AND column_name = ANY(%(columns)s)" + ) + + +def build_transitions_sql(table: str, signal: str) -> str: + column = _quoted(signal) + return ( + "SELECT time, value, prev_time FROM (" + f"SELECT time, {column} AS value, " + f"LAG({column}) OVER (ORDER BY time) AS prev_value, " + "LAG(time) OVER (ORDER BY time) AS prev_time " + f"FROM {_table(table)} " + "WHERE time >= %(start)s AND time <= %(end)s " + f"AND {column} IS NOT NULL " + "AND message_name = %(message)s" + ") AS samples " + "WHERE prev_value IS DISTINCT FROM value " + f"OR time - prev_time > interval '{GAP_SECONDS} seconds' " + "ORDER BY time" + ) + + +def build_lookback_sql(table: str, signal: str) -> str: + column = _quoted(signal) + return ( + f"SELECT {column} FROM {_table(table)} " + "WHERE time < %(start)s AND time >= %(lookback)s " + f"AND {column} IS NOT NULL AND message_name = %(message)s " + "ORDER BY time DESC LIMIT 1" + ) + + +def build_last_sample_sql(table: str, signal: str) -> str: + column = _quoted(signal) + return ( + f"SELECT time FROM {_table(table)} " + "WHERE time >= %(start)s AND time <= %(end)s " + f"AND {column} IS NOT NULL AND message_name = %(message)s " + "ORDER BY time DESC LIMIT 1" + ) + + +def assemble_segments( + transition_rows: list[tuple[int, int, int | None]], + seed_value: int | None, + window_start_ms: int, + last_sample_ms: int | None, +) -> list[dict]: + """Turn transition rows into value segments. + + transition_rows are (t_ms, value, prev_t_ms) ordered by time. The first + in-window row always appears because LAG() yields NULL there. Each row + closes the open segment (at prev_t_ms for gaps, at t_ms for changes) and + opens a new one. The seed is the state just before the window; it is + ignored when the first sample arrives more than GAP_MS after window start, + because nothing proves the seeded state persisted across that silence. + """ + if last_sample_ms is None or not transition_rows: + return [] + segments: list[dict] = [] + first_t = transition_rows[0][0] + current_value: int | None = None + current_start = window_start_ms + if seed_value is not None and first_t - window_start_ms <= GAP_MS: + current_value = seed_value + for t_ms, value, prev_t_ms in transition_rows: + is_gap = prev_t_ms is not None and t_ms - prev_t_ms > GAP_MS + if current_value is not None: + if value == current_value and not is_gap: + # Only reachable when the seed matches the first sample; the + # open segment simply continues. + continue + end = prev_t_ms if is_gap else t_ms + if end is not None and end > current_start: + segments.append( + {"start_ms": current_start, "end_ms": end, "value": current_value} + ) + current_value = value + current_start = t_ms + if current_value is not None and last_sample_ms > current_start: + segments.append( + {"start_ms": current_start, "end_ms": last_sample_ms, "value": current_value} + ) + return segments + + +def label_segments(segments: list[dict], choices: dict[int, str] | None) -> list[dict]: + for segment in segments: + label = choices.get(segment["value"]) if choices else None + segment["label"] = label if label is not None else f"state {segment['value']}" + return segments + + +def decode_fault_segments( + bitfield_segments: list[dict], + names: tuple, + bit_offset: int, +) -> dict[str, list[tuple[int, int]]]: + faults: dict[str, list[tuple[int, int]]] = {} + for segment in bitfield_segments: + value = int(segment["value"]) + for bit in range(16): + if not value >> bit & 1: + continue + name = names[bit_offset + bit] + if name is None: + continue + faults.setdefault(name, []).append((segment["start_ms"], segment["end_ms"])) + return faults + + +def merge_intervals(intervals: list[tuple[int, int]]) -> list[dict]: + merged: list[list[int]] = [] + for start, end in sorted(intervals): + if merged and start <= merged[-1][1]: + merged[-1][1] = max(merged[-1][1], end) + else: + merged.append([start, end]) + return [{"start_ms": s, "end_ms": e} for s, e in merged] + + +def execute_states_query( + *, + postgres_dsn: str, + table: str, + start: datetime, + end: datetime, + choices_by_signal: dict[str, dict[int, str] | None] | None = None, +) -> dict: + start_utc = normalize_utc(start) + end_utc = normalize_utc(end) + validate_states_request(start_utc, end_utc) + choices_by_signal = choices_by_signal or {} + window_start_ms = _epoch_ms(start_utc) + params = {"start": start_utc, "end": end_utc} + lookback_params = { + "start": start_utc, + "lookback": start_utc - timedelta(hours=LOOKBACK_HOURS), + } + wanted = [lane["signal"] for lane in STATE_LANES] + [c for _, c, _, _ in FAULT_SOURCES] + lanes: list[dict] = [] + fault_lists: dict[tuple[str, str], list[tuple[int, int]]] = {} + fault_order: list[tuple[str, str]] = [] + + with psycopg2.connect(postgres_dsn) as conn: + with conn.cursor() as cur: + cur.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") + cur.execute(f"SET LOCAL statement_timeout = {STATEMENT_TIMEOUT_MS}") + cur.execute(build_columns_sql(), {"table": _table(table), "columns": wanted}) + present = {row[0] for row in cur.fetchall()} + + def segments_for(signal: str, message: str) -> list[dict]: + lookback_query_params = {**lookback_params, "message": message} + query_params = {**params, "message": message} + cur.execute(build_lookback_sql(table, signal), lookback_query_params) + row = cur.fetchone() + seed = int(row[0]) if row is not None else None + cur.execute(build_last_sample_sql(table, signal), query_params) + row = cur.fetchone() + last_ms = _epoch_ms(row[0]) if row is not None else None + cur.execute(build_transitions_sql(table, signal), query_params) + rows = [ + ( + _epoch_ms(r[0]), + int(r[1]), + _epoch_ms(r[2]) if r[2] is not None else None, + ) + for r in cur.fetchall() + ] + return assemble_segments(rows, seed, window_start_ms, last_ms) + + for lane in STATE_LANES: + if lane["signal"] not in present: + continue + segments = label_segments( + segments_for(lane["signal"], lane["message"]), + choices_by_signal.get(lane["signal"]), + ) + lanes.append( + { + "id": lane["id"], + "signal": lane["signal"], + "label": lane["label"], + "segments": segments, + } + ) + + for source, column, bit_offset, message in FAULT_SOURCES: + if column not in present: + continue + decoded = decode_fault_segments( + segments_for(column, message), FAULT_NAME_TABLES[source], bit_offset + ) + for name, intervals in decoded.items(): + key = (source, name) + if key not in fault_lists: + fault_lists[key] = [] + fault_order.append(key) + fault_lists[key].extend(intervals) + + faults = [ + { + "name": name, + "source": source, + "segments": merge_intervals(fault_lists[(source, name)]), + } + for source, name in fault_order + ] + faults.sort(key=lambda f: f["segments"][0]["start_ms"]) + return {"lanes": lanes, "faults": faults} diff --git a/server/installer/data-downloader/backend/tests/test_state_queries.py b/server/installer/data-downloader/backend/tests/test_state_queries.py new file mode 100644 index 00000000..7451ee6f --- /dev/null +++ b/server/installer/data-downloader/backend/tests/test_state_queries.py @@ -0,0 +1,254 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from backend import state_queries as stq + + +T0 = datetime(2026, 6, 20, 15, 0, tzinfo=timezone.utc) + + +def test_validate_states_request_accepts_normal_window(): + stq.validate_states_request(T0, T0 + timedelta(hours=2)) + + +@pytest.mark.parametrize( + "start,end,match", + [ + (T0, T0, "start must be before end"), + (T0, T0 - timedelta(hours=1), "start must be before end"), + (T0, T0 + timedelta(days=8), "seven days"), + ], +) +def test_validate_states_request_rejects_bad_windows(start, end, match): + with pytest.raises(ValueError, match=match): + stq.validate_states_request(start, end) + + +def test_transitions_sql_quotes_signal_and_uses_lag_and_gap(): + sql = stq.build_transitions_sql("wfr26", "INV_VSM_State") + assert '"INV_VSM_State"' in sql + assert "LAG" in sql + assert "interval '5 seconds'" in sql + assert "prev_value IS DISTINCT FROM value" in sql + assert "%(start)s" in sql and "%(end)s" in sql + assert "AND message_name = %(message)s" in sql + + +def test_transitions_sql_rejects_invalid_identifiers(): + with pytest.raises(ValueError): + stq.build_transitions_sql("wfr26", "bad-name; DROP") + with pytest.raises(ValueError): + stq.build_transitions_sql("Wfr26; --", "State") + + +def test_lookback_sql_bounds_lookback_and_orders_desc(): + sql = stq.build_lookback_sql("wfr26", "State") + assert "time < %(start)s" in sql + assert "time >= %(lookback)s" in sql + assert "ORDER BY time DESC LIMIT 1" in sql + assert "AND message_name = %(message)s" in sql + + +def test_last_sample_sql_bounds_window_and_orders_desc(): + sql = stq.build_last_sample_sql("wfr26", "State") + assert "time >= %(start)s" in sql + assert "time <= %(end)s" in sql + assert "ORDER BY time DESC LIMIT 1" in sql + assert "AND message_name = %(message)s" in sql + + +def test_assemble_segments_splits_on_value_change(): + rows = [(1000, 0, None), (5000, 4, 4900)] + segments = stq.assemble_segments(rows, None, 0, 9000) + assert segments == [ + {"start_ms": 1000, "end_ms": 5000, "value": 0}, + {"start_ms": 5000, "end_ms": 9000, "value": 4}, + ] + + +def test_assemble_segments_splits_on_gap_and_closes_at_prev_time(): + rows = [(0, 4, None), (20000, 4, 8000)] + segments = stq.assemble_segments(rows, None, 0, 25000) + assert segments == [ + {"start_ms": 0, "end_ms": 8000, "value": 4}, + {"start_ms": 20000, "end_ms": 25000, "value": 4}, + ] + + +def test_assemble_segments_seeds_state_from_before_window(): + rows = [(2000, 4, None)] + segments = stq.assemble_segments(rows, 1, 0, 9000) + assert segments == [ + {"start_ms": 0, "end_ms": 2000, "value": 1}, + {"start_ms": 2000, "end_ms": 9000, "value": 4}, + ] + + +def test_assemble_segments_merges_seed_equal_to_first_value(): + rows = [(2000, 4, None)] + segments = stq.assemble_segments(rows, 4, 0, 9000) + assert segments == [{"start_ms": 0, "end_ms": 9000, "value": 4}] + + +def test_assemble_segments_skips_seed_when_first_sample_is_late(): + rows = [(7000, 4, None)] + segments = stq.assemble_segments(rows, 1, 0, 9000) + assert segments == [{"start_ms": 7000, "end_ms": 9000, "value": 4}] + + +def test_assemble_segments_empty_inputs(): + assert stq.assemble_segments([], 4, 0, 9000) == [] + assert stq.assemble_segments([(0, 4, None)], None, 0, None) == [] + + +def test_label_segments_uses_choices_with_numeric_fallback(): + segments = [ + {"start_ms": 0, "end_ms": 1, "value": 4}, + {"start_ms": 1, "end_ms": 2, "value": 9}, + ] + labeled = stq.label_segments(segments, {4: "DRIVE"}) + assert labeled[0]["label"] == "DRIVE" + assert labeled[1]["label"] == "state 9" + unlabeled = stq.label_segments([{"start_ms": 0, "end_ms": 1, "value": 6}], None) + assert unlabeled[0]["label"] == "state 6" + + +def test_decode_fault_segments_extracts_named_bits(): + segments = [{"start_ms": 0, "end_ms": 100, "value": 0b1000000000101}] + faults = stq.decode_fault_segments(segments, stq.RUN_FAULT_NAMES, 0) + assert faults == { + "Motor Over-speed Fault": [(0, 100)], + "Over-voltage Fault": [(0, 100)], + "Motor Over-temperature Fault": [(0, 100)], + } + + +def test_decode_fault_segments_uses_bit_offset_for_hi_word(): + segments = [{"start_ms": 0, "end_ms": 100, "value": 0b1}] + faults = stq.decode_fault_segments(segments, stq.RUN_FAULT_NAMES, 16) + assert faults == {"Brake Input Shorted Fault": [(0, 100)]} + + +def test_decode_fault_segments_skips_reserved_bits(): + # RUN bit 13 is reserved (None). + segments = [{"start_ms": 0, "end_ms": 100, "value": 1 << 13}] + assert stq.decode_fault_segments(segments, stq.RUN_FAULT_NAMES, 0) == {} + + +def test_merge_intervals_merges_overlaps_and_keeps_disjoint(): + merged = stq.merge_intervals([(0, 10), (5, 20), (30, 40)]) + assert merged == [ + {"start_ms": 0, "end_ms": 20}, + {"start_ms": 30, "end_ms": 40}, + ] + + +def test_fault_name_tables_shape(): + assert len(stq.POST_FAULT_NAMES) == 32 + assert len(stq.RUN_FAULT_NAMES) == 32 + assert stq.RUN_FAULT_NAMES[11] == "CAN Command Message Lost Fault" + assert stq.POST_FAULT_NAMES[22] == "Precharge Timeout" + + +class ScriptedCursor: + """Feeds queued fetch results in order while recording executed SQL.""" + + def __init__(self, script): + self.script = list(script) + self.executed = [] + + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + def execute(self, sql, params=None): + self.executed.append((sql, params)) + + def fetchone(self): + kind, value = self.script.pop(0) + assert kind == "one", f"expected fetchall next, got fetchone ({value})" + return value + + def fetchall(self): + kind, value = self.script.pop(0) + assert kind == "all", f"expected fetchone next, got fetchall ({value})" + return value + + +class FakeConnection: + def __init__(self, cursor): + self._cursor = cursor + + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + def cursor(self): + return self._cursor + + +def test_execute_states_query_shapes_lanes_and_faults(monkeypatch): + import psycopg2 + + start = T0 + end = T0 + timedelta(minutes=10) + t = lambda seconds: start + timedelta(seconds=seconds) + # Column probe says only State and INV_Run_Fault_Lo exist. + script = [ + ("all", [("State",), ("INV_Run_Fault_Lo",)]), + # State lane: lookback, last sample, transitions. + ("one", (1.0,)), + ("one", (t(100),)), + ("all", [(t(0), 4.0, None), (t(60), 6.0, t(59))]), + # INV_Run_Fault_Lo: lookback none, last sample, transitions. + ("one", None), + ("one", (t(100),)), + ("all", [(t(50), float(1 << 11), None), (t(80), 0.0, t(79))]), + ] + cursor = ScriptedCursor(script) + monkeypatch.setattr(psycopg2, "connect", lambda _dsn: FakeConnection(cursor)) + + result = stq.execute_states_query( + postgres_dsn="unused", + table="wfr26", + start=start, + end=end, + choices_by_signal={"State": {4: "DRIVE", 6: "DEVICE_FAULT"}}, + ) + + assert [lane["id"] for lane in result["lanes"]] == ["car"] + segments = result["lanes"][0]["segments"] + assert [s["label"] for s in segments] == ["DRIVE", "DEVICE_FAULT"] + assert segments[0]["start_ms"] < segments[0]["end_ms"] + assert "message" not in result["lanes"][0] + + assert len(result["faults"]) == 1 + fault = result["faults"][0] + assert fault["name"] == "CAN Command Message Lost Fault" + assert fault["source"] == "run" + assert len(fault["segments"]) == 1 + + assert cursor.executed[0][0] == "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY" + assert cursor.executed[1][0] == "SET LOCAL statement_timeout = 15000" + + # State lane queries (indices 3-5) must be scoped to VCU_State_Info. + for _, params in cursor.executed[3:6]: + assert params["message"] == "VCU_State_Info" + # INV_Run_Fault_Lo queries (indices 6-8) must be scoped to M171_Fault_Codes. + for _, params in cursor.executed[6:9]: + assert params["message"] == "M171_Fault_Codes" + + +def test_execute_states_query_rejects_bad_window(): + with pytest.raises(ValueError, match="start must be before end"): + stq.execute_states_query( + postgres_dsn="unused", + table="wfr26", + start=T0, + end=T0, + ) diff --git a/server/installer/data-downloader/backend/tests/test_states_endpoint.py b/server/installer/data-downloader/backend/tests/test_states_endpoint.py new file mode 100644 index 00000000..db600f66 --- /dev/null +++ b/server/installer/data-downloader/backend/tests/test_states_endpoint.py @@ -0,0 +1,71 @@ +import importlib +import os +import tempfile + +from fastapi.testclient import TestClient +import pytest + + +os.environ.setdefault("DATA_DIR", tempfile.mkdtemp()) +os.environ.setdefault("POSTGRES_DSN", "postgresql://test:test@127.0.0.1:1/test") + +app_module = importlib.import_module("backend.app") +client = TestClient(app_module.app) + + +@pytest.fixture(autouse=True) +def metadata(monkeypatch): + monkeypatch.setattr( + app_module.service, + "get_seasons", + lambda: [{"name": "WFR26", "year": 2026, "table": "wfr26", "color": None}], + ) + + +def payload(**overrides): + base = { + "season": "wfr26", + "start": "2026-06-20T15:00:00Z", + "end": "2026-06-20T16:00:00Z", + } + return {**base, **overrides} + + +def test_states_endpoint_returns_lanes_and_faults(monkeypatch): + expected = { + "lanes": [{"id": "car", "signal": "State", "label": "Car", "segments": []}], + "faults": [], + } + monkeypatch.setattr( + app_module.state_queries, "execute_states_query", lambda **kwargs: expected + ) + response = client.post("/api/states", json=payload()) + assert response.status_code == 200 + body = response.json() + assert body["season"] == "wfr26" + assert body["lanes"] == expected["lanes"] + assert body["faults"] == [] + + +def test_states_endpoint_rejects_unknown_table(): + response = client.post("/api/states", json=payload(season="wfr99")) + assert response.status_code == 400 + assert "season" in response.json()["detail"].lower() + + +def test_states_endpoint_rejects_inverted_window(): + response = client.post( + "/api/states", + json=payload(start="2026-06-20T16:00:00Z", end="2026-06-20T15:00:00Z"), + ) + assert response.status_code == 400 + + +def test_states_endpoint_maps_database_failure_to_503(monkeypatch): + def fail(**kwargs): + raise RuntimeError("db unavailable") + + monkeypatch.setattr(app_module.state_queries, "execute_states_query", fail) + response = client.post("/api/states", json=payload()) + assert response.status_code == 503 + assert "Database query failed" in response.json()["detail"] diff --git a/server/installer/data-downloader/frontend/src/analysis/plot-layout.test.ts b/server/installer/data-downloader/frontend/src/analysis/plot-layout.test.ts new file mode 100644 index 00000000..1d59098e --- /dev/null +++ b/server/installer/data-downloader/frontend/src/analysis/plot-layout.test.ts @@ -0,0 +1,142 @@ +// src/analysis/plot-layout.test.ts +import { describe, expect, it } from "vitest"; + +import { + MAX_TOTAL_SIGNALS, + NEW_PLOT, + type PlotLayout, + assignSignals, + flattenSignals, + parseLayout, + pruneUnknown, + serializeLayout, + toggleRightAxis, + toggleSignal, +} from "./plot-layout"; + +function group(id: string, signals: string[], rightAxis: string[] = []) { + return { id, signals, rightAxis }; +} + +describe("toggleSignal", () => { + it("adds an unknown signal as a new solo group at the end", () => { + const next = toggleSignal([group("a", ["S1"])], "S2"); + expect(next).toHaveLength(2); + expect(next[1].signals).toEqual(["S2"]); + expect(next[1].rightAxis).toEqual([]); + }); + + it("removes a present signal and prunes the emptied group", () => { + const next = toggleSignal([group("a", ["S1"]), group("b", ["S2", "S3"])], "S1"); + expect(next).toEqual([group("b", ["S2", "S3"])]); + }); + + it("drops the signal from rightAxis when removed", () => { + const next = toggleSignal([group("a", ["S1", "S2"], ["S1"])], "S1"); + expect(next).toEqual([group("a", ["S2"])]); + }); + + it("rejects adds beyond the total cap by returning the input unchanged", () => { + const layout: PlotLayout = [ + group("a", Array.from({ length: MAX_TOTAL_SIGNALS }, (_, i) => `S${i}`)), + ]; + expect(toggleSignal(layout, "OVER")).toBe(layout); + }); +}); + +describe("assignSignals", () => { + it("moves an already placed signal into the target group", () => { + const next = assignSignals([group("a", ["S1"]), group("b", ["S2"])], ["S1"], "b"); + expect(next).toEqual([group("b", ["S2", "S1"])]); + }); + + it("adds missing signals and moves existing ones together", () => { + const next = assignSignals([group("a", ["S1"])], ["S1", "S2"], NEW_PLOT); + expect(next).toHaveLength(1); + expect(next[0].signals).toEqual(["S1", "S2"]); + expect(next[0].id).not.toBe("a"); + }); + + it("is a no-op for an unknown target group id", () => { + const layout = [group("a", ["S1"])]; + expect(assignSignals(layout, ["S2"], "missing")).toBe(layout); + }); + + it("does not duplicate a signal already in the target", () => { + const next = assignSignals([group("a", ["S1", "S2"])], ["S2"], "a"); + expect(next[0].signals).toEqual(["S1", "S2"]); + }); + + it("rejects the assignment when new signals would exceed the cap", () => { + const layout: PlotLayout = [ + group("a", Array.from({ length: MAX_TOTAL_SIGNALS - 1 }, (_, i) => `S${i}`)), + ]; + expect(assignSignals(layout, ["N1", "N2"], "a")).toBe(layout); + }); + + it("keeps rightAxis membership when moving a signal between groups", () => { + const next = assignSignals( + [group("a", ["S1"], ["S1"]), group("b", ["S2"])], + ["S1"], + "b", + ); + expect(next).toEqual([group("b", ["S2", "S1"], ["S1"])]); + }); +}); + +describe("toggleRightAxis", () => { + it("adds then removes a signal from rightAxis", () => { + const once = toggleRightAxis([group("a", ["S1", "S2"])], "a", "S2"); + expect(once[0].rightAxis).toEqual(["S2"]); + const twice = toggleRightAxis(once, "a", "S2"); + expect(twice[0].rightAxis).toEqual([]); + }); + + it("ignores signals not in the group and unknown group ids", () => { + const layout = [group("a", ["S1"])]; + expect(toggleRightAxis(layout, "a", "S9")).toBe(layout); + expect(toggleRightAxis(layout, "zz", "S1")).toBe(layout); + }); +}); + +describe("flattenSignals and pruneUnknown", () => { + it("flattens in draw order", () => { + expect(flattenSignals([group("a", ["S2", "S1"]), group("b", ["S3"])])).toEqual([ + "S2", + "S1", + "S3", + ]); + }); + + it("prunes unknown signals and emptied groups, keeping reference when unchanged", () => { + const layout = [group("a", ["S1", "GONE"], ["GONE"]), group("b", ["GONE2"])]; + const pruned = pruneUnknown(layout, new Set(["S1"])); + expect(pruned).toEqual([group("a", ["S1"])]); + expect(pruneUnknown(pruned, new Set(["S1"]))).toBe(pruned); + }); +}); + +describe("serializeLayout and parseLayout", () => { + it("round-trips signals and rightAxis with fresh ids", () => { + const layout = [group("a", ["S1", "S2"], ["S2"])]; + const parsed = parseLayout(serializeLayout(layout)); + expect(parsed).not.toBeNull(); + expect(parsed![0].signals).toEqual(["S1", "S2"]); + expect(parsed![0].rightAxis).toEqual(["S2"]); + expect(parsed![0].id).toBeTruthy(); + }); + + it.each([null, "", "not json", '{"v":2,"plots":[]}', '{"v":1,"plots":"x"}', '{"v":1,"plots":[{"signals":"x"}]}'])( + "returns null for corrupt or wrong-version input %#", + (raw) => { + expect(parseLayout(raw as string | null)).toBeNull(); + }, + ); + + it("drops non-string entries and keeps rightAxis a subset on parse", () => { + const parsed = parseLayout('{"v":1,"plots":[{"signals":["S1",5],"rightAxis":["S1","GHOST"]}]}'); + expect(parsed).toEqual([ + { id: expect.any(String), signals: ["S1"], rightAxis: ["S1"] }, + ]); + }); +}); diff --git a/server/installer/data-downloader/frontend/src/analysis/plot-layout.ts b/server/installer/data-downloader/frontend/src/analysis/plot-layout.ts new file mode 100644 index 00000000..53ee4e7d --- /dev/null +++ b/server/installer/data-downloader/frontend/src/analysis/plot-layout.ts @@ -0,0 +1,151 @@ +// src/analysis/plot-layout.ts +export interface PlotGroup { + id: string; + signals: string[]; + rightAxis: string[]; +} + +export type PlotLayout = PlotGroup[]; + +export const MAX_TOTAL_SIGNALS = 12; +export const NEW_PLOT = "__new__"; + +let idCounter = 0; + +function createGroupId(): string { + idCounter += 1; + return `plot-${Date.now().toString(36)}-${idCounter}`; +} + +export function flattenSignals(layout: PlotLayout): string[] { + return layout.flatMap((g) => g.signals); +} + +function pruneEmpty(layout: PlotLayout): PlotLayout { + return layout.filter((g) => g.signals.length > 0); +} + +function removeSignals(layout: PlotLayout, remove: ReadonlySet): PlotLayout { + return pruneEmpty( + layout.map((g) => ({ + ...g, + signals: g.signals.filter((s) => !remove.has(s)), + rightAxis: g.rightAxis.filter((s) => !remove.has(s)), + })), + ); +} + +export function toggleSignal(layout: PlotLayout, signal: string): PlotLayout { + const present = layout.some((g) => g.signals.includes(signal)); + if (present) { + return removeSignals(layout, new Set([signal])); + } + if (flattenSignals(layout).length + 1 > MAX_TOTAL_SIGNALS) return layout; + return [...layout, { id: createGroupId(), signals: [signal], rightAxis: [] }]; +} + +export function assignSignals( + layout: PlotLayout, + signals: string[], + target: string, +): PlotLayout { + const unique = [...new Set(signals)]; + if (unique.length === 0) return layout; + if (target !== NEW_PLOT && !layout.some((g) => g.id === target)) return layout; + + const current = new Set(flattenSignals(layout)); + const additions = unique.filter((s) => !current.has(s)); + if (current.size + additions.length > MAX_TOTAL_SIGNALS) return layout; + + // Moved signals keep their right-axis membership in the destination group. + const movedRight = new Set( + layout.flatMap((g) => g.rightAxis.filter((s) => unique.includes(s))), + ); + const removed = removeSignals(layout, new Set(unique)); + + if (target === NEW_PLOT) { + return [ + ...removed, + { id: createGroupId(), signals: unique, rightAxis: unique.filter((s) => movedRight.has(s)) }, + ]; + } + return removed.map((g) => + g.id === target + ? { + ...g, + signals: [...g.signals, ...unique.filter((s) => !g.signals.includes(s))], + rightAxis: [...g.rightAxis, ...unique.filter((s) => movedRight.has(s))], + } + : g, + ); +} + +export function toggleRightAxis( + layout: PlotLayout, + groupId: string, + signal: string, +): PlotLayout { + const target = layout.find((g) => g.id === groupId); + if (!target || !target.signals.includes(signal)) return layout; + return layout.map((g) => + g.id === groupId + ? { + ...g, + rightAxis: g.rightAxis.includes(signal) + ? g.rightAxis.filter((s) => s !== signal) + : [...g.rightAxis, signal], + } + : g, + ); +} + +export function pruneUnknown( + layout: PlotLayout, + known: ReadonlySet, +): PlotLayout { + const hasUnknown = layout.some( + (g) => g.signals.some((s) => !known.has(s)) || g.rightAxis.some((s) => !known.has(s)), + ); + if (!hasUnknown) return layout; + return pruneEmpty( + layout.map((g) => ({ + ...g, + signals: g.signals.filter((s) => known.has(s)), + rightAxis: g.rightAxis.filter((s) => known.has(s)), + })), + ); +} + +export function serializeLayout(layout: PlotLayout): string { + return JSON.stringify({ + v: 1, + plots: layout.map((g) => ({ signals: g.signals, rightAxis: g.rightAxis })), + }); +} + +export function parseLayout(raw: string | null): PlotLayout | null { + if (!raw) return null; + try { + const data = JSON.parse(raw) as { v?: unknown; plots?: unknown }; + if (data.v !== 1 || !Array.isArray(data.plots)) return null; + const layout: PlotLayout = []; + for (const entry of data.plots) { + if (typeof entry !== "object" || entry === null) return null; + const rec = entry as { signals?: unknown; rightAxis?: unknown }; + if (!Array.isArray(rec.signals)) return null; + const signals = rec.signals.filter((s): s is string => typeof s === "string"); + const right = Array.isArray(rec.rightAxis) + ? rec.rightAxis.filter((s): s is string => typeof s === "string") + : []; + if (signals.length === 0) continue; + layout.push({ + id: createGroupId(), + signals, + rightAxis: right.filter((s) => signals.includes(s)), + }); + } + return layout; + } catch { + return null; + } +} diff --git a/server/installer/data-downloader/frontend/src/analysis/plot-traces.test.ts b/server/installer/data-downloader/frontend/src/analysis/plot-traces.test.ts index efd9a055..757e4c29 100644 --- a/server/installer/data-downloader/frontend/src/analysis/plot-traces.test.ts +++ b/server/installer/data-downloader/frontend/src/analysis/plot-traces.test.ts @@ -3,6 +3,7 @@ import type { Data, PlotRelayoutEvent } from "plotly.js"; import type { SignalSeries } from "../types"; import { buildTraces, parseXRangeRelayout } from "./plot-traces"; +import { plotStroke } from "../components/sensor-palette"; /** Plotly can emit numbers, Date, or ISO strings; typings only allow numbers. */ function asRelayout(event: Record): PlotRelayoutEvent { @@ -195,3 +196,51 @@ describe("parseXRangeRelayout", () => { expect(parseXRangeRelayout(asRelayout({ "yaxis.range": [0, 1] }))).toBeNull(); }); }); + +describe("plotStroke", () => { + it("returns the shared palette border color and is case-insensitive", () => { + expect(plotStroke("ws_fl", "light")).toBe(plotStroke("WS_FL", "light")); + expect(plotStroke("WS_FL", "light")).toMatch(/^#[0-9a-f]{6}$/i); + }); +}); + +describe("buildTraces yAxis", () => { + const raw = { mode: "raw" as const, resolution_ms: null, point_count: 1, t: [0], v: [1] }; + + it("defaults every trace to the left axis", () => { + for (const trace of buildTraces("S1", raw, "#2563eb")) { + expect((trace as { yaxis?: string }).yaxis).toBe("y"); + } + }); + + it("assigns y2 to all traces of a right-axis signal, including envelope bands", () => { + const env = { + mode: "envelope" as const, + resolution_ms: 1000, + point_count: 1, + t: [0], + min: [0], + max: [2], + avg: [1], + }; + const traces = buildTraces("S1", env, "#2563eb", "y2"); + expect(traces).toHaveLength(3); + for (const trace of traces) { + expect((trace as { yaxis?: string }).yaxis).toBe("y2"); + } + }); + + it("fills the envelope band at 0.15 alpha", () => { + const env = { + mode: "envelope" as const, + resolution_ms: 1000, + point_count: 1, + t: [0], + min: [0], + max: [2], + avg: [1], + }; + const [, minTrace] = buildTraces("S1", env, "#2563eb"); + expect((minTrace as { fillcolor?: string }).fillcolor).toBe("rgba(37, 99, 235, 0.15)"); + }); +}); diff --git a/server/installer/data-downloader/frontend/src/analysis/plot-traces.ts b/server/installer/data-downloader/frontend/src/analysis/plot-traces.ts index 721b4bb9..a855d147 100644 --- a/server/installer/data-downloader/frontend/src/analysis/plot-traces.ts +++ b/server/installer/data-downloader/frontend/src/analysis/plot-traces.ts @@ -2,6 +2,11 @@ import type { Data, PlotData, PlotRelayoutEvent } from "plotly.js"; import type { SignalSeries } from "../types"; +// Horizontal plot-area insets shared by every plot card and the state +// timeline, so the timeline's tracks stay pixel-aligned with the plots' +// time axis. Right-axis plots widen their right margin and drift slightly. +export const PLOT_AREA_MARGIN = { left: 52, right: 16 } as const; + /** Hex (#rgb / #rrggbb) → rgba() with the given alpha. */ export function withAlpha(hex: string, alpha: number): string { const raw = hex.replace("#", "").trim(); @@ -26,11 +31,18 @@ function toDateX(t: number[]): Date[] { return t.map((ms) => new Date(ms)); } +const ENVELOPE_FILL_ALPHA = 0.15; + /** * Build Plotly scattergl traces for one signal. * Envelope order is max → min (fill tonexty) → avg so the band fills correctly. */ -export function buildTraces(signal: string, series: SignalSeries, color: string): Data[] { +export function buildTraces( + signal: string, + series: SignalSeries, + color: string, + yAxis: "y" | "y2" = "y", +): Data[] { const x = toDateX(series.t); if (series.mode === "raw") { @@ -40,6 +52,7 @@ export function buildTraces(signal: string, series: SignalSeries, color: string) name: signal, x, y: series.v, + yaxis: yAxis, line: { color, width: 1.5 }, hovertemplate: "%{y:.4g}" + signal + "", }; @@ -47,7 +60,7 @@ export function buildTraces(signal: string, series: SignalSeries, color: string) } const transparent = withAlpha(color, 0); - const fill = withAlpha(color, 0.25); + const fill = withAlpha(color, ENVELOPE_FILL_ALPHA); const maxTrace: Partial = { type: "scattergl", @@ -55,6 +68,7 @@ export function buildTraces(signal: string, series: SignalSeries, color: string) name: `${signal} max`, x, y: series.max, + yaxis: yAxis, line: { color: transparent, width: 0 }, showlegend: false, hoverinfo: "skip", @@ -66,6 +80,7 @@ export function buildTraces(signal: string, series: SignalSeries, color: string) name: `${signal} min`, x, y: series.min, + yaxis: yAxis, line: { color: transparent, width: 0 }, fill: "tonexty", fillcolor: fill, @@ -79,6 +94,7 @@ export function buildTraces(signal: string, series: SignalSeries, color: string) name: `${signal} (avg)`, x, y: series.avg, + yaxis: yAxis, line: { color, width: 1.5 }, hovertemplate: "%{y:.4g}" + signal + "", }; diff --git a/server/installer/data-downloader/frontend/src/analysis/state-timeline.test.ts b/server/installer/data-downloader/frontend/src/analysis/state-timeline.test.ts new file mode 100644 index 00000000..d77453c2 --- /dev/null +++ b/server/installer/data-downloader/frontend/src/analysis/state-timeline.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import type { StatesResponse } from "../types"; +import { + faultNamesAt, + formatDuration, + MIN_ZOOM_SPAN_MS, + padRange, + segmentBox, + severityFor, +} from "./state-timeline"; + +describe("severityFor", () => { + it("classifies car states", () => { + expect(severityFor("car", 4)).toBe("ok"); + expect(severityFor("car", 0)).toBe("transitional"); + expect(severityFor("car", 5)).toBe("fault"); + expect(severityFor("car", 6)).toBe("fault"); + expect(severityFor("car", 99)).toBe("unknown"); + }); + + it("classifies inverter VSM states", () => { + expect(severityFor("inverter", 6)).toBe("ok"); + expect(severityFor("inverter", 2)).toBe("transitional"); + expect(severityFor("inverter", 7)).toBe("fault"); + expect(severityFor("inverter", 14)).toBe("transitional"); + expect(severityFor("inverter", 42)).toBe("unknown"); + }); + + it("returns unknown for unknown lanes", () => { + expect(severityFor("nope", 4)).toBe("unknown"); + }); +}); + +describe("segmentBox", () => { + const view: [number, number] = [1000, 11000]; + + it("maps a segment inside the view to percentages", () => { + expect(segmentBox(1000, 6000, view)).toEqual({ leftPct: 0, widthPct: 50 }); + }); + + it("clips segments that overflow the view", () => { + const box = segmentBox(0, 21000, view); + expect(box).toEqual({ leftPct: 0, widthPct: 100 }); + }); + + it("returns null for segments outside the view or invalid spans", () => { + expect(segmentBox(11000, 12000, view)).toBeNull(); + expect(segmentBox(0, 1000, view)).toBeNull(); + expect(segmentBox(2000, 3000, [5, 5])).toBeNull(); + }); + + it("enforces a minimum visible width", () => { + const box = segmentBox(5000, 5001, view); + expect(box).not.toBeNull(); + expect(box!.widthPct).toBeGreaterThan(0.3); + }); +}); + +describe("padRange", () => { + it("passes wide ranges through", () => { + expect(padRange(0, 5000)).toEqual([0, 5000]); + }); + + it("pads short ranges to the minimum span, centered", () => { + const [start, end] = padRange(1000, 1200); + expect(end - start).toBe(MIN_ZOOM_SPAN_MS); + expect(start).toBe(600); + expect(end).toBe(1600); + }); +}); + +describe("formatDuration", () => { + it("formats ms, seconds, minutes, and hours", () => { + expect(formatDuration(450)).toBe("450 ms"); + expect(formatDuration(3200)).toBe("3.2 s"); + expect(formatDuration(72000)).toBe("1 m 12 s"); + expect(formatDuration(3_660_000)).toBe("1 h 1 m"); + }); +}); + +describe("faultNamesAt", () => { + const data: StatesResponse = { + season: "wfr26", + start: "s", + end: "e", + lanes: [], + faults: [ + { + name: "Over-current Fault", + source: "run", + segments: [{ start_ms: 100, end_ms: 200 }], + }, + { + name: "Precharge Timeout", + source: "post", + segments: [{ start_ms: 150, end_ms: 400 }], + }, + ], + }; + + it("lists every fault overlapping the interval", () => { + expect(faultNamesAt(data, 150, 180)).toEqual([ + "Over-current Fault", + "Precharge Timeout", + ]); + expect(faultNamesAt(data, 300, 350)).toEqual(["Precharge Timeout"]); + expect(faultNamesAt(data, 500, 600)).toEqual([]); + }); +}); diff --git a/server/installer/data-downloader/frontend/src/analysis/state-timeline.ts b/server/installer/data-downloader/frontend/src/analysis/state-timeline.ts new file mode 100644 index 00000000..2df8d560 --- /dev/null +++ b/server/installer/data-downloader/frontend/src/analysis/state-timeline.ts @@ -0,0 +1,88 @@ +import type { StatesResponse } from "../types"; + +export type Severity = "ok" | "transitional" | "fault" | "unknown"; + +// VCU_State_Info.State: 4 DRIVE is healthy, 5 PRECHARGE_ERROR and +// 6 DEVICE_FAULT are faults, everything else is startup/transitional. +const CAR_SEVERITY: Record = { + 0: "transitional", + 1: "transitional", + 2: "transitional", + 3: "transitional", + 4: "ok", + 5: "fault", + 6: "fault", +}; + +// INV_VSM_State: 6 Motor Running is healthy, 7 Blink Fault Code is a fault, +// precharge/wait/ready/shutdown/reset are transitional. +const VSM_SEVERITY: Record = { + 0: "transitional", + 1: "transitional", + 2: "transitional", + 3: "transitional", + 4: "transitional", + 5: "transitional", + 6: "ok", + 7: "fault", + 14: "transitional", + 15: "transitional", +}; + +export function severityFor(laneId: string, value: number): Severity { + const map = laneId === "car" ? CAR_SEVERITY : laneId === "inverter" ? VSM_SEVERITY : null; + return map?.[value] ?? "unknown"; +} + +export interface SegmentBox { + leftPct: number; + widthPct: number; +} + +const MIN_SEGMENT_WIDTH_PCT = 0.4; + +export function segmentBox( + startMs: number, + endMs: number, + view: [number, number], +): SegmentBox | null { + const [viewStart, viewEnd] = view; + const span = viewEnd - viewStart; + if (!(span > 0) || endMs <= viewStart || startMs >= viewEnd) return null; + const left = (Math.max(startMs, viewStart) - viewStart) / span; + const right = (Math.min(endMs, viewEnd) - viewStart) / span; + return { + leftPct: left * 100, + widthPct: Math.max((right - left) * 100, MIN_SEGMENT_WIDTH_PCT), + }; +} + +export const MIN_ZOOM_SPAN_MS = 1000; + +export function padRange(startMs: number, endMs: number): [number, number] { + const span = endMs - startMs; + if (span >= MIN_ZOOM_SPAN_MS) return [startMs, endMs]; + const pad = (MIN_ZOOM_SPAN_MS - span) / 2; + return [startMs - pad, endMs + pad]; +} + +export function formatDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)} ms`; + const totalSeconds = ms / 1000; + if (totalSeconds < 60) return `${totalSeconds.toFixed(1)} s`; + const totalMinutes = Math.floor(totalSeconds / 60); + const seconds = Math.round(totalSeconds % 60); + if (totalMinutes < 60) return `${totalMinutes} m ${seconds} s`; + const hours = Math.floor(totalMinutes / 60); + return `${hours} h ${totalMinutes % 60} m`; +} + +export function faultNamesAt(data: StatesResponse, startMs: number, endMs: number): string[] { + const names: string[] = []; + for (const fault of data.faults) { + if (fault.segments.some((seg) => seg.start_ms < endMs && seg.end_ms > startMs)) { + names.push(fault.name); + } + } + return names; +} diff --git a/server/installer/data-downloader/frontend/src/api.ts b/server/installer/data-downloader/frontend/src/api.ts index e2a7d21b..e742c037 100644 --- a/server/installer/data-downloader/frontend/src/api.ts +++ b/server/installer/data-downloader/frontend/src/api.ts @@ -7,7 +7,9 @@ import { SensorsGroupedResponse, SensorsResponse, SeriesRequest, - SeriesResponse + SeriesResponse, + StatesRequest, + StatesResponse } from "./types"; const RAW_API_BASE = import.meta.env.VITE_API_BASE_URL?.trim() ?? ""; @@ -106,3 +108,11 @@ export function querySeries(payload: SeriesRequest): Promise { body: JSON.stringify(payload), }); } + +export function queryStates(payload: StatesRequest, init?: RequestInit): Promise { + return request("/api/states", { + method: "POST", + body: JSON.stringify(payload), + ...init, + }); +} diff --git a/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.test.tsx b/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.test.tsx index cca8504e..8c3e12aa 100644 --- a/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.test.tsx +++ b/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.test.tsx @@ -1,9 +1,10 @@ -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { CSSProperties } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { SignalSeries } from "../types"; -import { AnalysisPlotStack } from "./AnalysisPlotStack"; +import { NEW_PLOT } from "../analysis/plot-layout"; +import type { SeriesMap } from "../analysis/series-cache"; +import { AnalysisPlotStack, SIGNALS_MIME, readSignalsPayload } from "./AnalysisPlotStack"; vi.mock("react-plotly.js", () => ({ default: (props: { @@ -24,36 +25,147 @@ afterEach(() => { cleanup(); }); -const series: SignalSeries = { +const rawSeries = (v: number[]): SeriesMap[string] => ({ mode: "raw", resolution_ms: null, - point_count: 3, - t: [ - Date.parse("2026-06-08T21:00:00.000Z"), - Date.parse("2026-06-08T22:00:00.000Z"), - Date.parse("2026-06-08T23:00:00.000Z"), - ], - v: [1.0, 2.5, 1.8], + point_count: v.length, + t: v.map((_, i) => i * 1000), + v, +}); + +function makeDataTransfer(payload: unknown): DataTransfer { + const data: Record = { + [SIGNALS_MIME]: JSON.stringify(payload), + }; + return { + types: Object.keys(data), + getData: (type: string) => data[type] ?? "", + setData: (type: string, value: string) => { + data[type] = value; + }, + dropEffect: "move", + effectAllowed: "move", + } as unknown as DataTransfer; +} + +const baseProps = { + seriesBySignal: { S1: rawSeries([1, 2]), S2: rawSeries([3, 4]) } as SeriesMap, + range: [0, 2000] as [number, number], + onRangeChange: vi.fn(), + onAssignSignals: vi.fn(), + onRemoveSignal: vi.fn(), + onToggleRightAxis: vi.fn(), + theme: "light" as const, }; -describe("AnalysisPlotStack", () => { +describe("AnalysisPlotStack groups", () => { + it("renders one card per group with a legend chip per signal", () => { + render( + , + ); + expect(screen.getAllByTestId("analysis-plot-card")).toHaveLength(1); + expect(screen.getByRole("button", { name: /remove S1/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /remove S2/i })).toBeInTheDocument(); + }); + + it("toggles the right axis from the legend chip axis badge", () => { + const onToggleRightAxis = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /move S2 to the right axis/i })); + expect(onToggleRightAxis).toHaveBeenCalledWith("g1", "S2"); + }); + + it("hides axis badges on single-signal cards", () => { + render( + , + ); + expect(screen.queryByRole("button", { name: /right axis/i })).toBeNull(); + }); + + it("dispatches onAssignSignals when a payload drops on a card", () => { + const onAssignSignals = vi.fn(); + render( + , + ); + fireEvent.drop(screen.getByTestId("analysis-plot-card"), { + dataTransfer: makeDataTransfer({ signals: ["S2"] }), + }); + expect(onAssignSignals).toHaveBeenCalledWith(["S2"], "g1"); + }); + + it("dispatches NEW_PLOT when dropped on the new-plot zone", () => { + const onAssignSignals = vi.fn(); + render( + , + ); + fireEvent.drop(screen.getByTestId("analysis-new-plot-zone"), { + dataTransfer: makeDataTransfer({ signals: ["S2"] }), + }); + expect(onAssignSignals).toHaveBeenCalledWith(["S2"], NEW_PLOT); + }); + + it("ignores foreign drop payloads", () => { + const onAssignSignals = vi.fn(); + render( + , + ); + const dt = { + types: ["text/plain"], + getData: () => "", + } as unknown as DataTransfer; + fireEvent.drop(screen.getByTestId("analysis-plot-card"), { dataTransfer: dt }); + expect(onAssignSignals).not.toHaveBeenCalled(); + }); + + it("shows the empty-stack message when the layout has no groups", () => { + render(); + expect(screen.getByText(/select one or more signals to plot/i)).toBeInTheDocument(); + expect(screen.queryByTestId("analysis-plot-card")).toBeNull(); + }); + it("passes Plotly an explicit 180px height (not percentage) so plots stay in-card", () => { render( , ); - const plot = screen.getByTestId("plotly-mock"); expect(Number(plot.getAttribute("data-trace-count"))).toBeGreaterThan(0); expect(plot).toHaveStyle({ width: "100%", height: "180px" }); expect(plot.style.height).not.toBe("100%"); }); }); + +describe("readSignalsPayload", () => { + it("parses a valid payload and rejects junk", () => { + expect(readSignalsPayload(makeDataTransfer({ signals: ["A", "B"] }))).toEqual(["A", "B"]); + expect(readSignalsPayload(makeDataTransfer({ signals: "A" }))).toBeNull(); + expect(readSignalsPayload(null)).toBeNull(); + }); +}); diff --git a/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.tsx b/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.tsx index 72c93c9c..6df055e8 100644 --- a/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.tsx +++ b/server/installer/data-downloader/frontend/src/components/AnalysisPlotStack.tsx @@ -1,112 +1,106 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import type { Config, Data, Layout, PlotRelayoutEvent } from "plotly.js"; import Plot from "react-plotly.js"; -import { buildTraces, parseXRangeRelayout } from "../analysis/plot-traces"; +import { NEW_PLOT, type PlotGroup, type PlotLayout } from "../analysis/plot-layout"; +import { buildTraces, parseXRangeRelayout, PLOT_AREA_MARGIN } from "../analysis/plot-traces"; import type { SeriesMap } from "../analysis/series-cache"; -import type { SignalSeries } from "../types"; - -/** Season-independent plot stroke colors (light / dark pairs by hash index). */ -const PLOT_PALETTE: Array<{ light: string; dark: string }> = [ - { light: "#2563eb", dark: "#60a5fa" }, - { light: "#c2410c", dark: "#fb923c" }, - { light: "#0d9488", dark: "#2dd4bf" }, - { light: "#7c3aed", dark: "#a78bfa" }, - { light: "#b45309", dark: "#fbbf24" }, - { light: "#15803d", dark: "#4ade80" }, - { light: "#be185d", dark: "#f472b6" }, - { light: "#0e7490", dark: "#22d3ee" }, -]; - -/** djb2 → stable palette index for a signal name (not season). */ -export function signalPlotColor(signal: string, theme: "light" | "dark"): string { - let h = 5381; - for (let i = 0; i < signal.length; i++) { - h = (((h << 5) + h) ^ signal.charCodeAt(i)) >>> 0; - } - return PLOT_PALETTE[h % PLOT_PALETTE.length][theme]; -} +import { plotStroke } from "./sensor-palette"; -function formatResolution(ms: number): string { - if (ms >= 60_000 && ms % 60_000 === 0) return `${ms / 60_000}m`; - if (ms >= 1000 && ms % 1000 === 0) return `${ms / 1000}s`; - return `${ms}ms`; -} +export const SIGNALS_MIME = "application/x-wfr-signals"; -function seriesStatus(series: SignalSeries | undefined): { - modeLabel: string; - countLabel: string; - resolutionLabel: string | null; - emptyMessage: string | null; -} { - if (!series) { - return { - modeLabel: "—", - countLabel: "no data", - resolutionLabel: null, - emptyMessage: "No series loaded for this signal yet.", - }; +/** Parse the custom drag payload; null for foreign or malformed drops. */ +export function readSignalsPayload(dt: DataTransfer | null): string[] | null { + if (!dt) return null; + const raw = dt.getData(SIGNALS_MIME); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as { signals?: unknown }; + if (!Array.isArray(parsed.signals)) return null; + const signals = parsed.signals.filter((s): s is string => typeof s === "string"); + return signals.length > 0 ? signals : null; + } catch { + return null; } - if (series.point_count === 0 || series.t.length === 0) { - return { - modeLabel: series.mode === "envelope" ? "envelope" : "raw", - countLabel: "0 pts", - resolutionLabel: - series.mode === "envelope" ? formatResolution(series.resolution_ms) : null, - emptyMessage: "No samples in the visible range.", - }; - } - return { - modeLabel: series.mode === "envelope" ? "envelope" : "raw", - countLabel: `${series.point_count.toLocaleString()} pts`, - resolutionLabel: - series.mode === "envelope" ? formatResolution(series.resolution_ms) : null, - emptyMessage: null, - }; } export interface AnalysisPlotStackProps { + layout: PlotLayout; seriesBySignal: SeriesMap; - signals: string[]; range: [number, number]; onRangeChange: (startMs: number, endMs: number) => void; + onAssignSignals: (signals: string[], target: string) => void; + onRemoveSignal: (signal: string) => void; + onToggleRightAxis: (groupId: string, signal: string) => void; theme: "light" | "dark"; } +/** Explicit px height, percentage fails when the flex body has only min-height (no definite height). */ +const PLOT_STYLE = { width: "100%", height: "180px" } as const; + +function totalPoints(group: PlotGroup, seriesBySignal: SeriesMap): number { + return group.signals.reduce( + (sum, s) => sum + (seriesBySignal[s]?.point_count ?? 0), + 0, + ); +} + function PlotCard({ - signal, - series, - color, + group, + seriesBySignal, range, isBottom, theme, onRangeChange, + onAssignSignals, + onRemoveSignal, + onToggleRightAxis, }: { - signal: string; - series: SignalSeries | undefined; - color: string; + group: PlotGroup; + seriesBySignal: SeriesMap; range: [number, number]; isBottom: boolean; theme: "light" | "dark"; onRangeChange: (startMs: number, endMs: number) => void; + onAssignSignals: (signals: string[], target: string) => void; + onRemoveSignal: (signal: string) => void; + onToggleRightAxis: (groupId: string, signal: string) => void; }) { - const status = seriesStatus(series); + const [dragOver, setDragOver] = useState(false); const isDark = theme === "dark"; const chartFont = isDark ? "#e6e8eb" : "#111827"; const chartGrid = isDark ? "#2c313a" : "#e5e7eb"; + const rightSet = useMemo(() => new Set(group.rightAxis), [group.rightAxis]); + const showAxisBadges = group.signals.length > 1; const data = useMemo((): Data[] => { - if (!series || status.emptyMessage) return []; - return buildTraces(signal, series, color); - }, [series, signal, color, status.emptyMessage]); + return group.signals.flatMap((signal) => { + const series = seriesBySignal[signal]; + if (!series || series.point_count === 0 || series.t.length === 0) return []; + return buildTraces( + signal, + series, + plotStroke(signal, theme), + rightSet.has(signal) ? "y2" : "y", + ); + }); + }, [group.signals, seriesBySignal, theme, rightSet]); + + const hasRight = group.signals.some((s) => rightSet.has(s)); const layout = useMemo((): Partial => { - return { + const base: Partial = { autosize: true, - margin: { t: 8, r: 16, b: isBottom ? 36 : 8, l: 52, pad: 2 }, + margin: { + t: 8, + r: hasRight ? 52 : PLOT_AREA_MARGIN.right, + b: isBottom ? 36 : 8, + l: PLOT_AREA_MARGIN.left, + pad: 2, + }, hovermode: "x unified", showlegend: false, - uirevision: signal, + uirevision: group.id, font: { color: chartFont, size: 11 }, paper_bgcolor: "rgba(0,0,0,0)", plot_bgcolor: "rgba(0,0,0,0)", @@ -120,13 +114,19 @@ function PlotCard({ showspikes: true, spikemode: "across", }, - yaxis: { + yaxis: { zeroline: false, gridcolor: chartGrid, fixedrange: true }, + }; + if (hasRight) { + base.yaxis2 = { + overlaying: "y", + side: "right", zeroline: false, - gridcolor: chartGrid, + showgrid: false, fixedrange: true, - }, - }; - }, [signal, range, isBottom, chartFont, chartGrid]); + }; + } + return base; + }, [group.id, range, isBottom, chartFont, chartGrid, hasRight]); const config = useMemo( (): Partial => ({ @@ -143,22 +143,78 @@ function PlotCard({ onRangeChange(next[0], next[1]); }; + const points = totalPoints(group, seriesBySignal); + return ( -
+
{ + if (e.dataTransfer.types.includes(SIGNALS_MIME)) { + e.preventDefault(); + setDragOver(true); + } + }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + const signals = readSignalsPayload(e.dataTransfer); + if (signals) onAssignSignals(signals, group.id); + }} + >
-

{signal}

-
- {status.modeLabel} - {status.countLabel} - {status.resolutionLabel && ( - {status.resolutionLabel} - )} +
+ {group.signals.map((signal) => { + const series = seriesBySignal[signal]; + const mode = series ? series.mode : "none"; + const onRight = rightSet.has(signal); + return ( + + + ); + })}
+ {points.toLocaleString()} pts
- {status.emptyMessage ? ( + {data.length === 0 ? (
- {status.emptyMessage} + No samples in the visible range.
) : (
@@ -201,18 +259,39 @@ export function AnalysisPlotStack({ return (
- {signals.map((signal, index) => ( + {layout.map((group, index) => ( ))} +
{ + if (e.dataTransfer.types.includes(SIGNALS_MIME)) { + e.preventDefault(); + setZoneOver(true); + } + }} + onDragLeave={() => setZoneOver(false)} + onDrop={(e) => { + e.preventDefault(); + setZoneOver(false); + const signals = readSignalsPayload(e.dataTransfer); + if (signals) onAssignSignals(signals, NEW_PLOT); + }} + > + Drop signals here for a new plot +
); } diff --git a/server/installer/data-downloader/frontend/src/components/AnalysisSignalPicker.test.tsx b/server/installer/data-downloader/frontend/src/components/AnalysisSignalPicker.test.tsx index c0e7b784..caff322e 100644 --- a/server/installer/data-downloader/frontend/src/components/AnalysisSignalPicker.test.tsx +++ b/server/installer/data-downloader/frontend/src/components/AnalysisSignalPicker.test.tsx @@ -1,10 +1,16 @@ -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { NEW_PLOT } from "../analysis/plot-layout"; import type { SensorsGroupedResponse } from "../types"; import { AnalysisSignalPicker } from "./AnalysisSignalPicker"; +import { SIGNALS_MIME } from "./AnalysisPlotStack"; import { subsystemColor } from "./sensor-palette"; +// The picker imports SIGNALS_MIME from AnalysisPlotStack, which pulls in +// react-plotly.js; stub it so plotly.js never loads under jsdom. +vi.mock("react-plotly.js", () => ({ default: () => null })); + const grouped: SensorsGroupedResponse = { updated_at: null, dbc_source: "test.dbc", @@ -54,3 +60,78 @@ describe("AnalysisSignalPicker", () => { expect(header).toHaveStyle({ borderLeftColor: darkBorder }); }); }); + +const groupedFixture: SensorsGroupedResponse = { + updated_at: null, + dbc_source: "file", + messages: [ + { + name: "M167_Voltage", + subsystem: "INV", + can_id: 167, + can_id_hex: "0x0A7", + signals: ["INV_DC_Bus_Voltage", "INV_Output_Voltage"], + }, + ], + ungrouped: [], +}; + +describe("picker grouping interactions", () => { + it("double-clicking a message header assigns all its signals to a new plot", () => { + const onAssignSignals = vi.fn(); + render( + , + ); + fireEvent.doubleClick(screen.getByRole("button", { name: /M167_Voltage/ })); + expect(onAssignSignals).toHaveBeenCalledWith( + ["INV_DC_Bus_Voltage", "INV_Output_Voltage"], + NEW_PLOT, + ); + }); + + it("sets the drag payload on signal chip dragstart", () => { + render( + , + ); + const setData = vi.fn(); + fireEvent.dragStart(screen.getByRole("checkbox", { name: "INV_DC_Bus_Voltage" }), { + dataTransfer: { setData, effectAllowed: "" }, + }); + expect(setData).toHaveBeenCalledWith( + SIGNALS_MIME, + JSON.stringify({ signals: ["INV_DC_Bus_Voltage"] }), + ); + }); + + it("shows a plot dropdown for selected signals and emits assignment", () => { + const onAssignSignals = vi.fn(); + render( + , + ); + const trigger = screen.getByLabelText("Plot for INV_DC_Bus_Voltage"); + expect(trigger.tagName).toBe("BUTTON"); + fireEvent.click(trigger); + fireEvent.click(screen.getByRole("option", { name: "New plot" })); + expect(onAssignSignals).toHaveBeenCalledWith(["INV_DC_Bus_Voltage"], NEW_PLOT); + }); +}); diff --git a/server/installer/data-downloader/frontend/src/components/AnalysisSignalPicker.tsx b/server/installer/data-downloader/frontend/src/components/AnalysisSignalPicker.tsx index 304ef582..bc2f1a7d 100644 --- a/server/installer/data-downloader/frontend/src/components/AnalysisSignalPicker.tsx +++ b/server/installer/data-downloader/frontend/src/components/AnalysisSignalPicker.tsx @@ -1,6 +1,9 @@ import { useMemo, useState, type ReactNode } from "react"; +import { NEW_PLOT } from "../analysis/plot-layout"; import type { MessageGroup, SensorsGroupedResponse } from "../types"; +import { PlotAssignMenu } from "./PlotAssignMenu"; +import { SIGNALS_MIME } from "./AnalysisPlotStack"; import { OTHER_PALETTE, subsystemColor, type PaletteEntry } from "./sensor-palette"; function matchesQuery(haystacks: Array, query: string): boolean { @@ -13,6 +16,10 @@ export interface AnalysisSignalPickerProps { grouped: SensorsGroupedResponse; selected: ReadonlySet; onToggle: (signal: string) => void; + onClearAll?: () => void; + onAssignSignals?: (signals: string[], target: string) => void; + assignments?: Record; + plotOptions?: Array<{ id: string; label: string }>; theme: "light" | "dark"; maxSelected?: number; } @@ -26,6 +33,9 @@ interface PickerGroupProps { onCollapseToggle: () => void; selected: ReadonlySet; onToggle: (signal: string) => void; + onAssignSignals?: (signals: string[], target: string) => void; + assignments?: Record; + plotOptions?: Array<{ id: string; label: string }>; atCap: boolean; badge?: ReactNode; } @@ -39,6 +49,9 @@ function PickerGroup({ onCollapseToggle, selected, onToggle, + onAssignSignals, + assignments, + plotOptions, atCap, badge, }: PickerGroupProps) { @@ -48,6 +61,12 @@ function PickerGroup({ type="button" className="message-group-header" onClick={onCollapseToggle} + onDoubleClick={() => onAssignSignals?.(signals, NEW_PLOT)} + draggable={Boolean(onAssignSignals)} + onDragStart={(e) => { + e.dataTransfer.setData(SIGNALS_MIME, JSON.stringify({ signals })); + e.dataTransfer.effectAllowed = "move"; + }} style={{ borderLeftColor: colors.border, background: collapsed ? undefined : colors.bg }} aria-expanded={!collapsed} > @@ -69,25 +88,43 @@ function PickerGroup({ const isSelected = selected.has(signal); const disabled = atCap && !isSelected; return ( - + + + {isSelected && onAssignSignals && plotOptions && ( + onAssignSignals([signal], target)} + /> + )} + ); })}
@@ -101,6 +138,10 @@ export function AnalysisSignalPicker({ grouped, selected, onToggle, + onClearAll, + onAssignSignals, + assignments, + plotOptions, theme, maxSelected = 12, }: AnalysisSignalPickerProps) { @@ -146,9 +187,20 @@ export function AnalysisSignalPicker({ -

- {selected.size} / {maxSelected} selected -

+
+

+ {selected.size} / {maxSelected} selected +

+ +
toggleCollapsed(msg.name)} selected={selected} onToggle={onToggle} + onAssignSignals={onAssignSignals} + assignments={assignments} + plotOptions={plotOptions} atCap={atCap} badge={ <> @@ -214,6 +269,9 @@ export function AnalysisSignalPicker({ onCollapseToggle={() => toggleCollapsed("__ungrouped__")} selected={selected} onToggle={onToggle} + onAssignSignals={onAssignSignals} + assignments={assignments} + plotOptions={plotOptions} atCap={atCap} /> )} diff --git a/server/installer/data-downloader/frontend/src/components/AnalysisStateTimeline.test.tsx b/server/installer/data-downloader/frontend/src/components/AnalysisStateTimeline.test.tsx new file mode 100644 index 00000000..c6c250e2 --- /dev/null +++ b/server/installer/data-downloader/frontend/src/components/AnalysisStateTimeline.test.tsx @@ -0,0 +1,109 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { PLOT_AREA_MARGIN } from "../analysis/plot-traces"; +import type { StatesResponse } from "../types"; +import { AnalysisStateTimeline } from "./AnalysisStateTimeline"; + +const data: StatesResponse = { + season: "wfr26", + start: "s", + end: "e", + lanes: [ + { + id: "car", + signal: "State", + label: "Car", + segments: [ + { start_ms: 0, end_ms: 4000, value: 4, label: "DRIVE" }, + { start_ms: 4000, end_ms: 4100, value: 6, label: "DEVICE_FAULT" }, + { start_ms: 20000, end_ms: 30000, value: 4, label: "DRIVE" }, + ], + }, + { + id: "inverter", + signal: "INV_VSM_State", + label: "Inverter", + segments: [{ start_ms: 0, end_ms: 10000, value: 6, label: "Motor Running State" }], + }, + ], + faults: [ + { + name: "Over-current Fault", + source: "run", + segments: [{ start_ms: 4000, end_ms: 4100 }], + }, + ], +}; + +const baseProps = { + data, + loading: false, + error: null as string | null, + viewRange: [0, 10000] as [number, number], + onSelectRange: vi.fn(), + onRetry: vi.fn(), +}; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + window.localStorage.clear(); +}); + +describe("AnalysisStateTimeline", () => { + it("renders lane labels and segments within the view range", () => { + render(); + expect(screen.getByText("Car")).toBeInTheDocument(); + expect(screen.getByText("Inverter")).toBeInTheDocument(); + expect(screen.getByText("Faults")).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: /Car DRIVE/ })).toHaveLength(1); + expect(screen.getByRole("button", { name: "Car DEVICE_FAULT" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Fault Over-current Fault" }), + ).toBeInTheDocument(); + }); + + it("aligns lane gutters with the shared plot-area margins", () => { + render(); + const label = screen.getByText("Car"); + expect(label).toHaveStyle({ width: `${PLOT_AREA_MARGIN.left}px` }); + const track = label.nextElementSibling as HTMLElement; + expect(track).toHaveStyle({ marginRight: `${PLOT_AREA_MARGIN.right}px` }); + }); + + it("zooms to a clicked segment with minimum-span padding", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Car DEVICE_FAULT" })); + // 100 ms segment padded to 1000 ms centered on [4000, 4100]. + expect(baseProps.onSelectRange).toHaveBeenCalledWith(3550, 4550); + }); + + it("shows an error with retry", () => { + render(); + expect(screen.getByRole("alert")).toHaveTextContent(/boom/); + fireEvent.click(screen.getByRole("button", { name: /Retry/i })); + expect(baseProps.onRetry).toHaveBeenCalledTimes(1); + }); + + it("shows an empty notice when there is no state data", () => { + render( + , + ); + expect(screen.getByText(/No state data in this window/i)).toBeInTheDocument(); + }); + + it("collapses, persists the flag, and restores it", () => { + const { unmount } = render(); + fireEvent.click(screen.getByRole("button", { name: /State timeline/i })); + expect(screen.queryByText("Car")).not.toBeInTheDocument(); + expect(window.localStorage.getItem("analysis-timeline-collapsed")).toBe("1"); + unmount(); + + render(); + expect(screen.queryByText("Car")).not.toBeInTheDocument(); + }); +}); diff --git a/server/installer/data-downloader/frontend/src/components/AnalysisStateTimeline.tsx b/server/installer/data-downloader/frontend/src/components/AnalysisStateTimeline.tsx new file mode 100644 index 00000000..d785f716 --- /dev/null +++ b/server/installer/data-downloader/frontend/src/components/AnalysisStateTimeline.tsx @@ -0,0 +1,307 @@ +import { useCallback, useState } from "react"; + +import { PLOT_AREA_MARGIN } from "../analysis/plot-traces"; +import { + faultNamesAt, + formatDuration, + padRange, + segmentBox, + severityFor, +} from "../analysis/state-timeline"; +import type { FaultEntry, StateLane, StatesResponse } from "../types"; + +// Lane labels live inside the plots' left axis gutter and tracks stop at the +// plots' right margin, so timeline segments line up with the plot time axis. +const LANE_LABEL_STYLE = { width: `${PLOT_AREA_MARGIN.left}px` } as const; +const TRACK_STYLE = { marginRight: `${PLOT_AREA_MARGIN.right}px` } as const; + +const COLLAPSE_KEY = "analysis-timeline-collapsed"; +const HOVER_DELAY_MS = 50; + +function readCollapsed(): boolean { + try { + return localStorage.getItem(COLLAPSE_KEY) === "1"; + } catch { + return false; + } +} + +function formatTime(ms: number): string { + return new Date(ms).toLocaleTimeString([], { hour12: false }); +} + +interface TooltipContent { + title: string; + body: string[]; + segmentStartMs: number; + segmentEndMs: number; + /** center of the segment, in viewport coordinates */ + anchorLeft: number; + anchorTop: number; +} + +interface TooltipState extends TooltipContent { + visible: boolean; +} + +export interface AnalysisStateTimelineProps { + data: StatesResponse | null; + loading: boolean; + error: string | null; + viewRange: [number, number]; + onSelectRange: (startMs: number, endMs: number) => void; + onRetry: () => void; +} + +function buildLaneTooltip(lane: StateLane, seg: { start_ms: number; end_ms: number; label: string }): Omit { + return { + title: `${lane.label}: ${seg.label}`, + body: [], + segmentStartMs: seg.start_ms, + segmentEndMs: seg.end_ms, + }; +} + +function buildFaultTooltip(data: StatesResponse, fault: FaultEntry, seg: { start_ms: number; end_ms: number }): Omit { + return { + title: fault.name, + body: faultNamesAt(data, seg.start_ms, seg.end_ms).filter((n) => n !== fault.name), + segmentStartMs: seg.start_ms, + segmentEndMs: seg.end_ms, + }; +} + +interface SegmentRowProps { + segments: Array<{ start_ms: number; end_ms: number; value?: number; label?: string }>; + viewRange: [number, number]; + onActivate: (startMs: number, endMs: number) => void; + classFor: (seg: { start_ms: number; end_ms: number; value?: number }) => string; + ariaLabelFor: (seg: { start_ms: number; end_ms: number }) => string; + tooltipFor: (seg: { start_ms: number; end_ms: number }) => Omit | null; + keyFor: (seg: { start_ms: number; end_ms: number }) => string; + onTipShow: (tip: TooltipState) => void; + onTipHide: () => void; +} + +function SegmentRow({ + segments, + viewRange, + onActivate, + classFor, + ariaLabelFor, + tooltipFor, + keyFor, + onTipShow, + onTipHide, +}: SegmentRowProps) { + function enter( + event: React.MouseEvent | React.FocusEvent, + seg: { start_ms: number; end_ms: number }, + ) { + const content = tooltipFor(seg); + if (!content) return; + const rect = event.currentTarget.getBoundingClientRect(); + onTipShow({ + ...content, + anchorLeft: rect.left + rect.width / 2, + anchorTop: rect.bottom, + visible: true, + }); + } + return ( + <> + {segments.map((seg) => { + const box = segmentBox(seg.start_ms, seg.end_ms, viewRange); + if (!box) return null; + return ( + + {!collapsed && ( +
+ {error && ( +
+ Could not load state timeline: {error} + +
+ )} + {!error && loading && !data && ( +
+ Loading states… +
+ )} + {!error && isEmpty && ( +
+ No state data in this window. +
+ )} + {!error && data && !isEmpty && ( + <> + {data.lanes.map((lane) => ( +
+ + {lane.label} + +
+ + `analysis-timeline-segment severity-${severityFor( + lane.id, + seg.value ?? 0, + )}` + } + ariaLabelFor={(seg) => { + const s = lane.segments.find((x) => x.start_ms === seg.start_ms && x.end_ms === seg.end_ms); + return `${lane.label} ${s?.label ?? ""}`.trim(); + }} + tooltipFor={(seg) => { + const s = lane.segments.find((x) => x.start_ms === seg.start_ms && x.end_ms === seg.end_ms); + if (!s) return null; + return buildLaneTooltip(lane, s); + }} + keyFor={(seg) => `${lane.id}-${seg.start_ms}-${seg.end_ms}`} + onTipShow={(next) => showTip(next)} + onTipHide={() => setTip((prev) => (prev ? { ...prev, visible: false } : prev))} + /> +
+
+ ))} +
+ + Faults + +
+ {data.faults.flatMap((fault) => ( + "analysis-timeline-segment severity-fault"} + ariaLabelFor={() => `Fault ${fault.name}`} + tooltipFor={(seg) => buildFaultTooltip(data, fault, seg)} + keyFor={(seg) => `${fault.source}-${fault.name}-${seg.start_ms}`} + onTipShow={(next) => showTip(next)} + onTipHide={() => setTip((prev) => (prev ? { ...prev, visible: false } : prev))} + /> + ))} +
+
+ + + )} +
+ )} + + ); +} + +interface TimelineTooltipProps { + tip: TooltipState | null; +} + +function TimelineTooltip({ tip }: TimelineTooltipProps) { + // Empty content divs let styled tooltips appear/disappear cleanly while + // preserving the space the CSS transition animates. Native `title` tooltips + // had a ~700 ms browser-controlled delay; HOVER_DELAY_MS in this file + // renders a themed popover after 50 ms instead. + if (!tip) return null; + if (!tip.visible) return
; + return ( +
+ {tip.title} + + {formatTime(tip.segmentStartMs)} - {formatTime(tip.segmentEndMs)} ( + {formatDuration(tip.segmentEndMs - tip.segmentStartMs)}) + + {tip.body.map((line) => ( + {line} + ))} +
+ ); +} diff --git a/server/installer/data-downloader/frontend/src/components/AnalysisWorkspace.test.tsx b/server/installer/data-downloader/frontend/src/components/AnalysisWorkspace.test.tsx index 82ab9794..7d6e17c4 100644 --- a/server/installer/data-downloader/frontend/src/components/AnalysisWorkspace.test.tsx +++ b/server/installer/data-downloader/frontend/src/components/AnalysisWorkspace.test.tsx @@ -1,7 +1,13 @@ import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { RunRecord, Season, SensorsGroupedResponse, SeriesResponse } from "../types"; +import type { + RunRecord, + Season, + SensorsGroupedResponse, + SeriesResponse, + StatesResponse, +} from "../types"; vi.mock("../api", () => ({ fetchSeasons: vi.fn(), @@ -13,6 +19,7 @@ vi.mock("../api", () => ({ updateNote: vi.fn(), querySeries: vi.fn(), querySensorData: vi.fn(), + queryStates: vi.fn(), })); vi.mock("react-plotly.js", () => ({ @@ -51,6 +58,31 @@ vi.mock("../analysis/export-csv", async () => { }; }); +// Capture every requestRange invocation so tests can assert on what the workspace +// dispatches, not just what survives the debounce coalescing at the querySeries boundary. +const { requestRangeCalls } = vi.hoisted(() => ({ requestRangeCalls: [] as string[][] })); + +vi.mock("../analysis/use-series-data", async () => { + const actual = await vi.importActual( + "../analysis/use-series-data", + ); + const React = await import("react"); + return { + ...actual, + useSeriesData: () => { + const hook = actual.useSeriesData(); + const requestRange = React.useCallback( + (seasonTable: string, signals: string[], startMs: number, endMs: number) => { + requestRangeCalls.push([...signals]); + hook.requestRange(seasonTable, signals, startMs, endMs); + }, + [hook.requestRange], + ); + return { ...hook, requestRange }; + }, + }; +}); + import App from "../App"; import { fetchRuns, @@ -59,8 +91,10 @@ import { fetchSensors, fetchSensorsGrouped, querySeries, + queryStates, } from "../api"; import { downloadSeriesCsv } from "../analysis/export-csv"; +import { serializeLayout } from "../analysis/plot-layout"; import { AnalysisWorkspace } from "./AnalysisWorkspace"; const fetchSeasonsMock = vi.mocked(fetchSeasons); @@ -69,6 +103,7 @@ const fetchSensorsMock = vi.mocked(fetchSensors); const fetchSensorsGroupedMock = vi.mocked(fetchSensorsGrouped); const fetchScannerStatusMock = vi.mocked(fetchScannerStatus); const querySeriesMock = vi.mocked(querySeries); +const queryStatesMock = vi.mocked(queryStates); const downloadSeriesCsvMock = vi.mocked(downloadSeriesCsv); const seasons: Season[] = [ @@ -147,6 +182,31 @@ function seriesResponse( }; } +function statesResponse(overrides: Partial = {}): StatesResponse { + return { + season: "wfr26", + start: "s", + end: "e", + lanes: [ + { + id: "car", + signal: "State", + label: "Car", + segments: [ + { + start_ms: Date.parse(run.start_utc) + 60_000, + end_ms: Date.parse(run.start_utc) + 120_000, + value: 4, + label: "DRIVE", + }, + ], + }, + ], + faults: [], + ...overrides, + }; +} + function emptySeriesResponse(season: string, signal: string): SeriesResponse { return { season, @@ -185,6 +245,7 @@ function stubAppApis() { updated_at: null, }); querySeriesMock.mockResolvedValue(seriesResponse("wfr26", "INV_Analog_Input_2", 5)); + queryStatesMock.mockResolvedValue(statesResponse()); } async function flushInitialLoad() { @@ -198,6 +259,7 @@ afterEach(() => { cleanup(); vi.useRealTimers(); vi.clearAllMocks(); + window.localStorage.clear(); }); describe("Analysis workspace tabs (App)", () => { @@ -324,7 +386,9 @@ describe("AnalysisWorkspace", () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); querySeriesMock.mockReset(); + queryStatesMock.mockReset(); downloadSeriesCsvMock.mockReset(); + queryStatesMock.mockResolvedValue(statesResponse()); }); it("clears prior season plots when remounted with a new season key", async () => { @@ -412,6 +476,53 @@ describe("AnalysisWorkspace", () => { expect(screen.getByTestId("analysis-empty")).toBeInTheDocument(); }); + it("shows a 'Refreshing' overlay over the previous plots while a refresh is in flight", async () => { + const refreshSeason: Season = { name: "WFR26", year: 2026, table: "wfr26-refresh-overlay" }; + let resolveSecond!: (value: SeriesResponse) => void; + querySeriesMock + .mockResolvedValueOnce(seriesResponse(refreshSeason.table, "INV_Analog_Input_2", 5)) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); + + render( + , + ); + + fireEvent.change(screen.getByLabelText(/Run window/i), { target: { value: run.key } }); + fireEvent.click(screen.getByRole("checkbox", { name: "INV_Analog_Input_2" })); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(await screen.findByTestId("plotly-mock")).toBeInTheDocument(); + + // Zoom fires a second /api/series request; the first response stays visible. + fireEvent.click(screen.getByTestId("plotly-zoom")); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(screen.queryByTestId("analysis-empty")).toBeNull(); + expect(screen.getByTestId("analysis-refreshing")).toBeInTheDocument(); + expect(await screen.findByTestId("plotly-mock")).toBeInTheDocument(); + + await act(async () => { + resolveSecond(emptySeriesResponse(refreshSeason.table, "INV_Analog_Input_2")); + await Promise.resolve(); + }); + + expect(screen.queryByTestId("analysis-refreshing")).toBeNull(); + expect(screen.getByTestId("analysis-empty")).toBeInTheDocument(); + }); + it("asks for inline confirmation before exporting envelope data", async () => { const envelopeSeason: Season = { name: "WFR26", year: 2026, table: "wfr26-envelope" }; querySeriesMock.mockResolvedValue( @@ -640,3 +751,371 @@ describe("AnalysisWorkspace", () => { expect(endInput.value).toBe(fullEnd); }); }); + +describe("multi-plot workspace", () => { + const runsBySeason: Record = { + WFR26: [run], + WFR25: [], + }; + + const twoSignalGrouped: SensorsGroupedResponse = { + ...grouped, + messages: [ + { + name: "M160_Temperature_Set_1", + subsystem: "INV", + can_id: 160, + can_id_hex: "0x0A0", + signals: ["INV_Analog_Input_2", "INV_Analog_Input_3"], + }, + ], + }; + + function groupedWithSignals(signals: string[]): SensorsGroupedResponse { + return { + ...grouped, + messages: [ + { + name: "M160_Temperature_Set_1", + subsystem: "INV", + can_id: 160, + can_id_hex: "0x0A0", + signals, + }, + ], + }; + } + + function stubGenericSeries() { + querySeriesMock.mockImplementation(async (payload) => { + const series: SeriesResponse["series"] = {}; + for (const signal of payload.signals) { + series[signal] = { + mode: "raw", + resolution_ms: null, + point_count: 1, + t: [Date.parse(run.start_utc)], + v: [1], + }; + } + return { season: payload.season, start: "s", end: "e", series }; + }); + } + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + querySeriesMock.mockReset(); + queryStatesMock.mockReset(); + downloadSeriesCsvMock.mockReset(); + window.localStorage.clear(); + requestRangeCalls.length = 0; + stubGenericSeries(); + queryStatesMock.mockResolvedValue(statesResponse()); + }); + + it("requests the flattened signal list once for all groups", async () => { + render( + , + ); + + fireEvent.change(screen.getByLabelText(/Run window/i), { target: { value: run.key } }); + fireEvent.click(screen.getByRole("checkbox", { name: "INV_Analog_Input_2" })); + fireEvent.click(screen.getByRole("checkbox", { name: "INV_Analog_Input_3" })); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(querySeriesMock).toHaveBeenCalledTimes(1); + expect(querySeriesMock).toHaveBeenCalledWith( + expect.objectContaining({ + season: "wfr26", + signals: ["INV_Analog_Input_2", "INV_Analog_Input_3"], + }), + ); + expect(screen.getAllByTestId("analysis-plot-card")).toHaveLength(2); + + // Regroup the two signals into a single plot; membership is unchanged. + const trigger = screen.getByLabelText("Plot for INV_Analog_Input_3"); + fireEvent.click(trigger); + fireEvent.click(screen.getByRole("option", { name: "Plot 1" })); + + expect(screen.getAllByTestId("analysis-plot-card")).toHaveLength(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + // Same membership must not re-fire the series request. + expect(querySeriesMock).toHaveBeenCalledTimes(1); + }); + + it("persists the layout per season and restores it on remount", async () => { + window.localStorage.setItem( + "analysis-layout:WFR26", + serializeLayout([{ id: "x", signals: ["S1", "S2"], rightAxis: ["S2"] }]), + ); + + render( + , + ); + + expect(screen.getByRole("checkbox", { name: "S1" })).toHaveAttribute("aria-checked", "true"); + expect(screen.getByRole("checkbox", { name: "S2" })).toHaveAttribute("aria-checked", "true"); + + fireEvent.change(screen.getByLabelText(/Run window/i), { target: { value: run.key } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(screen.getAllByTestId("analysis-plot-card")).toHaveLength(1); + const card = screen.getByTestId("analysis-plot-card"); + expect(within(card).getByText("S1")).toBeInTheDocument(); + expect(within(card).getByText("S2")).toBeInTheDocument(); + }); + + it("prunes persisted signals unknown to the season before the first request", async () => { + window.localStorage.setItem( + "analysis-layout:WFR26", + serializeLayout([{ id: "x", signals: ["S1", "GONE"], rightAxis: [] }]), + ); + + render( + , + ); + + fireEvent.change(screen.getByLabelText(/Run window/i), { target: { value: run.key } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(querySeriesMock).toHaveBeenCalled(); + for (const [payload] of querySeriesMock.mock.calls) { + expect(payload.signals).toEqual(["S1"]); + expect(payload.signals).not.toContain("GONE"); + } + }); + + it("never requests a stale persisted signal when the sensor list arrives after the range", async () => { + // Unique table so the shared series cache never masks the request under test. + const lateSeason: Season = { name: "WFR26", year: 2026, table: "wfr26-late-grouped" }; + window.localStorage.setItem( + "analysis-layout:WFR26", + serializeLayout([{ id: "x", signals: ["S1", "GONE"], rightAxis: [] }]), + ); + + const { rerender } = render( + , + ); + + // Establish a view range while the sensor list is still loading. + fireEvent.change(screen.getByLabelText(/Run window/i), { target: { value: run.key } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(querySeriesMock).not.toHaveBeenCalled(); + + // Sensor list arrives without GONE; prune and request now commit together. + rerender( + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + // The stale signal must never be dispatched at all, not merely coalesced away by + // the debounce. Assert on the dispatched requestRange args, then on the API boundary. + expect(requestRangeCalls.length).toBeGreaterThan(0); + for (const signals of requestRangeCalls) { + expect(signals).toEqual(["S1"]); + expect(signals).not.toContain("GONE"); + } + + expect(querySeriesMock).toHaveBeenCalled(); + for (const [payload] of querySeriesMock.mock.calls) { + expect(payload.signals).toEqual(["S1"]); + expect(payload.signals).not.toContain("GONE"); + } + }); + + it("recovers to an empty layout from corrupt storage", () => { + window.localStorage.setItem("analysis-layout:WFR26", "{broken"); + + expect(() => + render( + , + ), + ).not.toThrow(); + + expect( + screen.getByText(/Select a run window and one or more signals/i), + ).toBeInTheDocument(); + }); +}); + +describe("state timeline wiring", () => { + const runsBySeason: Record = { + WFR26: [run], + WFR25: [], + }; + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + querySeriesMock.mockReset(); + queryStatesMock.mockReset(); + querySeriesMock.mockResolvedValue(seriesResponse("wfr26", "INV_Analog_Input_2", 5)); + queryStatesMock.mockResolvedValue(statesResponse()); + }); + + it("fetches states once when a run window is selected, even with no signals", async () => { + render( + , + ); + + fireEvent.change(screen.getByLabelText(/Run window/i), { target: { value: run.key } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(queryStatesMock).toHaveBeenCalledTimes(1); + expect(queryStatesMock).toHaveBeenCalledWith( + expect.objectContaining({ + season: "wfr26", + start: run.start_utc, + end: run.end_utc, + }), + expect.anything(), + ); + const timeline = screen.getByTestId("analysis-timeline"); + expect(timeline).toBeInTheDocument(); + // The timeline lives in the plots column so its tracks share the plots' x extent. + expect(timeline.parentElement).toHaveClass("analysis-plots"); + expect(screen.getByRole("button", { name: "Car DRIVE" })).toBeInTheDocument(); + }); + + it("does not refetch states on zoom", async () => { + const zoomSeason: Season = { name: "WFR26", year: 2026, table: "wfr26-states-zoom" }; + render( + , + ); + + fireEvent.change(screen.getByLabelText(/Run window/i), { target: { value: run.key } }); + fireEvent.click(screen.getByRole("checkbox", { name: "INV_Analog_Input_2" })); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(await screen.findByTestId("plotly-mock")).toBeInTheDocument(); + expect(queryStatesMock).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByTestId("plotly-zoom")); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + // The zoom refires the series request but never the states request. + expect(queryStatesMock).toHaveBeenCalledTimes(1); + }); + + it("zooms the plots when a timeline segment is clicked", async () => { + const clickSeason: Season = { name: "WFR26", year: 2026, table: "wfr26-states-click" }; + render( + , + ); + + fireEvent.change(screen.getByLabelText(/Run window/i), { target: { value: run.key } }); + fireEvent.click(screen.getByRole("checkbox", { name: "INV_Analog_Input_2" })); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + querySeriesMock.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Car DRIVE" })); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + const segStart = new Date(Date.parse(run.start_utc) + 60_000).toISOString(); + const segEnd = new Date(Date.parse(run.start_utc) + 120_000).toISOString(); + expect(querySeriesMock).toHaveBeenCalledWith( + expect.objectContaining({ start: segStart, end: segEnd }), + ); + }); + + it("shows a timeline error without blocking the plots", async () => { + const errSeason: Season = { name: "WFR26", year: 2026, table: "wfr26-states-error" }; + queryStatesMock.mockRejectedValue(new Error("states down")); + + render( + , + ); + + fireEvent.change(screen.getByLabelText(/Run window/i), { target: { value: run.key } }); + fireEvent.click(screen.getByRole("checkbox", { name: "INV_Analog_Input_2" })); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(await screen.findByTestId("plotly-mock")).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent(/states down/i); + }); +}); diff --git a/server/installer/data-downloader/frontend/src/components/AnalysisWorkspace.tsx b/server/installer/data-downloader/frontend/src/components/AnalysisWorkspace.tsx index 5fa8a259..5ea40df7 100644 --- a/server/installer/data-downloader/frontend/src/components/AnalysisWorkspace.tsx +++ b/server/installer/data-downloader/frontend/src/components/AnalysisWorkspace.tsx @@ -1,14 +1,33 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { queryStates } from "../api"; import { findSeasonWithData } from "../analysis/analysis-range"; import { downloadSeriesCsv, seriesToCsv } from "../analysis/export-csv"; +import { + type PlotLayout, + assignSignals, + flattenSignals, + parseLayout, + pruneUnknown, + serializeLayout, + toggleRightAxis, + toggleSignal, +} from "../analysis/plot-layout"; import type { SeriesMap } from "../analysis/series-cache"; import { useSeriesData } from "../analysis/use-series-data"; -import type { RunRecord, Season, SensorsGroupedResponse } from "../types"; +import type { RunRecord, Season, SensorsGroupedResponse, StatesResponse } from "../types"; import { AnalysisPlotStack } from "./AnalysisPlotStack"; import { AnalysisSignalPicker } from "./AnalysisSignalPicker"; +import { AnalysisStateTimeline } from "./AnalysisStateTimeline"; import { AnalysisToolbar } from "./AnalysisToolbar"; +const layoutStorageKey = (seasonName: string) => `analysis-layout:${seasonName}`; + +function knownSignalsOf(grouped: SensorsGroupedResponse | null): Set | null { + if (!grouped) return null; + return new Set([...grouped.messages.flatMap((m) => m.signals), ...grouped.ungrouped]); +} + export interface AnalysisWorkspaceProps { season: Season; runs: RunRecord[]; @@ -61,23 +80,70 @@ export function AnalysisWorkspace({ runsBySeason, }: AnalysisWorkspaceProps) { const [selectedRunKey, setSelectedRunKey] = useState(""); - const [selectedSignals, setSelectedSignals] = useState([]); + const [plots, setPlots] = useState(() => { + try { + return parseLayout(window.localStorage.getItem(layoutStorageKey(season.name))) ?? []; + } catch { + return []; + } + }); const [fullRange, setFullRange] = useState<[number, number] | null>(null); const [viewRange, setViewRange] = useState<[number, number] | null>(null); const [awaitingFirstResponse, setAwaitingFirstResponse] = useState(false); const [exportConfirmOpen, setExportConfirmOpen] = useState(false); + const [statesData, setStatesData] = useState(null); + const [statesLoading, setStatesLoading] = useState(false); + const [statesError, setStatesError] = useState(null); + const [statesReloadKey, setStatesReloadKey] = useState(0); const { seriesBySignal, loadedRequest, loading, error, requestRange, retry } = useSeriesData(); const seasonName = season.name; const seasonTable = season.table; + // Persist on every layout change; storage failures must never break the UI. + useEffect(() => { + try { + window.localStorage.setItem(layoutStorageKey(seasonName), serializeLayout(plots)); + } catch { + // Ignore persistence failures in restricted environments. + } + }, [plots, seasonName]); + + const knownSignals = useMemo(() => knownSignalsOf(grouped), [grouped]); + + // Drop persisted signals this season does not know before they can hit the API. + useEffect(() => { + if (!knownSignals) return; + setPlots((prev) => pruneUnknown(prev, knownSignals)); + }, [knownSignals]); + + const selectedSignals = useMemo(() => flattenSignals(plots), [plots]); const selectedSet = useMemo(() => new Set(selectedSignals), [selectedSignals]); + // Only request signals the season knows: a stale persisted signal must never reach + // the API, even in the commit before pruneUnknown cleans the layout itself. + const requestSignals = useMemo( + () => (knownSignals ? selectedSignals.filter((s) => knownSignals.has(s)) : []), + [knownSignals, selectedSignals], + ); + // Order-insensitive key over the filtered set so neither regrouping nor the + // post-prune re-render (which drops the stale signal) refires the request. + const signalsKey = useMemo(() => [...requestSignals].sort().join(" "), [requestSignals]); const handleToggleSignal = useCallback((signal: string) => { - setSelectedSignals((prev) => - prev.includes(signal) ? prev.filter((s) => s !== signal) : [...prev, signal], - ); + setPlots((prev) => toggleSignal(prev, signal)); + }, []); + + const handleClearAll = useCallback(() => { + setPlots([]); + }, []); + + const handleAssignSignals = useCallback((signals: string[], target: string) => { + setPlots((prev) => assignSignals(prev, signals, target)); + }, []); + + const handleToggleRightAxis = useCallback((groupId: string, signal: string) => { + setPlots((prev) => toggleRightAxis(prev, groupId, signal)); }, []); const handleRunChange = useCallback((runKey: string, startMs: number, endMs: number) => { @@ -112,13 +178,48 @@ export function AnalysisWorkspace({ // Request series whenever table + signals + view range are valid. useEffect(() => { - if (!viewRange || !isValidRange(viewRange[0], viewRange[1]) || selectedSignals.length === 0) { - return; - } + if (!viewRange || !isValidRange(viewRange[0], viewRange[1])) return; + // Wait for the sensor list so restored layouts are pruned before the first request. + if (!knownSignals) return; + if (requestSignals.length === 0) return; setAwaitingFirstResponse(true); setExportConfirmOpen(false); - requestRange(seasonTable, selectedSignals, viewRange[0], viewRange[1]); - }, [seasonTable, selectedSignals, viewRange, requestRange]); + requestRange(seasonTable, [...requestSignals].sort(), viewRange[0], viewRange[1]); + // signalsKey stands in for requestSignals so regrouping does not refire. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [seasonTable, signalsKey, viewRange, requestRange, knownSignals]); + + // Fetch the state timeline once per selected window; zoom is display-only. + useEffect(() => { + if (!fullRange || !isValidRange(fullRange[0], fullRange[1])) { + setStatesData(null); + setStatesError(null); + return; + } + const controller = new AbortController(); + setStatesLoading(true); + setStatesError(null); + queryStates( + { + season: seasonTable, + start: new Date(fullRange[0]).toISOString(), + end: new Date(fullRange[1]).toISOString(), + }, + { signal: controller.signal }, + ) + .then((response) => { + setStatesData(response); + }) + .catch((err: unknown) => { + if (controller.signal.aborted) return; + setStatesData(null); + setStatesError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (!controller.signal.aborted) setStatesLoading(false); + }); + return () => controller.abort(); + }, [seasonTable, fullRange, statesReloadKey]); // Mark first response complete once loading settles after a request. const wasLoadingRef = useRef(false); @@ -180,6 +281,11 @@ export function AnalysisWorkspace({ viewRange != null && !hasPlottableSeries(seriesBySignal, selectedSignals); + // True only when we still have plots rendered AND a refresh is in flight + // (signal/window change after the first response). Drives the "Refreshing" + // overlay instead of letting the plot stack silently redraw. + const refreshInFlight = !error && (loading || awaitingFirstResponse) && !showEmpty && (awaitingFirstResponse || hasPlottableSeries(seriesBySignal, selectedSignals) || wrongSeasonHint != null); + const keepPreviousPlots = !error && selectedSignals.length > 0 && @@ -207,8 +313,30 @@ export function AnalysisWorkspace({ performExport(); }, [exportDisabled, exportSeries, performExport]); + const handleTimelineSelect = useCallback((startMs: number, endMs: number) => { + setViewRange([startMs, endMs]); + }, []); + + const handleStatesRetry = useCallback(() => { + setStatesReloadKey((key) => key + 1); + }, []); + const pickerGrouped = grouped ?? EMPTY_GROUPED; + const plotOptions = useMemo( + () => plots.map((p, i) => ({ id: p.id, label: `Plot ${i + 1}` })), + [plots], + ); + const assignments = useMemo(() => { + const map: Record = {}; + plots.forEach((p, i) => + p.signals.forEach((s) => { + map[s] = i + 1; + }), + ); + return map; + }, [plots]); + return (
+ {fullRange && viewRange && ( + + )} + {error && (
Could not load series. @@ -287,15 +430,36 @@ export function AnalysisWorkspace({ )} {!error && keepPreviousPlots && viewRange && ( - +
+ + {refreshInFlight && ( +
+ Refreshing… +
+ )} +
)} + {!error && + !awaitingFirstResponse && + !loading && + !showEmpty && + !keepPreviousPlots && + !fullRange && ( +
+ {selectedSignals.length} signal{selectedSignals.length === 1 ? "" : "s"} selected. +

Select a run window or time range to plot them.

+
+ )} {!error && !awaitingFirstResponse && !loading && diff --git a/server/installer/data-downloader/frontend/src/components/PlotAssignMenu.test.tsx b/server/installer/data-downloader/frontend/src/components/PlotAssignMenu.test.tsx new file mode 100644 index 00000000..323ecbae --- /dev/null +++ b/server/installer/data-downloader/frontend/src/components/PlotAssignMenu.test.tsx @@ -0,0 +1,68 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { NEW_PLOT } from "../analysis/plot-layout"; +import type { PlotAssignOption } from "./PlotAssignMenu"; +import { PlotAssignMenu } from "./PlotAssignMenu"; + +const options: PlotAssignOption[] = [ + { id: "g1", label: "Plot 1" }, + { id: "g2", label: "Plot 2" }, +]; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function renderMenu(value = "g1") { + const onAssign = vi.fn(); + render(); + return { onAssign }; +} + +describe("PlotAssignMenu", () => { + it("renders the current value as the trigger label", () => { + renderMenu(); + const trigger = screen.getByLabelText("Plot for S"); + expect(trigger.textContent).toContain("Plot 1"); + }); + + it("opens the listbox, selects an option, and assigns", () => { + const { onAssign } = renderMenu(); + fireEvent.click(screen.getByLabelText("Plot for S")); + fireEvent.click(screen.getByRole("option", { name: "Plot 2" })); + expect(onAssign).toHaveBeenCalledWith("g2"); + }); + + it("exposes a New plot item that assigns NEW_PLOT", () => { + const { onAssign } = renderMenu(); + fireEvent.click(screen.getByLabelText("Plot for S")); + fireEvent.click(screen.getByRole("option", { name: "New plot" })); + expect(onAssign).toHaveBeenCalledWith(NEW_PLOT); + }); + + it("closes on Escape and ignores further key events", () => { + const { onAssign } = renderMenu(); + fireEvent.click(screen.getByLabelText("Plot for S")); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("listbox")).toBeNull(); + fireEvent.keyDown(document, { key: "Enter" }); + expect(onAssign).not.toHaveBeenCalled(); + }); + + it("navigates with arrow keys and assigns on Enter", () => { + const { onAssign } = renderMenu("g1"); + fireEvent.click(screen.getByLabelText("Plot for S")); + fireEvent.keyDown(document, { key: "ArrowDown" }); + fireEvent.keyDown(document, { key: "Enter" }); + expect(onAssign).toHaveBeenCalledWith("g2"); + }); + + it("checks the row matching the current value", () => { + renderMenu("g2"); + fireEvent.click(screen.getByLabelText("Plot for S")); + const selected = screen.getByRole("option", { name: "Plot 2" }); + expect(selected).toHaveAttribute("aria-selected", "true"); + }); +}); diff --git a/server/installer/data-downloader/frontend/src/components/PlotAssignMenu.tsx b/server/installer/data-downloader/frontend/src/components/PlotAssignMenu.tsx new file mode 100644 index 00000000..990e37d1 --- /dev/null +++ b/server/installer/data-downloader/frontend/src/components/PlotAssignMenu.tsx @@ -0,0 +1,184 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useId, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +import { NEW_PLOT } from "../analysis/plot-layout"; + +const PLOT_MENU_MAX_WIDTH = 220; + +export interface PlotAssignOption { + id: string; + label: string; +} + +export interface PlotAssignMenuProps { + signal: string; + value: string; + options: PlotAssignOption[]; + onAssign: (value: string) => void; +} + +const NEW_PLOT_LABEL = "New plot"; + +interface PopoverPlacement { + left: number; + top: number; + width: number; +} + +function clampToViewport(left: number, width: number): number { + const maxLeft = Math.max(8, window.innerWidth - width - 8); + return Math.min(Math.max(8, left), maxLeft); +} + +export function PlotAssignMenu({ signal, value, options, onAssign }: PlotAssignMenuProps) { + const [open, setOpen] = useState(false); + const [focusIndex, setFocusIndex] = useState(0); + const [placement, setPlacement] = useState(null); + const triggerRef = useRef(null); + const listboxRef = useRef(null); + const listboxId = useId(); + + const items: PlotAssignOption[] = [ + ...options, + { id: NEW_PLOT, label: NEW_PLOT_LABEL }, + ]; + + const selectedIndex = Math.max( + 0, + items.findIndex((opt) => opt.id === value), + ); + + const close = useCallback(() => setOpen(false), []); + + function measure() { + const trigger = triggerRef.current; + if (!trigger) return; + const rect = trigger.getBoundingClientRect(); + const width = Math.min(Math.max(rect.width, 80), PLOT_MENU_MAX_WIDTH); + const left = clampToViewport(rect.right - width, width); + setPlacement({ left, top: rect.bottom + 4, width }); + } + + useLayoutEffect(() => { + if (open) measure(); + }, [open, selectedIndex]); + + useEffect(() => { + if (!open) return; + function onDown(event: MouseEvent) { + const target = event.target as Node; + if (triggerRef.current?.contains(target)) return; + if (listboxRef.current?.contains(target)) return; + close(); + } + function onKey(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + close(); + triggerRef.current?.focus(); + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + setFocusIndex((idx) => Math.min(items.length - 1, idx + 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setFocusIndex((idx) => Math.max(0, idx - 1)); + } else if (event.key === "Home") { + event.preventDefault(); + setFocusIndex(0); + } else if (event.key === "End") { + event.preventDefault(); + setFocusIndex(items.length - 1); + } else if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onAssign(items[focusIndex].id); + close(); + triggerRef.current?.focus(); + } + } + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + window.addEventListener("resize", measure); + window.addEventListener("scroll", measure, true); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + window.removeEventListener("resize", measure); + window.removeEventListener("scroll", measure, true); + }; + }, [open, close, items, focusIndex, onAssign]); + + function handleSelect(optionId: string) { + onAssign(optionId); + setOpen(false); + } + + return ( +
+ + {open && placement && + createPortal( +
    = 0 ? `${listboxId}-${focusIndex}` : undefined + } + > + {items.map((opt, idx) => { + const isSelected = idx === selectedIndex; + const isFocused = idx === focusIndex; + return ( +
  • setFocusIndex(idx)} + onClick={() => handleSelect(opt.id)} + > + + {opt.label} +
  • + ); + })} +
, + document.body, + )} +
+ ); +} diff --git a/server/installer/data-downloader/frontend/src/components/sensor-palette.ts b/server/installer/data-downloader/frontend/src/components/sensor-palette.ts index 0e2ba31e..cb5e6008 100644 --- a/server/installer/data-downloader/frontend/src/components/sensor-palette.ts +++ b/server/installer/data-downloader/frontend/src/components/sensor-palette.ts @@ -69,3 +69,8 @@ export function paletteIndex(subsystem: string): number { export function subsystemColor(subsystem: string, theme: "light" | "dark"): PaletteEntry { return PALETTE[paletteIndex(subsystem)][theme]; } + +/** Stroke color for plot traces, sharing the subsystem hash and palette borders. */ +export function plotStroke(name: string, theme: "light" | "dark"): string { + return PALETTE[paletteIndex(name)][theme].border; +} diff --git a/server/installer/data-downloader/frontend/src/styles.css b/server/installer/data-downloader/frontend/src/styles.css index f16e3619..d040502f 100644 --- a/server/installer/data-downloader/frontend/src/styles.css +++ b/server/installer/data-downloader/frontend/src/styles.css @@ -714,16 +714,27 @@ body { .analysis-signal-picker-header { display: flex; - align-items: baseline; + align-items: center; justify-content: space-between; gap: 0.5rem; } +.analysis-signal-picker-meta { + display: flex; + align-items: center; + gap: 0.5rem; +} + .analysis-signal-picker-count { margin: 0; font-size: 0.8rem; } +.analysis-signal-clear { + font-size: 0.75rem; + padding: 2px 8px; +} + .analysis-signal-search { width: 100%; } @@ -819,6 +830,25 @@ body { min-height: 180px; } +.analysis-plot-stack-wrap { + position: relative; +} + +.analysis-refreshing { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + background: rgba(127, 127, 127, 0.18); + border-radius: 12px; + pointer-events: none; + z-index: 5; +} + .analysis-plotly { width: 100%; height: 180px; @@ -889,3 +919,272 @@ body { width: 100%; } } + +.analysis-plot-legend { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + min-width: 0; +} + +.analysis-legend-chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.72rem; + padding: 0.1rem 0.4rem; + border: 1px solid var(--border-strong); + border-radius: 999px; + background: var(--surface); + color: var(--text); +} + +.analysis-legend-swatch { + width: 0.55rem; + height: 0.55rem; + border-radius: 2px; +} + +.analysis-legend-axis, +.analysis-legend-remove { + border: none; + background: transparent; + cursor: pointer; + font-size: 0.7rem; + padding: 0 0.15rem; + color: var(--text-muted); +} + +.analysis-legend-axis:hover, +.analysis-legend-remove:hover { + color: var(--text); +} + +.analysis-new-plot-zone { + border: 2px dashed var(--border-strong); + border-radius: 8px; + padding: 0.75rem; + text-align: center; + font-size: 0.8rem; + color: var(--text-subtle); +} + +.analysis-plot-card.is-drop-target, +.analysis-new-plot-zone.is-drop-target { + border-color: var(--accent, #2563eb); + background: var(--surface-2); +} + +.analysis-signal-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem 0.5rem; + min-width: 0; +} + +.analysis-plot-menu { + position: relative; + flex: none; + margin-left: auto; +} + +.analysis-plot-menu-trigger { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px 2px 8px; + font-size: 11px; + font-weight: 500; + line-height: 1.1; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: 6px; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.analysis-plot-menu-trigger:hover, +.analysis-plot-menu-trigger[aria-expanded="true"] { + border-color: var(--accent, #2563eb); + background: var(--surface-2); +} + +.analysis-plot-menu-caret { + font-size: 9px; + opacity: 0.65; +} + +.analysis-plot-menu-list { + z-index: 60; + max-height: 220px; + overflow-y: auto; + margin: 0; + padding: 4px; + list-style: none; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: 8px; + box-shadow: var(--shadow-card, 0 20px 25px -20px rgba(15, 23, 42, 0.25)); +} + +.analysis-plot-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + font-size: 12px; + border-radius: 4px; + cursor: pointer; + user-select: none; +} + +.analysis-plot-menu-item.is-focused { + background: var(--accent-soft-bg, #eef2ff); + color: var(--accent-soft-text, #1e3a8a); +} + +.analysis-plot-menu-item.is-selected { + font-weight: 600; +} + +.analysis-plot-menu-tick { + width: 12px; + text-align: center; + color: var(--accent, #2563eb); +} + +.analysis-timeline { + /* Horizontal padding mirrors .analysis-plot-card so the lane gutters can + line up with the plots' axis margins. */ + padding: 8px 0.85rem 10px; +} + +.analysis-timeline-header { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + background: none; + border: none; + padding: 4px 0; + font-size: 13px; + font-weight: 600; + color: inherit; + cursor: pointer; + text-align: left; +} + +.analysis-timeline-chevron { + display: inline-block; + transition: transform 0.15s ease; +} + +.analysis-timeline-chevron.collapsed { + transform: rotate(-90deg); +} + +.analysis-timeline-body { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 4px; +} + +.analysis-timeline-lane { + display: flex; + align-items: center; + height: 24px; +} + +.analysis-timeline-label { + /* Width comes inline from PLOT_AREA_MARGIN.left; the label sits inside the + same gutter the plots reserve for their y-axis. */ + flex: none; + box-sizing: border-box; + padding-right: 8px; + font-size: 11px; + text-align: right; + opacity: 0.75; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.analysis-timeline-track { + position: relative; + flex: 1; + height: 14px; + border-radius: 4px; + background: rgba(127, 127, 127, 0.12); + overflow: hidden; +} + +.analysis-timeline-segment { + position: absolute; + top: 0; + height: 100%; + border: none; + padding: 0; + cursor: pointer; +} + +.analysis-timeline-segment.severity-ok { + background: #34a853; +} + +.analysis-timeline-segment.severity-transitional { + background: #f9ab00; +} + +.analysis-timeline-segment.severity-fault { + background: #ea4335; +} + +.analysis-timeline-segment.severity-unknown { + background: #9aa0a6; +} + +.analysis-timeline-error { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; +} + +.analysis-timeline-skeleton, +.analysis-timeline-empty { + font-size: 13px; + opacity: 0.7; + padding: 2px 0; +} + +.analysis-timeline-tooltip { + z-index: 60; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 180px; + max-width: 280px; + padding: 8px 10px; + border-radius: 8px; + background: var(--surface); + border: 1px solid var(--border-strong); + box-shadow: var(--shadow-card, 0 20px 25px -20px rgba(15, 23, 42, 0.25)); + color: var(--text); + font-size: 12px; + font-weight: 500; + line-height: 1.35; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.analysis-timeline-tooltip-hidden { + display: none; +} + +.analysis-timeline-tooltip strong { + font-weight: 600; +} diff --git a/server/installer/data-downloader/frontend/src/test-setup.ts b/server/installer/data-downloader/frontend/src/test-setup.ts index f149f27a..aa373369 100644 --- a/server/installer/data-downloader/frontend/src/test-setup.ts +++ b/server/installer/data-downloader/frontend/src/test-setup.ts @@ -1 +1,25 @@ import "@testing-library/jest-dom/vitest"; + +// jsdom in this config does not implement localStorage; provide an in-memory shim +// so persistence-dependent components and tests have a working Storage. +if (typeof window !== "undefined" && !window.localStorage) { + const store = new Map(); + const memoryStorage = { + get length() { + return store.size; + }, + clear: () => store.clear(), + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + key: (index: number) => Array.from(store.keys())[index] ?? null, + removeItem: (key: string) => { + store.delete(key); + }, + setItem: (key: string, value: string) => { + store.set(key, String(value)); + }, + } as Storage; + Object.defineProperty(window, "localStorage", { + value: memoryStorage, + configurable: true, + }); +} diff --git a/server/installer/data-downloader/frontend/src/types.ts b/server/installer/data-downloader/frontend/src/types.ts index 7f9fa238..7a8ad6b2 100644 --- a/server/installer/data-downloader/frontend/src/types.ts +++ b/server/installer/data-downloader/frontend/src/types.ts @@ -102,3 +102,37 @@ export interface SensorsGroupedResponse { messages: MessageGroup[]; ungrouped: string[]; } + +export interface StateSegment { + start_ms: number; + end_ms: number; + value: number; + label: string; +} + +export interface StateLane { + id: string; + signal: string; + label: string; + segments: StateSegment[]; +} + +export interface FaultEntry { + name: string; + source: "post" | "run"; + segments: { start_ms: number; end_ms: number }[]; +} + +export interface StatesRequest { + season: string; + start: string; + end: string; +} + +export interface StatesResponse { + season: string; + start: string; + end: string; + lanes: StateLane[]; + faults: FaultEntry[]; +}