From 9cd5be2d78ed8d7148eb944a2767182edf28ccff Mon Sep 17 00:00:00 2001 From: Evan Bursch Date: Thu, 8 Oct 2026 15:15:02 -0400 Subject: [PATCH] gui: Compare tab, plasma-signal strip, per-n mode amplitude, flattop cut Compare tab: the rotating spectrogram (n-map or log power), n(t), per-|n| mode amplitude, the QS phi-t contour / amplitude / phase and the selected plasma signals stacked on one shared time axis, with user annotations (v-lines, spans, h-levels, points; persisted per shot, JSON import/export) and a drag/arrow panel order. It plots the params last run in the Rotating and QS tabs (store.rotParams / qsParams), else their defaults; the shared trace builders move to lib/plotTraces.ts. Plasma-signal strip above every tab: Ip / Bt / kappa chips plus any coil or probe channel in the shot file, stacked on the time axis with the cursor. Shared time axis: store.timeRange links the strip, the QS traces, the rotating spectrogram and time tracks, and Compare; zoom any of them and the rest follow; double-click or the strip's reset restores the full range. Rotating mode amplitude: new mode_amplitude node and card, one trace per toroidal |n| (core mode_shape.mode_amplitude_by_n). Each (t, f) cell counts only toward its best-fit n, so a strong n=1 does not echo into n=2/3 on the unevenly spaced DIII-D array. Ip flattop cut-off: core plasma.ip_flattop (smoothed |Ip| >= 95 % of peak); with cut_flattop=1 the QS fit window, the rotating tracks and the (t, f) maps stop at the flattop end. Toggled from the strip. Plot: optional onDoubleClick (single clicks held 320 ms so a cursor move can't swallow a double-click), and unmount purges only after an in-flight Plotly.react settles. Co-Authored-By: Claude Opus 5.5 --- docs/VISION.md | 8 +- gui/web/src/App.tsx | 15 +- gui/web/src/components/PlasmaSignals.tsx | 190 ++++++ gui/web/src/components/tabs/ComparisonTab.tsx | 598 ++++++++++++++++++ .../components/tabs/QuasiStationaryTab.tsx | 183 ++---- gui/web/src/components/tabs/RotatingTab.tsx | 190 +++--- gui/web/src/lib/Plot.tsx | 43 +- gui/web/src/lib/annotations.test.ts | 50 ++ gui/web/src/lib/annotations.ts | 148 +++++ gui/web/src/lib/compareLayout.test.ts | 80 +++ gui/web/src/lib/compareLayout.ts | 157 +++++ gui/web/src/lib/flattop.test.ts | 10 + gui/web/src/lib/flattop.ts | 12 + gui/web/src/lib/plotTraces.ts | 135 ++++ gui/web/src/lib/qsTransforms.ts | 16 + gui/web/src/lib/rotatingTransforms.test.ts | 10 +- gui/web/src/lib/rotatingTransforms.ts | 56 ++ gui/web/src/lib/signalUnits.test.ts | 28 + gui/web/src/lib/signalUnits.ts | 24 + gui/web/src/lib/timeRange.test.ts | 21 + gui/web/src/lib/timeRange.ts | 29 + gui/web/src/store.test.ts | 19 + gui/web/src/store.ts | 108 +++- gui/web/src/theme.css | 23 + src/magnetics/core/mode_shape.py | 70 ++ src/magnetics/core/plasma.py | 54 ++ src/magnetics/service/nodes.py | 141 ++++- tests/test_mode_amplitude_by_n.py | 46 ++ tests/test_nodes.py | 50 ++ tests/test_plasma.py | 38 ++ 30 files changed, 2334 insertions(+), 218 deletions(-) create mode 100644 gui/web/src/components/PlasmaSignals.tsx create mode 100644 gui/web/src/components/tabs/ComparisonTab.tsx create mode 100644 gui/web/src/lib/annotations.test.ts create mode 100644 gui/web/src/lib/annotations.ts create mode 100644 gui/web/src/lib/compareLayout.test.ts create mode 100644 gui/web/src/lib/compareLayout.ts create mode 100644 gui/web/src/lib/flattop.test.ts create mode 100644 gui/web/src/lib/flattop.ts create mode 100644 gui/web/src/lib/plotTraces.ts create mode 100644 gui/web/src/lib/signalUnits.test.ts create mode 100644 gui/web/src/lib/signalUnits.ts create mode 100644 gui/web/src/lib/timeRange.test.ts create mode 100644 gui/web/src/lib/timeRange.ts create mode 100644 src/magnetics/core/plasma.py create mode 100644 tests/test_mode_amplitude_by_n.py create mode 100644 tests/test_plasma.py diff --git a/docs/VISION.md b/docs/VISION.md index c155ea6..ee451e6 100644 --- a/docs/VISION.md +++ b/docs/VISION.md @@ -250,7 +250,13 @@ a separate spectrogram pipeline and the IDL SLCONTOUR/MODESPEC tools). What a mo phase vs time, prominent K/χ² quality. 3. **Rotating modes** — n-colored spectrogram with linked cursor, phase-vs-φ / phase-vs-θ fits, coherence, the θ\* / fittype controls. - 4. **Fit registry / export** — all analyses for a shot in one place with quality flags. + 4. **Compare** — the rotating-mode spectrogram (n-map or log power) and the quasi-stationary + φ–t contour / amplitude / phase stacked on one shared time axis (zoom in any panel moves + all), using the params last run in the Rotating and Quasi-stationary views. User + annotations — vertical lines, shaded time spans, per-panel horizontal levels and points — + are added by clicking or by typing values, persist per shot in the browser, and + export/import as JSON. + 5. **Fit registry / export** — all analyses for a shot in one place with quality flags. - **Multi-device** from the same UI (DIII-D, NSTX-U, synthetic) — the UI is data-driven. - A **3D view** (torus colored by the fitted δB(φ,θ), rotating/locked animation) is a strong optional/educational mode. diff --git a/gui/web/src/App.tsx b/gui/web/src/App.tsx index 0c2cda9..3640762 100644 --- a/gui/web/src/App.tsx +++ b/gui/web/src/App.tsx @@ -1,5 +1,5 @@ // App shell: header · left rail (shot picker) · tabbed main. -// The four tabs are independent files owned by different people — they read from +// The tabs are independent files owned by different people — they read from // the store and render `kind`-nodes via . Adding a view = one file. // // `gui` is the GUI integration branch: teammates branch off it (gui-) and @@ -10,15 +10,18 @@ import { useStore, type TabId } from "./store"; import { usingLiveBackend } from "./lib/api"; import SettingsMenu from "./components/SettingsMenu"; import PullControl from "./components/PullControl"; +import PlasmaSignals from "./components/PlasmaSignals"; import ErrorBoundary from "./components/ErrorBoundary"; import SensorsTab from "./components/tabs/SensorsTab"; import QuasiStationaryTab from "./components/tabs/QuasiStationaryTab"; import RotatingTab from "./components/tabs/RotatingTab"; +import ComparisonTab from "./components/tabs/ComparisonTab"; const TABS: { id: TabId; label: string }[] = [ { id: "sensors", label: "Sensors" }, { id: "qs", label: "Quasi-stationary" }, { id: "rotating", label: "Rotating modes" }, + { id: "compare", label: "Compare" }, ]; export default function App() { @@ -174,6 +177,12 @@ export default function App() { ))} + {/* Ip / Bt / κ / any channel, on every tab (needs the live backend) */} + {machine && !mock && ( + + + + )} {!machine ? (
No machine selected.
) : ( @@ -182,8 +191,10 @@ export default function App() { ) : tab === "qs" ? ( - ) : ( + ) : tab === "rotating" ? ( + ) : ( + )} )} diff --git a/gui/web/src/components/PlasmaSignals.tsx b/gui/web/src/components/PlasmaSignals.tsx new file mode 100644 index 0000000..2a876bf --- /dev/null +++ b/gui/web/src/components/PlasmaSignals.tsx @@ -0,0 +1,190 @@ +// Plasma-signal strip — shown above every tab. Plots any channel in the shot file +// (Ip, Bt, κ, 3D-coil currents, individual probes, …) as stacked time traces on the +// global time axis (store.timeRange — zooming here zooms every tab's time plots and +// vice versa), with the global time cursor; clicking sets the cursor. (Pointnames +// not yet in the file are fetched with the Quasi-stationary tab's custom-signal +// panel, which merges them into the shot; they then appear here.) On the Compare tab (`plot={false}`) only the controls show: the selected signals are +// drawn as panels inside the Compare figure instead. +// +// Data: the `extra_signals` node — one request returns both the selected traces and +// `meta.available` (every channel in the file, grouped plasma / coil / sensor). +import { useCallback, useMemo } from "react"; +import type Plotly from "plotly.js-dist-min"; +import { useStore } from "../store"; +import { useNode } from "../lib/useNode"; +import Plot from "../lib/Plot"; +import type { LineNode } from "../lib/contract"; +import { LINE_PALETTE } from "../lib/plotTraces"; +import { displayScale, signalAxisTitle } from "../lib/signalUnits"; +import { resetTimeRangeOnDoubleClick, sharedXAxis, timeRangeFromRelayout } from "../lib/timeRange"; + +type Groups = { plasma: string[]; coil: string[]; sensor: string[] }; +const PANEL_PX = 78; // plot height per trace +const GAP = 0.04; // vertical gap between stacked traces (paper fraction) + +const chipStyle = (on: boolean): React.CSSProperties => ({ + fontSize: 11, padding: "1px 8px", borderRadius: 10, cursor: "pointer", + background: on ? "var(--accent)" : "var(--panel)", + color: on ? "#fff" : "var(--text-dim)", border: "1px solid var(--border)", +}); + +export default function PlasmaSignals({ machine, plot = true }: { machine: string; plot?: boolean }) { + const theme = useStore((s) => s.theme); + const cursorMs = useStore((s) => s.cursorMs); + const setCursorMs = useStore((s) => s.setCursorMs); + const timeRange = useStore((s) => s.timeRange); + const cutFlattop = useStore((s) => s.cutFlattop); + const setCutFlattop = useStore((s) => s.setCutFlattop); + const setTimeRange = useStore((s) => s.setTimeRange); + const selected = useStore((s) => s.traceSignals); + const setSelected = useStore((s) => s.setTraceSignals); + const open = useStore((s) => s.traceOpen); + const setOpen = useStore((s) => s.setTraceOpen); + + // Always fetch (even collapsed) so the header can show what's available; the node + // is light — ≤2000 points per selected trace. + const { node, error } = useNode(machine, "extra_signals", { signals: selected.join(",") }); + const line = node?.kind === "line" ? (node as LineNode) : null; + const groups = useMemo( + () => (line?.meta?.available as Groups | undefined) ?? { plasma: [], coil: [], sensor: [] }, + [line], + ); + const units = useMemo(() => (line?.meta?.units as Record | undefined) ?? {}, [line]); + const missing = (line?.meta?.missing as string[] | undefined) ?? []; + const flattop = (line?.meta?.flattop_ms as [number, number] | null | undefined) ?? null; + const inFile = useMemo( + () => new Set([...groups.plasma, ...groups.coil, ...groups.sensor]), + [groups.plasma, groups.coil, groups.sensor], + ); + + const toggle = (name: string) => + setSelected(selected.includes(name) ? selected.filter((n) => n !== name) : [...selected, name]); + + + // ── One stacked figure, shared x axis, one y axis per trace ────────── + const figure = useMemo(() => { + const series = line?.series ?? []; + const n = series.length; + const data: Partial[] = []; + const layout: Record = { + showlegend: false, + margin: { t: 6, b: 30, l: 70, r: 16 }, + xaxis: { title: { text: "time (ms)" }, anchor: n ? `y${n > 1 ? n : ""}` : "y", ...sharedXAxis(timeRange) }, + hovermode: "x unified", + }; + series.forEach((s, i) => { + const sc = displayScale(units[s.name], s.y); + const ax = i === 0 ? "y" : `y${i + 1}`; + const top = 1 - i / n; + const bot = 1 - (i + 1) / n; + data.push({ + type: "scatter", mode: "lines", name: s.name, x: s.x, + y: sc.factor === 1 ? s.y : s.y.map((v) => v * sc.factor), + xaxis: "x", yaxis: ax, + line: { color: LINE_PALETTE[i % LINE_PALETTE.length], width: 1.4 }, + hovertemplate: `%{y:.4g} ${sc.unit}${s.name}`, + } as Partial); + layout[i === 0 ? "yaxis" : `yaxis${i + 1}`] = { + domain: [bot + (i === n - 1 ? 0 : GAP / 2), top - (i === 0 ? 0 : GAP / 2)], + title: { text: signalAxisTitle(s.name, sc), font: { size: 10 } }, + anchor: "x", zeroline: false, + }; + }); + const accent = theme === "dark" ? "#2ee6cf" : "#0a8d80"; // literal --accent + // Draw the shared cursor only inside the traces' time span — the cursor starts + // at 0 ms, which would otherwise stretch the axis back to t=0. + const xs = series.flatMap((s) => (s.x.length ? [s.x[0], s.x[s.x.length - 1]] : [])); + const inSpan = xs.length > 0 && cursorMs >= Math.min(...xs) && cursorMs <= Math.max(...xs); + const shapes: Record[] = inSpan + ? [{ type: "line", xref: "x", yref: "paper", x0: cursorMs, x1: cursorMs, y0: 0, y1: 1, + line: { color: accent, width: 1.5, dash: "dash" } }] + : []; + // Analysis cut-off at the Ip flattop end: shade what the analyses now leave out. + if (cutFlattop && flattop && xs.length) { + const tEnd = Math.max(...xs); + shapes.push( + { type: "rect", xref: "x", yref: "paper", x0: flattop[1], x1: Math.max(tEnd, flattop[1]), y0: 0, y1: 1, + fillcolor: "rgba(128,128,128,0.18)", line: { width: 0 }, layer: "below" }, + { type: "line", xref: "x", yref: "paper", x0: flattop[1], x1: flattop[1], y0: 0, y1: 1, + line: { color: "#e0533d", width: 1.2 } }, + ); + } + layout.shapes = shapes; + layout.uirevision = `${machine}:${series.map((s) => s.name).join(",")}`; + return { data, layout: layout as Partial, n }; + }, [line, units, cursorMs, theme, machine, timeRange, cutFlattop, flattop]); + + const onRelayout = useCallback((e: Record) => { + const r = timeRangeFromRelayout(e); + if (r !== undefined) setTimeRange(r); + }, [setTimeRange]); + const onDoubleClick = useMemo(() => resetTimeRangeOnDoubleClick(setTimeRange), [setTimeRange]); + + const onClick = useCallback((e: Plotly.PlotMouseEvent) => { + const x = e.points?.[0]?.x; + if (typeof x === "number") setCursorMs(Math.round(x * 100) / 100); + }, [setCursorMs]); + + const extraChannels = [...groups.coil, ...groups.sensor].filter((n) => !selected.includes(n)); + + return ( +
+
+ + {/* plasma signals in this shot (Ip, Bt, κ, …) as toggle chips */} + {groups.plasma.map((name) => ( + + ))} + {/* any other selected channel (coil, probe) — click to remove */} + {selected.filter((n) => !groups.plasma.includes(n) && inFile.has(n)).map((name) => ( + + ))} + + + {timeRange && ( + + )} +
+ {missing.length > 0 && ( +
+ not in this shot: {missing.join(", ")} — fetch it with the Quasi-stationary tab's custom signals +
+ )} + {error &&
signals unavailable for this shot
} + {open && plot && figure.n > 0 && ( + + )} + {open && !plot && figure.n > 0 && ( +
+ plotted as panels in the Compare figure below (toggle “Plasma signals” there) +
+ )} +
+ ); +} diff --git a/gui/web/src/components/tabs/ComparisonTab.tsx b/gui/web/src/components/tabs/ComparisonTab.tsx new file mode 100644 index 0000000..40b4ca2 --- /dev/null +++ b/gui/web/src/components/tabs/ComparisonTab.tsx @@ -0,0 +1,598 @@ +// Compare view — the rotating-mode (MODESPEC) and quasi-stationary (SLCONTOUR) main +// plots stacked on ONE shared time axis, plus user annotations (vertical lines, time +// spans, horizontal levels, points) for marking events across both analyses. +// +// Data: the same nodes the Rotating / Quasi-stationary tabs fetch, with the params +// those tabs last ran (store.rotParams / store.qsParams) — or their defaults when a tab +// hasn't been opened yet — so this view always shows the analysis the user tuned. +import { useCallback, useMemo, useRef, useState } from "react"; +import type Plotly from "plotly.js-dist-min"; +import { useStore } from "../../store"; +import { useNode } from "../../lib/useNode"; +import Plot from "../../lib/Plot"; +import type { ContourNode, HeatmapNode, LineNode, Node } from "../../lib/contract"; +import { MODE_PALETTE, plotChrome } from "../../lib/colormaps"; +import { displayScale, signalAxisTitle } from "../../lib/signalUnits"; +import { withFlattopCut } from "../../lib/flattop"; +import { resetTimeRangeOnDoubleClick, timeRangeFromRelayout } from "../../lib/timeRange"; +import { LINE_PALETTE, QS_MODE_PALETTE, lineTraces, phiTimeTraces, spectrogramTrace } from "../../lib/plotTraces"; +import { QS_DEFAULTS, phiPeak } from "../../lib/qsTransforms"; +import { ROT_DEFAULTS, rotFetchParams } from "../../lib/rotatingTransforms"; +import { + ANNOTATION_COLORS, DEFAULT_COLOR, annotationsToPlotly, parseAnnotations, + type Annotation, type AnnotationInput, type AnnotationKind, type AnnotationPatch, type FixedPanelId, type PanelId, +} from "../../lib/annotations"; +import { + buildCompareFigure, movePanel, orderPanels, panelForAxis, timeExtents, type PanelSpec, +} from "../../lib/compareLayout"; + +const PANEL_LABEL: Record = { + spec: "Spectrogram", + mode_over_time: "n(t)", + mode_amplitude: "Mode amplitude", + phi_t: "δBp(φ, t)", + amplitude: "QS amplitude", + phase: "QS phase", +}; +const PANEL_WEIGHT: Record = { + spec: 1.5, mode_over_time: 0.7, mode_amplitude: 0.8, phi_t: 1.3, amplitude: 0.9, phase: 0.9, +}; +// Display order, top → bottom: rotating analyses first, then quasi-stationary. +const PANEL_ORDER: FixedPanelId[] = ["spec", "mode_over_time", "mode_amplitude", "phi_t", "amplitude", "phase"]; +const DEFAULT_PANELS: FixedPanelId[] = ["spec", "phi_t", "amplitude", "phase"]; +const SIGNAL_WEIGHT = 0.6; +const SIG_PREFIX = "sig:"; +/** Display name of any panel, including the per-signal `sig:` panels. */ +const panelLabel = (p: PanelId) => (p.startsWith(SIG_PREFIX) ? p.slice(SIG_PREFIX.length) : PANEL_LABEL[p as FixedPanelId]); + +type ClickMode = "cursor" | AnnotationKind; +const CLICK_MODES: { id: ClickMode; label: string; hint: string }[] = [ + { id: "cursor", label: "Cursor", hint: "Click sets the shared time cursor" }, + { id: "vline", label: "V-line", hint: "Click adds a vertical time marker through every panel" }, + { id: "span", label: "Span", hint: "Click twice to shade a time window across every panel" }, + { id: "hline", label: "H-line", hint: "Click adds a horizontal level on the clicked panel" }, + { id: "point", label: "Point", hint: "Click adds a marker at the clicked (t, y) on that panel" }, +]; +const KIND_LABEL: Record = { vline: "V-line", span: "Span", hline: "H-line", point: "Point" }; + +// The user's top→bottom panel order, kept per browser (a layout preference, not data). +const ORDER_KEY = "magnetics-compare-order"; +function loadOrder(): string[] { + try { + const v = JSON.parse(window.localStorage.getItem(ORDER_KEY) ?? "[]"); + return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; + } catch { return []; } +} +function saveOrder(order: string[]) { + try { window.localStorage.setItem(ORDER_KEY, JSON.stringify(order)); } catch { /* best effort */ } +} + +const errText = (e: string | null) => e?.replace(/^Error:\s*fetch failed \(\d+\):\s*/, "") ?? null; +const r2 = (v: number) => Math.round(v * 100) / 100; +const r4 = (v: number) => Number(v.toPrecision(4)); + +export default function ComparisonTab({ machine }: { machine: string }) { + const theme = useStore((s) => s.theme); + const cursorMs = useStore((s) => s.cursorMs); + const setCursorMs = useStore((s) => s.setCursorMs); + const rotParams = useStore((s) => s.rotParams); + const qsParams = useStore((s) => s.qsParams); + const annotationsAll = useStore((s) => s.annotations); + const addAnnotation = useStore((s) => s.addAnnotation); + const updateAnnotation = useStore((s) => s.updateAnnotation); + const removeAnnotation = useStore((s) => s.removeAnnotation); + const setAnnotations = useStore((s) => s.setAnnotations); + const annotations = useMemo(() => annotationsAll[machine] ?? [], [annotationsAll, machine]); + + const [panels, setPanels] = useState>(new Set(DEFAULT_PANELS)); + // Plasma signals (the strip's selection: Ip, Bt, κ, …) as panels on the shared axis. + const traceSignals = useStore((s) => s.traceSignals); + const [showSignals, setShowSignals] = useState(true); + const [order, setOrderState] = useState(loadOrder); + const setOrder = useCallback((o: string[]) => { setOrderState(o); saveOrder(o); }, []); + const [dragId, setDragId] = useState(null); + const [specMode, setSpecMode] = useState<"n" | "power">("n"); + const [clickMode, setClickMode] = useState("cursor"); + const [pendingSpan, setPendingSpan] = useState(null); + const xRange = useStore((s) => s.timeRange); + const setXRange = useStore((s) => s.setTimeRange); + const [panelPx, setPanelPx] = useState(170); + + const on = (p: FixedPanelId) => panels.has(p); + const togglePanel = (p: FixedPanelId) => + setPanels((prev) => { + const next = new Set(prev); + if (next.has(p)) next.delete(p); else next.add(p); + return next; + }); + + // ── Data — only enabled panels fetch (a null machine suppresses useNode) ── + const cutFlattop = useStore((s) => s.cutFlattop); + const rot = useMemo(() => { + const r = rotParams ?? rotFetchParams(ROT_DEFAULTS); + return { spec: withFlattopCut(r.spec, cutFlattop), mode: withFlattopCut(r.mode, cutFlattop) }; + }, [rotParams, cutFlattop]); + const qs = useMemo(() => withFlattopCut>(qsParams ?? { ...QS_DEFAULTS }, cutFlattop), + [qsParams, cutFlattop]); + const trackParams = useMemo(() => withFlattopCut({}, cutFlattop), [cutFlattop]); + const m = (p: FixedPanelId, cond = true) => (on(p) && cond ? machine : null); + + const spec = useNode(m("spec", specMode === "power"), "spectrogram", rot.spec); + const modeNum = useNode(m("spec", specMode === "n"), "mode_number", rot.mode); + const modeOverTime = useNode(m("mode_over_time"), "mode_over_time", trackParams); + const modeAmp = useNode(m("mode_amplitude"), "mode_amplitude", trackParams); + const phiT = useNode(m("phi_t"), "phi_t", qs); + const amp = useNode(m("amplitude"), "amplitude", qs); + const phase = useNode(m("phase"), "phase_t", qs); + const sigs = useNode( + showSignals && traceSignals.length ? machine : null, "extra_signals", { signals: traceSignals.join(",") }, + ); + const specRes = specMode === "n" ? modeNum : spec; + + const fetched: Record = { + spec: specRes, mode_over_time: modeOverTime, mode_amplitude: modeAmp, phi_t: phiT, amplitude: amp, phase, + }; + + // ── Panels → one stacked figure ─────────────────────────────────── + const panelSpecs = useMemo((): PanelSpec[] => { + const out: PanelSpec[] = []; + for (const id of PANEL_ORDER) { + if (!panels.has(id)) continue; + if (id === "spec" && specRes.node?.kind === "heatmap") { + const n = specRes.node as HeatmapNode; + // Same treatment as the Rotating tab: [-0.5,6.5] aligns the |n| palette to integers. + const shown = specMode === "n" + ? { ...n, discrete: true, zrange: [-0.5, 6.5] as [number, number] } + : { ...n, discrete: false, zrange: undefined }; + out.push({ + id, weight: PANEL_WEIGHT[id], traces: [spectrogramTrace(shown)], + yaxis: { title: { text: n.axes.y }, range: [rot.spec.fmin ?? ROT_DEFAULTS.fmin, rot.spec.fmax ?? ROT_DEFAULTS.fmax] }, + }); + } else if (id === "mode_over_time" && modeOverTime.node?.kind === "line") { + const n = modeOverTime.node as LineNode; + out.push({ + id, weight: PANEL_WEIGHT[id], traces: lineTraces(n, { palette: MODE_PALETTE.slice(1) }), + yaxis: { title: { text: n.axes.y } }, + }); + } else if (id === "mode_amplitude" && modeAmp.node?.kind === "line") { + // One trace per |n| (series n=0…5, in order), colored like the n-map. + const n = modeAmp.node as LineNode; + out.push({ + id, weight: PANEL_WEIGHT[id], traces: lineTraces(n, { palette: MODE_PALETTE }), + yaxis: { title: { text: "|δḂp| by n (arb.)" }, rangemode: "tozero" }, + }); + } else if (id === "phi_t" && phiT.node?.kind === "contour") { + const n = phiT.node as ContourNode; + out.push({ + id, weight: PANEL_WEIGHT[id], traces: phiTimeTraces(n, phiPeak(n.z, n.y)), + yaxis: { title: { text: n.axes.y }, range: [0, 360], tickvals: [0, 90, 180, 270, 360] }, + }); + } else if (id === "amplitude" && amp.node?.kind === "line") { + const n = amp.node as LineNode; + out.push({ + id, weight: PANEL_WEIGHT[id], legend: String(n.meta?.legend_title ?? "n"), + traces: lineTraces(n, { palette: QS_MODE_PALETTE }), + yaxis: { title: { text: n.axes.y }, rangemode: "tozero" }, + }); + } else if (id === "phase" && phase.node?.kind === "line") { + const n = phase.node as LineNode; + out.push({ + id, weight: PANEL_WEIGHT[id], + traces: lineTraces(n, { visible: n.meta?.phase_visible as boolean[] | undefined, palette: QS_MODE_PALETTE }), + yaxis: { title: { text: n.axes.y }, range: [-180, 180], tickvals: [-180, -90, 0, 90, 180] }, + }); + } + } + if (showSignals && sigs.node?.kind === "line") { + const n = sigs.node as LineNode; + const units = (n.meta?.units as Record | undefined) ?? {}; + n.series.forEach((s, i) => { + const sc = displayScale(units[s.name], s.y); + out.push({ + id: `${SIG_PREFIX}${s.name}`, weight: SIGNAL_WEIGHT, + traces: [{ + type: "scatter", mode: "lines", name: s.name, x: s.x, + y: sc.factor === 1 ? s.y : s.y.map((v) => v * sc.factor), + line: { color: LINE_PALETTE[i % LINE_PALETTE.length], width: 1.4 }, + } as Partial], + yaxis: { title: { text: signalAxisTitle(s.name, sc) } }, + }); + }); + } + // User order (drag / ‹ › in the "Order" row); unknown panels keep default order after. + const byId = new Map(out.map((p) => [p.id, p])); + return orderPanels(out.map((p) => p.id), order).map((id) => byId.get(id)!); + }, [panels, specRes.node, specMode, modeOverTime.node, modeAmp.node, phiT.node, amp.node, phase.node, rot, + showSignals, sigs.node, order]); + const shownIds = panelSpecs.map((p) => p.id); + const move = (id: PanelId, to: number) => setOrder(movePanel(shownIds, order, id, to)); + + const extents = useMemo( + () => timeExtents(panelSpecs.flatMap((p) => p.traces.map((t) => t.x as number[] | undefined))), + [panelSpecs], + ); + + const chrome = plotChrome(theme); + const accent = theme === "dark" ? "#2ee6cf" : "#0a8d80"; // literal --accent (Plotly can't read CSS vars) + + const figure = useMemo(() => { + const fig = buildCompareFigure(panelSpecs, { + chrome, + xTitle: "time (ms)", + xRange, + uirevision: `${machine}:${panelSpecs.map((p) => p.id).join(",")}:${specMode}`, + }); + const ann = annotationsToPlotly(annotations, fig.axisOf, chrome.font.color); + const shapes: Partial[] = [...ann.shapes]; + // Shared time cursor (same look as the Rotating tab's), and the first edge of a span in progress. + if (extents.union && cursorMs >= extents.union[0] && cursorMs <= extents.union[1]) { + shapes.push({ + type: "line", xref: "x", yref: "paper", x0: cursorMs, x1: cursorMs, y0: 0, y1: 1, + line: { color: accent, width: 2, dash: "dash" }, + }); + } + if (pendingSpan != null) { + shapes.push({ + type: "line", xref: "x", yref: "paper", x0: pendingSpan, x1: pendingSpan, y0: 0, y1: 1, + line: { color: DEFAULT_COLOR.span, width: 1, dash: "dot" }, + }); + } + return { + axisOf: fig.axisOf, + data: [...fig.data, ...ann.traces], + layout: { ...fig.layout, shapes, annotations: ann.annotations } as Partial, + }; + }, [panelSpecs, chrome, xRange, machine, specMode, annotations, extents.union, cursorMs, pendingSpan, accent]); + + // ── Interaction ─────────────────────────────────────────────────── + const onClick = useCallback((e: Plotly.PlotMouseEvent) => { + const pt = e.points?.[0]; + if (!pt || typeof pt.x !== "number") return; + const t = r2(pt.x); + const y = typeof pt.y === "number" ? r4(pt.y) : null; + const panel = panelForAxis(figure.axisOf, (pt.data as { yaxis?: string }).yaxis); + switch (clickMode) { + case "cursor": + setCursorMs(t); + break; + case "vline": + addAnnotation(machine, { kind: "vline", t }); + break; + case "span": + if (pendingSpan == null) setPendingSpan(t); + else { + if (t !== pendingSpan) + addAnnotation(machine, { kind: "span", t0: Math.min(pendingSpan, t), t1: Math.max(pendingSpan, t) }); + setPendingSpan(null); + } + break; + case "hline": + if (panel && y != null) addAnnotation(machine, { kind: "hline", panel, y }); + break; + case "point": + if (panel && y != null) addAnnotation(machine, { kind: "point", panel, t, y }); + break; + } + }, [clickMode, figure.axisOf, machine, pendingSpan, setCursorMs, addAnnotation]); + + const onRelayout = useCallback((e: Record) => { + const r = timeRangeFromRelayout(e); + if (r !== undefined) setXRange(r); + }, [setXRange]); + const onDoubleClick = useMemo(() => resetTimeRangeOnDoubleClick(setXRange), [setXRange]); + + const height = Math.max(260, panelSpecs.reduce((a, p) => a + (p.weight ?? 1), 0) * panelPx + 90); + + // ── Status of each enabled panel (loading / unavailable) ───────── + const status = PANEL_ORDER.filter(on).flatMap((p) => { + const f = fetched[p]; + if (f.node) return []; + const err = errText(f.error); + return [{ p, text: err ? `unavailable — ${err}` : "loading…", err: !!err }]; + }); + + const source = ( + + rotating: {rotParams ? "Rotating-tab settings" : "defaults"} · quasi-stationary:{" "} + {qsParams ? "last QS-tab fit" : "defaults (shot time window)"} + + ); + + return ( +
+
+ Panels + {PANEL_ORDER.map((p) => ( + + ))} + + +
+ + +
+
+ + {shownIds.length > 1 && ( +
+ + Order ↓ + + {shownIds.map((id, i) => ( + { setDragId(id); e.dataTransfer.effectAllowed = "move"; }} + onDragEnd={() => setDragId(null)} + onDragOver={(e) => { if (dragId) e.preventDefault(); }} + onDrop={(e) => { e.preventDefault(); if (dragId && dragId !== id) move(dragId, i); setDragId(null); }} + className="seg-btn" data-panel={id} + style={{ display: "inline-flex", alignItems: "center", gap: 2, cursor: "grab", + opacity: dragId === id ? 0.4 : 1, outline: dragId && dragId !== id ? "1px dashed var(--border)" : "none" }}> + + {panelLabel(id)} + + + ))} + {order.length > 0 && ( + + )} +
+ )} + +
+ Click to add +
+ {CLICK_MODES.map((c) => ( + + ))} +
+ {pendingSpan != null && span from {pendingSpan} ms — click the other edge} + + Time axis + + + +
+ +
+ {source} + {status.map((s) => ( + · {panelLabel(s.p)}: {s.text} + ))} +
+ + {panelSpecs.length ? ( + + ) : ( +
{panels.size ? "Loading…" : "Select at least one panel."}
+ )} + + p.id)} + cursorMs={cursorMs} + onAdd={(a) => addAnnotation(machine, a)} + onUpdate={(id, patch) => updateAnnotation(machine, id, patch)} + onRemove={(id) => removeAnnotation(machine, id)} + onReplace={(list) => setAnnotations(machine, list)} + /> +
+ ); +} + +// ── Annotation list + numeric add form ──────────────────────────────── + +/** A number box that commits on blur / Enter, so partial input ("-", "1e") never + * writes NaN into the store. */ +function NumField({ value, onCommit, width = 72, label }: { + value: number; onCommit: (v: number) => void; width?: number; label: string; +}) { + const [text, setText] = useState(null); + const commit = () => { + if (text == null) return; + const v = Number(text); + if (text.trim() !== "" && Number.isFinite(v)) onCommit(v); + setText(null); + }; + return ( + setText(e.target.value)} onBlur={commit} + onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} /> + ); +} + +function ColorPick({ value, onChange }: { value: string; onChange: (c: string) => void }) { + return ( + + {ANNOTATION_COLORS.map((c) => ( + + + { const f = e.target.files?.[0]; if (f) void importJson(f); e.target.value = ""; }} /> + + {importMsg && {importMsg}} + + +
+ + {kind !== "hline" && ( + + )} + {kind === "span" && ( + + )} + {usePanel && ( + <> + + + + )} + setLabel(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") add(); }} /> + + +
+ + {annotations.length > 0 && ( + + + + + + {annotations.map((a) => ( + + + + + + + + + + + ))} + +
kindpanelt / t₀ (ms)t₁ (ms)ylabelcolor
{KIND_LABEL[a.kind]}{a.kind === "hline" || a.kind === "point" ? panelLabel(a.panel) : "all"} + {a.kind === "vline" || a.kind === "point" ? ( + onUpdate(a.id, { t: v })} /> + ) : a.kind === "span" ? ( + onUpdate(a.id, { t0: Math.min(v, a.t1), t1: Math.max(v, a.t1) })} /> + ) : "—"} + + {a.kind === "span" + ? onUpdate(a.id, { t0: Math.min(v, a.t0), t1: Math.max(v, a.t0) })} /> + : "—"} + + {a.kind === "hline" || a.kind === "point" + ? onUpdate(a.id, { y: v })} /> + : "—"} + + onUpdate(a.id, { label: e.target.value })} /> + onUpdate(a.id, { color: c })} /> + +
+ )} + + ); +} diff --git a/gui/web/src/components/tabs/QuasiStationaryTab.tsx b/gui/web/src/components/tabs/QuasiStationaryTab.tsx index 8aa463f..635b983 100644 --- a/gui/web/src/components/tabs/QuasiStationaryTab.tsx +++ b/gui/web/src/components/tabs/QuasiStationaryTab.tsx @@ -5,15 +5,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type Plotly from "plotly.js-dist-min"; import { useStore } from "../../store"; import { apiBase, startFetch, usingLiveBackend } from "../../lib/api"; +import { resetTimeRangeOnDoubleClick } from "../../lib/timeRange"; import { useNode } from "../../lib/useNode"; import NodeView from "../../lib/NodeView"; import Plot from "../../lib/Plot"; import type { ContourNode, LineNode, MetricsNode } from "../../lib/contract"; -import { phiPeak as phiPeakFn, phiRms as phiRmsFn } from "../../lib/qsTransforms"; +import { QS_DEFAULTS, phiPeak as phiPeakFn, phiRms as phiRmsFn } from "../../lib/qsTransforms"; import { fetchDevices, type DeviceInfo } from "../../lib/api"; - -// ── Colorblind-safe palette (Wong 2011) — for sensor/channel traces ── -const LINE_PALETTE = ["#0072B2", "#E69F00", "#56B4E9", "#D55E00", "#CC79A7", "#009E73", "#F0E442"]; +import { + LINE_PALETTE, QS_MODE_PALETTE as MODE_PALETTE, lineTraces, phiTimeTraces, type PhiColormap, +} from "../../lib/plotTraces"; // Excluded sensors are drawn on the maps but de-emphasised (thin grey dashes) so // the user can still see where the deselected/broken probes sit. @@ -28,9 +29,6 @@ const CREDS_HINT = "Enter your username in the left “Pull a shot” panel (plus password/Duo if your " + "account needs them) to fetch new signals."; -// ── Mode-number palette — green/purple/red for n=1,2,3,… ───────────── -// Clearly distinct hues so each mode reads immediately, not blue/orange. -const MODE_PALETTE = ["#2ca02c", "#9467bd", "#d62728", "#8c564b", "#e377c2", "#bcbd22", "#17becf"]; // ── Hooks & helpers ─────────────────────────────────────────────────── // Plotly chrome (axis colors, base font) is themed identically by the shared @@ -46,55 +44,6 @@ function themedLayout(_dark: boolean, overrides: Partial): Partia return overrides; } -function hexToRgba(hex: string, alpha: number): string { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `rgba(${r},${g},${b},${alpha})`; -} - -// Build Plotly traces from a LineNode, optionally with per-series visibility overrides. -function lineTraces( - node: LineNode, - opts?: { visible?: boolean[]; opacity?: number[]; palette?: string[] }, -): Partial[] { - const pal = opts?.palette ?? LINE_PALETTE; - const traces: Partial[] = []; - - node.series.forEach((s, i) => { - const color = pal[i % pal.length]; - const vis = opts?.visible?.[i] !== false; - const opacity = opts?.opacity?.[i] ?? 1; - - // ±1σ band from the contract's typed lower/upper fields (also what the - // HDF5 export writes, so the band on screen matches the downloaded data). - if (s.lower && s.upper && vis) { - traces.push({ - type: "scatter", mode: "lines", x: s.x, - y: s.upper, - line: { width: 0, color }, showlegend: false, hoverinfo: "skip", - opacity, - } as Partial); - traces.push({ - type: "scatter", mode: "lines", x: s.x, - y: s.lower, - fill: "tonexty", fillcolor: hexToRgba(color, 0.45 * opacity), - line: { width: 0, color }, showlegend: false, hoverinfo: "skip", - opacity, - } as Partial); - } - - traces.push({ - type: "scatter", mode: "lines", name: s.name, x: s.x, y: s.y, - line: { color, width: 1.5 }, - visible: vis ? true : "legendonly", - opacity, - } as Partial); - }); - - return traces; -} - // ── Sensor arrays most useful for QS analysis — offline/mock fallback, // used when there's no live backend (or no matching device) to supply the // full device.sensor_sets list the left sidebar's pull panel uses ──────── @@ -157,26 +106,27 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { // Selective subscriptions: a whole-store destructure re-rendered this heavy tab // on EVERY store change (each keystroke in the left rail's credential fields). const setCursorMs = useStore((s) => s.setCursorMs); + const setQsParams = useStore((s) => s.setQsParams); const machines = useStore((s) => s.machines); // ── Analysis settings ───────────────────────────────────────────── - const [ns, setNs] = useState("1,2,3"); - const [ms, setMs] = useState("0"); - const [channelFilter, setChannelFilter] = useState("Bp LFS midplane"); - const [detrendType, setDetrendType] = useState("baseline"); + const [ns, setNs] = useState(QS_DEFAULTS.ns); + const [ms, setMs] = useState(QS_DEFAULTS.ms); + const [channelFilter, setChannelFilter] = useState(QS_DEFAULTS.channel_filter); + const [detrendType, setDetrendType] = useState(QS_DEFAULTS.detrend_type); const [detrendLo, setDetrendLo] = useState(""); const [detrendHi, setDetrendHi] = useState(""); const [tminMs, setTminMs] = useState(""); // "" = auto (read from HDF5) const [tmaxMs, setTmaxMs] = useState(""); - const [colormapChoice, setColormapChoice] = useState<"rdbu" | "cividis" | "viridis">("rdbu"); + const [colormapChoice, setColormapChoice] = useState("rdbu"); // ── Advanced fit-tuning settings ─────────────────────────────────── - const [uncertainty, setUncertainty] = useState("2e-5"); - const [energyFraction, setEnergyFraction] = useState("0.98"); - const [fitBasis, setFitBasis] = useState("sinusoidal-integral"); - const [fitCond, setFitCond] = useState("1000"); // OMFIT SLCONTOUR inversion cutoff (1/rcond), not the K>10 trust threshold - const [cutoffLo, setCutoffLo] = useState("5.0"); - const [cutoffHi, setCutoffHi] = useState("250.0"); + const [uncertainty, setUncertainty] = useState(QS_DEFAULTS.sigma); + const [energyFraction, setEnergyFraction] = useState(QS_DEFAULTS.energy); + const [fitBasis, setFitBasis] = useState(QS_DEFAULTS.fit_basis); + const [fitCond, setFitCond] = useState(QS_DEFAULTS.fit_cond); // OMFIT SLCONTOUR inversion cutoff (1/rcond), not the K>10 trust threshold + const [cutoffLo, setCutoffLo] = useState(QS_DEFAULTS.cutoff_lo); + const [cutoffHi, setCutoffHi] = useState(QS_DEFAULTS.cutoff_hi); // ── Devices (for the Array dropdown's sensor-set list) ───────────── const [devices, setDevices] = useState([]); @@ -211,6 +161,7 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { // ── Deferred fetch: only compute when user clicks Plot ──────────── const [committedParams, setCommittedParams] = useState | null>(null); + const cutFlattop = useStore((s) => s.cutFlattop); // Bumped on every Plot click so an IDENTICAL param set still re-runs the fetch — // without it a transient failure was unrecoverable except by jiggling a setting. const [plotNonce, setPlotNonce] = useState(0); @@ -233,28 +184,45 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { } if (tminMs) p.tmin_ms = tminMs; if (tmaxMs) p.tmax_ms = tmaxMs; + if (cutFlattop) p.cut_flattop = "1"; // backend clamps the fit window to the Ip flattop // Sorted so the param string is stable (identical exclusion set → same fetch key). const excl = Array.from(excludedChannels).sort().join(","); if (excl) p.fit_exclude = excl; return p; }, [ ns, ms, channelFilter, detrendType, detrendLo, detrendHi, tminMs, tmaxMs, - uncertainty, energyFraction, fitBasis, fitCond, cutoffLo, cutoffHi, excludedChannels, + uncertainty, energyFraction, fitBasis, fitCond, cutoffLo, cutoffHi, excludedChannels, cutFlattop, ]); + // Toggling the flattop cut-off (strip, any tab) refits right away rather than just + // flagging the Plot button — it's a global analysis switch, not a QS-tab setting. + const cutRef = useRef(cutFlattop); + useEffect(() => { + if (cutRef.current === cutFlattop) return; + cutRef.current = cutFlattop; + setCommittedParams((prev) => (prev ? qsParams : prev)); + }, [cutFlattop, qsParams]); + - // Linked time-axis zoom (declared here so the trim-window effect below can reset it). - const [timeRange, setTimeRange] = useState<[number, number] | null>(null); + // Linked time-axis zoom — the GLOBAL shared time axis (plasma-signal strip, rotating, + // Compare all follow it). Declared here so the trim-window effect below can reset it. + const timeRange = useStore((s) => s.timeRange); + const setTimeRange = useStore((s) => s.setTimeRange); // ── Typed y-axis zoom (client-side only — doesn't touch fetched data) ── const [phiYMin, setPhiYMin] = useState(""); // "" = auto (0–360°) const [phiYMax, setPhiYMax] = useState(""); // When the trim window changes, clear any user zoom so the axis re-fits to the new data. + // Only on a real change — not on mount, which would wipe the zoom shared with the + // other tabs every time this one opens. + const trimKeyRef = useRef(`${tminMs}|${tmaxMs}`); useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- reset zoom to refit the axis on a new trim window + const k = `${tminMs}|${tmaxMs}`; + if (trimKeyRef.current === k) return; + trimKeyRef.current = k; setTimeRange(null); - }, [tminMs, tmaxMs]); + }, [tminMs, tmaxMs, setTimeRange]); // A different array has different channels, so stale exclusions don't apply. useEffect(() => { @@ -269,11 +237,18 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally run once on mount only }, []); - // When Plot is clicked (committedParams changes), reset zoom to fit new data. + // Publish the committed fit params so the Compare view plots this same fit. useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- reset zoom when new computation is triggered - setTimeRange(null); - }, [committedParams]); + if (committedParams) setQsParams(committedParams); + }, [committedParams, setQsParams]); + + // When Plot is clicked (committedParams changes), reset zoom to fit new data — but not + // for the automatic first commit on mount (that would wipe the shared zoom). + const prevCommitRef = useRef(committedParams); + useEffect(() => { + if (prevCommitRef.current !== null && prevCommitRef.current !== committedParams) setTimeRange(null); + prevCommitRef.current = committedParams; + }, [committedParams, setTimeRange]); // null fetchMachine suppresses all useNode fetches until initial commit fires. const fetchMachine = committedParams !== null ? machine : null; @@ -486,13 +461,15 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { }, [setCursorMs]); // ── Linked time-axis zoom (state declared above) ────────────────── + // Double-click → full range on every tab (see resetTimeRangeOnDoubleClick). + const onTimeDoubleClick = useMemo(() => resetTimeRangeOnDoubleClick(setTimeRange), [setTimeRange]); const handleTimeRelayout = useCallback((e: Record) => { if (e["xaxis.autorange"] === true) { setTimeRange(null); } else if (e["xaxis.range[0]"] != null) { setTimeRange([Number(e["xaxis.range[0]"]), Number(e["xaxis.range[1]"])]); } - }, []); + }, [setTimeRange]); // φ(t) contour: also keep the "y (θ°)" boxes in sync when the user zooms/pans/ // double-click-resets the plot directly (drag-box zoom, scroll, etc.), not just @@ -528,7 +505,7 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { setPhiYMin(String(Math.round(Number(e["yaxis.range[0]"]) * 10) / 10)); setPhiYMax(String(Math.round(Number(e["yaxis.range[1]"]) * 10) / 10)); } - }, [setPhiYMin, setPhiYMax]); + }, [setPhiYMin, setPhiYMax, setTimeRange]); // ── Shared time axis ────────────────────────────────────────────── // Double-click resets to [tMin, tMax] — the union of every linked panel's real @@ -827,34 +804,10 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { [fitResNode, sharedSigResRange, timeXAxis]); // ── Section 8: phi_t waterfall ──────────────────────────────────── - const cmapProps = useMemo(() => { - if (colormapChoice === "cividis") return { colorscale: "Cividis", reversescale: false }; - if (colormapChoice === "viridis") return { colorscale: "Viridis", reversescale: false }; - return { colorscale: "RdBu", reversescale: true }; // RdBu_r ≈ notebook matplotlib - }, [colormapChoice]); - - const phiTimeData = useMemo((): Partial[] => { - if (!phiTimePlot) return []; - const [zmin, zmax] = phiTimePlot.zrange ?? [-42, 42]; - const traces: Partial[] = [{ - type: "heatmap" as const, - x: phiTimePlot.x, y: phiTimePlot.y, z: phiTimePlot.z, - ...cmapProps, - zmin, zmax, - zsmooth: false, - showscale: true, - colorbar: { title: { text: "Fit" }, thickness: 12, outlinewidth: 0 }, - } as Partial]; - if (phiPeak) { - traces.push({ - type: "scatter" as const, mode: "markers" as const, - x: phiTimePlot.x, y: phiPeak, - marker: { symbol: "circle-open" as const, size: 4, color: "white", line: { width: 1, color: "white" } }, - hoverinfo: "skip" as const, showlegend: false, - } as Partial); - } - return traces; - }, [phiTimePlot, phiPeak, cmapProps]); + const phiTimeData = useMemo( + () => (phiTimePlot ? phiTimeTraces(phiTimePlot, phiPeak, colormapChoice) : []), + [phiTimePlot, phiPeak, colormapChoice], + ); const phiYRange = useMemo((): [number, number] => (phiYMin !== "" && phiYMax !== "") ? [Number(phiYMin), Number(phiYMax)] : [0, 360], @@ -1162,18 +1115,18 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { xaxis: { ...timeXAxis, showticklabels: false }, yaxis: { title: { text: "RMS (G)" }, rangemode: "tozero" as const }, margin: { t: 4, b: 4, l: 60, r: 80 }, - } as Partial} onClick={seekTo} onRelayout={handleTimeRelayout} exportName={xn("phi_rms")} download={dl("phi_t")} /> + } as Partial} onClick={seekTo} onRelayout={handleTimeRelayout} onDoubleClick={onTimeDoubleClick} exportName={xn("phi_rms")} download={dl("phi_t")} /> )} - + )} {/* Section 7: Mode amplitude & phase */} {ampNode?.kind === "line" && ( - + )} {phaseTimeNode?.kind === "line" && ( - + )} @@ -1245,7 +1198,7 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { showlegend: false, margin: { t: 4, b: 34, l: 64, r: 20 }, } as Partial)} - onClick={seekTo} onRelayout={handleTimeRelayout} + onClick={seekTo} onRelayout={handleTimeRelayout} onDoubleClick={onTimeDoubleClick} exportName={xn(`custom_${s.name}`)} /> ))} @@ -1294,13 +1247,13 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { showlegend: false, margin: { t: 2, b: isLast ? 34 : 2, l: 92, r: 20 }, } as Partial)} - onClick={seekTo} onRelayout={handleTimeRelayout} + onClick={seekTo} onRelayout={handleTimeRelayout} onDoubleClick={onTimeDoubleClick} exportName={xn(`signal_${pair.channel}`)} /> ); })} ) : ( - )} @@ -1315,12 +1268,12 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) {
{/* Residuals — top */} {fitResNode - ? + ? :
{fitResError ? `residuals unavailable: ${fitResError.replace(/^Error:\s*/, "")}` : "loading residuals…"}
} {/* Chi² — bottom */} {chiSqNode - ? + ? :
{chiSqError ? `χ² unavailable: ${chiSqError.replace(/^Error:\s*/, "")}` : "loading χ²…"}
}
diff --git a/gui/web/src/components/tabs/RotatingTab.tsx b/gui/web/src/components/tabs/RotatingTab.tsx index 6276431..cd604e1 100644 --- a/gui/web/src/components/tabs/RotatingTab.tsx +++ b/gui/web/src/components/tabs/RotatingTab.tsx @@ -7,11 +7,14 @@ import Plot from "../../lib/Plot"; import NodeView from "../../lib/NodeView"; import DraggableDivider from "../../lib/DraggableDivider"; import { usingLiveBackend, fetchChannelUsage, type ChannelUsage } from "../../lib/api"; -import type { Node } from "../../lib/contract"; -import { GATE_POS_MAX, gatePosToPct, medianStep, percentile } from "../../lib/rotatingTransforms"; - -// Slider position that yields ≈70% by default (a sensible noise floor to start). -const GATE_POS_DEFAULT = 227; +import type { LineNode, Node } from "../../lib/contract"; +import { + GATE_POS_DEFAULT, GATE_POS_MAX, gatePosToPct, medianStep, percentile, rotFetchParams, + ROT_DEFAULTS, +} from "../../lib/rotatingTransforms"; +import { lineTraces, spectrogramTrace } from "../../lib/plotTraces"; +import { withFlattopCut } from "../../lib/flattop"; +import { resetTimeRangeOnDoubleClick, sharedXAxis, timeRangeFromRelayout } from "../../lib/timeRange"; // Offline synthetic-demo constants. These were formerly user-facing knobs (PEST λ and // sensor-shielding cutoff) that only ever affected the no-backend demo — they have no @@ -63,9 +66,19 @@ export default function RotatingTab({ machine }: { machine: string }) { // in the left rail's credential fields. const cursorMs = useStore((s) => s.cursorMs); const setCursorMs = useStore((s) => s.setCursorMs); + const setRotParams = useStore((s) => s.setRotParams); // Foreground ink that flips with the theme so the raw dB/dt trace stays visible on // the light plot background (it was hard-coded white → invisible in light mode). const dark = useStore((s) => s.theme === "dark"); + // Global shared time axis (plasma-signal strip, QS, Compare): the spectrogram and the + // time tracks below follow it, and zooming their time axis sets it. + const timeRange = useStore((s) => s.timeRange); + const setTimeRange = useStore((s) => s.setTimeRange); + const onTimeRelayout = useCallback((e: Record) => { + const r = timeRangeFromRelayout(e); + if (r !== undefined) setTimeRange(r); + }, [setTimeRange]); + const onTimeDoubleClick = useMemo(() => resetTimeRangeOnDoubleClick(setTimeRange), [setTimeRange]); const ink = dark ? "rgba(255,255,255,0.85)" : "rgba(20,34,46,0.9)"; // View states @@ -74,10 +87,10 @@ export default function RotatingTab({ machine }: { machine: string }) { const [channelInfo, setChannelInfo] = useState(null); // Control Parameter states - const [fmin, setFmin] = useState(0); - const [fmax, setFmax] = useState(50); + const [fmin, setFmin] = useState(ROT_DEFAULTS.fmin); + const [fmax, setFmax] = useState(ROT_DEFAULTS.fmax); const [fittype, setFittype] = useState(2); // 0 = circular, 1 = toroidicity, 2 = PEST theta* - const [smoothing, setSmoothing] = useState(5); + const [smoothing, setSmoothing] = useState(ROT_DEFAULTS.smoothing); // Power gate: a percentile floor on cell power; hides low-power noise in BOTH the // power spectrogram (client-side) and the n-map (server n_amp_pct). The slider stores // a linear position; gatePosToPct maps it to a percentile that scrubs finely near 100%. @@ -88,26 +101,26 @@ export default function RotatingTab({ machine }: { machine: string }) { // Coherence gate γ² ∈ [0,1]: drops spectrogram cells whose 2-point magnitude-squared // coherence is below this (incoherent electronic noise), via the core denoise_spectrogram. // 0 = off, so the default view is unchanged. Applies to the POWER spectrogram only. - const [coherenceMin, setCoherenceMin] = useState(0); + const [coherenceMin, setCoherenceMin] = useState(ROT_DEFAULTS.coherenceMin); // Mode-coherence gate for the n-map: the per-cell harmonic energy fraction ∈ [1/M, 1] // (1 = a pure single-n pattern, 1/M = white across harmonics = noise; M = 2·n_max+1). // Distinct quantity from the 2-point γ² above — it gates the mode-number plot, not the // power view. Default 0.3 (real cells rarely exceed ~0.5, unlike the resultant length). - const [nGate, setNGate] = useState(0.3); + const [nGate, setNGate] = useState(ROT_DEFAULTS.nGate); // Optional 2-D Gaussian pre-smoothing (before the gates): blurs power/coherence (and the // n-map quality/amplitude) over (time, frequency) so contiguous coherent structure survives // aggressive gating. Off by default (σ=0 ⇒ exact no-op). - const [smoothOn, setSmoothOn] = useState(false); + const [smoothOn, setSmoothOn] = useState(ROT_DEFAULTS.smoothOn); // σ in *grid cells* (STFT bins), not physical ms/kHz: one cell = one (t, f) bin at the // current resolution, so the blur spans the same number of neighbours regardless of the // slice/column knobs — and can't collapse to a sub-bin no-op. The readout below the sliders // shows the physical equivalent for the live grid. Skewed to σ_time > σ_freq to fill gaps // along a ridge without merging neighbouring modes in frequency. - const [smoothTcells, setSmoothTcells] = useState(3); - const [smoothFcells, setSmoothFcells] = useState(1.5); + const [smoothTcells, setSmoothTcells] = useState(ROT_DEFAULTS.smoothTcells); + const [smoothFcells, setSmoothFcells] = useState(ROT_DEFAULTS.smoothFcells); // STFT window for the LIVE backend spectrogram (ms). Frequency resolution is // 1/window, so 2 ms → 500 Hz bins (sharper than the 1 ms / 1 kHz default). - const [specSliceMs, setSpecSliceMs] = useState(2); + const [specSliceMs, setSpecSliceMs] = useState(ROT_DEFAULTS.specSliceMs); // Advanced Parameter states const [advancedExpanded, setAdvancedExpanded] = useState(false); @@ -164,28 +177,19 @@ export default function RotatingTab({ machine }: { machine: string }) { // band ×3 nodes. These don't depend on the time cursor, so scrubbing never refetches. // `smoothing` is the coherence-estimation window (backend `coherence_smooth`): it // re-runs the core and changes the real coherence map → the sub-interval coherence trace. - // Server-side denoise: the coherence gate and the per-frequency power floor both run in - // the core (denoise_spectrogram) so the spectrogram, 2-point n-map, and n-spectrum all - // threshold on ONE consistent (t, f) grid — no client-side blanking on the live path. + // Server-side denoise (coherence gate + per-frequency power floor) and the optional 2-D + // Gaussian pre-smoothing are folded into these params by rotFetchParams — see there. // The Power Gate slider is a percentile floor: keep cells ≥ the p-th percentile of each - // frequency's power over time (power_floor_k=1 × that percentile). 0 on both = no-op. - const denoiseOn = coherenceMin > 0 || powerGate > 0; - // 2-D Gaussian pre-smoothing params, shared by every spectral node so all views smooth on - // one basis. Effective only when the toggle is on and a σ is non-zero (else a server no-op). - const smoothActive = smoothOn && (smoothTcells > 0 || smoothFcells > 0); - const smoothParams = { - smooth: smoothActive ? 1 : 0, - smooth_t_cells: smoothActive ? smoothTcells : 0, - smooth_f_cells: smoothActive ? smoothFcells : 0, - }; - const specParams = { - slice_duration: specSliceMs / 1000, max_columns: 1000, fmin, fmax, smoothing, - denoise: denoiseOn ? 1 : 0, - coherence_min: coherenceMin, - power_floor_k: powerGate > 0 ? 1.0 : 0, - floor_percentile: powerGate, - ...smoothParams, - }; + // frequency's power over time (power_floor_k=1 × that percentile). + const rawRot = rotFetchParams({ + specSliceMs, fmin, fmax, smoothing, coherenceMin, powerGate, nGate, + smoothOn, smoothTcells, smoothFcells, + }); + // Optional analysis cut-off at the Ip flattop end (global toggle in the strip). + const cutFlattop = useStore((s) => s.cutFlattop); + const specParams = withFlattopCut(rawRot.spec, cutFlattop); + const modeParams = withFlattopCut(rawRot.mode, cutFlattop); + const trackParams = withFlattopCut({}, cutFlattop); // Fetch main spectrogram node (real log-power Ḃp(t,f) from the live backend) const { @@ -208,10 +212,15 @@ export default function RotatingTab({ machine }: { machine: string }) { // Real toroidal mode-number map n(t,f) — a full-array fit per cell (resolves n=1,2,3,4… // that the 2-point estimate aliases away). Backs the "Mode n" toggle, gated server-side. // Honors the same resolution knob + band as the power view so the two stay consistent. - const { node: modeNumberNode, error: modeNumberError } = useNode(machine, "mode_number", { - slice_duration: specSliceMs / 1000, fmin, fmax, n_amp_pct: powerGate, n_gate: nGate, - ...smoothParams, - }); + const { node: modeNumberNode, error: modeNumberError } = useNode(machine, "mode_number", modeParams); + + // Publish the spectral params so the Compare view draws this same spectrogram / n-map. + // Keyed by value (both objects are rebuilt every render). + const rotParamsKey = JSON.stringify([specParams, modeParams]); + useEffect(() => { + const [spec, mode] = JSON.parse(rotParamsKey) as [Record, Record]; + setRotParams({ spec, mode }); + }, [rotParamsKey, setRotParams]); // Real 2-point coherence γ²(t,f) ∈ [0,1] — feeds the coherence gate honestly, // instead of the previous power-derived stand-in. @@ -242,13 +251,18 @@ export default function RotatingTab({ machine }: { machine: string }) { // Cursor-independent (reference is the strongest-mode slice), so no time param. const { node: modeTrackNode, - } = useNode(machine, "mode_track"); + } = useNode(machine, "mode_track", trackParams); // Fetch the best-fit toroidal mode number n(t) over the shot (appears/persists/locks). // Cursor-independent (global dominant frequency), so no time param. const { node: modeOverTimeNode, - } = useNode(machine, "mode_over_time"); + } = useNode(machine, "mode_over_time", trackParams); + + // Rotating-mode amplitude vs time, one trace per toroidal |n| (same slices as n(t)). + const { + node: modeAmplitudeNode, + } = useNode(machine, "mode_amplitude", trackParams); // Array wave-stripes: raw δBp(φ,t) / δBp(θ,t) over a few mode periods at the cursor. const { node: toroidalStripesNode } = useNode(machine, "toroidal_stripes", { time: cursorMs }); @@ -742,50 +756,14 @@ export default function RotatingTab({ machine }: { machine: string }) { return null; } - const colorscale: [number, string][] = processedSpecNode.discrete - ? (() => { - const n = MODE_PALETTE.length; - const s: [number, string][] = []; - for (let i = 0; i < n; i++) { - s.push([i / n, MODE_PALETTE[i]], [(i + 1) / n, MODE_PALETTE[i]]); - } - return s; - })() - : POWER_SEQUENTIAL; - const zr = processedSpecNode.zrange; - - const data = [ - { - type: "heatmap" as const, - x: processedSpecNode.x, - y: processedSpecNode.y, - z: processedSpecNode.z, - colorscale, - zmin: zr?.[0], - zmax: zr?.[1], - zsmooth: (processedSpecNode.discrete ? false : "best") as false | "best" | "fast" | undefined, - colorbar: { - title: { text: processedSpecNode.axes.z ?? "" }, - thickness: 12, - outlinewidth: 0, - ...(processedSpecNode.discrete - ? { - // one tick per integer mode-number magnitude |n| = 0 … 6 - tickvals: Array.from({ length: 7 }, (_, i) => i), - ticktext: Array.from({ length: 7 }, (_, i) => `${i}`), - tickmode: "array" as const, - } - : {}), - }, - }, - ]; + const data = [spectrogramTrace(processedSpecNode)]; const layout = { // Preserve the user's zoom/crop across every re-render (slider, toggle, scrub) — // Plotly only resets the view when uirevision changes, which we tie to the band, so // editing f_min/f_max intentionally re-frames while everything else keeps the crop. uirevision: `${machine}:${fmin}:${fmax}`, - xaxis: { title: { text: processedSpecNode.axes.x } }, + xaxis: { title: { text: processedSpecNode.axes.x }, ...sharedXAxis(timeRange) }, // Pin the band to the knobs so the n-map (mostly null above the modes) doesn't // autorange-trim to ~30 kHz — both views show the full requested 0–fmax band. yaxis: { title: { text: processedSpecNode.axes.y }, range: [fmin, fmax] as [number, number] }, @@ -823,7 +801,7 @@ export default function RotatingTab({ machine }: { machine: string }) { // Fill the card: subtract its 12px padding (×2), ~38px header and the 8px gap, so the // plot — and its x-axis title — fit instead of overflowing and getting clipped. const plotH = Math.max(220, specHeight - 78); - return ; + return ; }; const renderSubInterval = () => { @@ -1072,6 +1050,50 @@ export default function RotatingTab({ machine }: { machine: string }) { ); }; + // A time-track card (n(t), persistence, per-n amplitude): like analysisCard, but on the + // global shared time axis with the time cursor, click-to-set-cursor, and an optional + // palette/legend (per-|n| traces use the n-map colors). + const timeCard = ( + title: string, + node: Node | null, + subtitle: string, + download: { machine: string; nodeId: string }, + opts?: { palette?: string[]; legend?: string }, + ) => { + if (!node || node.kind !== "line") return null; + const n = node as LineNode; + const layout = { + uirevision: `${machine}:${download.nodeId}`, + xaxis: { title: { text: n.axes.x }, ...sharedXAxis(timeRange) }, + yaxis: { title: { text: n.axes.y } }, + showlegend: !!opts?.legend, + ...(opts?.legend ? { + legend: { title: { text: opts.legend }, orientation: "h" as const, x: 0, y: 1.02, yanchor: "bottom" as const, font: { size: 10 } }, + } : {}), + margin: { t: opts?.legend ? 28 : 10, b: 40, l: 60, r: 16 }, + shapes: [{ + type: "line" as const, xref: "x" as const, yref: "paper" as const, + x0: cursorMs, x1: cursorMs, y0: 0, y1: 1, + line: { color: dark ? "#2ee6cf" : "#0a8d80", width: 1.5, dash: "dash" as const }, + }], + }; + const onClick = (e: Plotly.PlotMouseEvent) => { + const x = e.points?.[0]?.x; + if (typeof x === "number") setCursorMs(x); + }; + return ( +
+

+ {title} + · {subtitle} +

+ +
+ ); + }; + const shapeMeta = (n: Node | null) => (n?.meta as Record | undefined); return ( @@ -1682,12 +1704,12 @@ export default function RotatingTab({ machine }: { machine: string }) { )} - {analysisCard("Mode Persistence", modeTrackNode, "line", 200, + {timeCard("Mode Persistence", modeTrackNode, shapeMeta(modeTrackNode)?.dominant_n != null ? `shape similarity to the dominant mode vs time (1 = persists) · n≈${shapeMeta(modeTrackNode)!.dominant_n}` : "shape similarity to the dominant mode vs time", { machine, nodeId: "mode_track" })} - {analysisCard("Toroidal Mode vs Time", modeOverTimeNode, "line", 200, + {timeCard("Toroidal Mode vs Time", modeOverTimeNode, shapeMeta(modeOverTimeNode)?.dominant_n != null ? `n of the strongest mode (freq follows the ridge${ Array.isArray(shapeMeta(modeOverTimeNode)?.f_range_kHz) @@ -1695,6 +1717,10 @@ export default function RotatingTab({ machine }: { machine: string }) { : ""}) · dominant n≈${shapeMeta(modeOverTimeNode)!.dominant_n}` : "best-fit toroidal n over time", { machine, nodeId: "mode_over_time" })} + {timeCard("Mode Amplitude vs Time", modeAmplitudeNode, + `one trace per toroidal |n| (n-map colors) · probe-averaged |δḂp| at each n's strongest in-band frequency${ + shapeMeta(modeAmplitudeNode)?.n_probes != null ? `, ${shapeMeta(modeAmplitudeNode)!.n_probes} probes` : ""}`, + { machine, nodeId: "mode_amplitude" }, { palette: MODE_PALETTE, legend: "|n|" })} ); diff --git a/gui/web/src/lib/Plot.tsx b/gui/web/src/lib/Plot.tsx index 9c46758..a0c879b 100644 --- a/gui/web/src/lib/Plot.tsx +++ b/gui/web/src/lib/Plot.tsx @@ -12,12 +12,20 @@ import { plotChrome } from "./colormaps"; import { nodeDownloadUrl } from "./api"; import { useStore } from "../store"; +// Plotly's own double-click window is 300 ms; hold single clicks just past it. +const DBLCLICK_MS = 320; + export interface PlotProps { data: Partial[]; layout?: Partial; height?: number; onClick?: (e: Plotly.PlotMouseEvent) => void; onRelayout?: (e: Record) => void; + /** Plotly double-click (fires before Plotly's own reset/autosize relayout). When + * set, `onClick` is held for DBLCLICK_MS and dropped if a double-click follows: a + * click handler that re-renders the plot (e.g. moving the time cursor) would + * otherwise redraw it between the two clicks and Plotly never sees the double. */ + onDoubleClick?: () => void; /** Per-plot Plotly config overrides (e.g. scrollZoom, displayModeBar). */ config?: Partial; /** Base filename for the PNG/SVG image exports (e.g. "shot_190000_amplitude"); @@ -52,9 +60,13 @@ function baseLayout(theme: "dark" | "light", fontScale: number): Partial(null); + const clickTimer = useRef | undefined>(undefined); + // The in-flight Plotly.react: unmount purges only after it settles — purging mid-draw + // throws inside Plotly (e.g. a plot unmounted right as it re-renders). + const drawing = useRef>(Promise.resolve()); const [hover, setHover] = useState(false); const theme = useStore((s) => s.theme); const fontScale = useStore((s) => s.fontScale); @@ -71,13 +83,19 @@ export default function Plot({ yaxis: { ...base.yaxis, ...(layout?.yaxis as object) }, }; try { - Plotly.react(el, data as Plotly.Data[], merged, { displayModeBar: false, responsive: true, ...config }); - const clickHandler = (e: Plotly.PlotMouseEvent) => onClick?.(e); + drawing.current = Plotly.react(el, data as Plotly.Data[], merged, { displayModeBar: false, responsive: true, ...config }); + const clickHandler = (e: Plotly.PlotMouseEvent) => { + if (!onDoubleClick) { onClick?.(e); return; } + clearTimeout(clickTimer.current); + clickTimer.current = setTimeout(() => onClick?.(e), DBLCLICK_MS); + }; const relayoutHandler = (e: Record) => onRelayout?.(e); // @ts-expect-error plotly event typing is loose on the dist build if (onClick) el.on("plotly_click", clickHandler); // @ts-expect-error plotly event typing is loose on the dist build if (onRelayout) el.on("plotly_relayout", relayoutHandler); + // @ts-expect-error plotly event typing is loose on the dist build + if (onDoubleClick) el.on("plotly_doubleclick", () => { clearTimeout(clickTimer.current); onDoubleClick(); }); } catch { /* transient Plotly layout race (e.g. StrictMode remount); next render re-syncs */ } @@ -87,20 +105,27 @@ export default function Plot({ el.removeAllListeners?.("plotly_click"); // @ts-expect-error plotly cleanup helper is untyped on the dist build el.removeAllListeners?.("plotly_relayout"); + // @ts-expect-error plotly cleanup helper is untyped on the dist build + el.removeAllListeners?.("plotly_doubleclick"); } catch { /* noop */ } }; - }, [data, layout, height, onClick, onRelayout, theme, fontScale, config]); + }, [data, layout, height, onClick, onRelayout, onDoubleClick, theme, fontScale, config]); useEffect(() => { const el = ref.current; + const timer = clickTimer; + const draw = drawing; return () => { - try { - if (el) Plotly.purge(el); - } catch { - /* noop */ - } + clearTimeout(timer.current); // no deferred click after unmount + void draw.current.catch(() => {}).then(() => { + try { + if (el) Plotly.purge(el); + } catch { + /* noop */ + } + }); }; }, []); diff --git a/gui/web/src/lib/annotations.test.ts b/gui/web/src/lib/annotations.test.ts new file mode 100644 index 0000000..e5de97b --- /dev/null +++ b/gui/web/src/lib/annotations.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "vitest"; +import { annotationsToPlotly, parseAnnotations, type Annotation } from "./annotations"; + +const axisOf = { spec: "y", amplitude: "y2" }; + +test("vline and span are time-anchored and span the full figure height", () => { + const list: Annotation[] = [ + { id: "a", kind: "vline", t: 1500, label: "LM onset", color: "#e0533d" }, + { id: "b", kind: "span", t0: 1600, t1: 1700 }, + ]; + const { shapes, annotations, traces } = annotationsToPlotly(list, axisOf, "#fff"); + expect(shapes[0]).toMatchObject({ type: "line", xref: "x", yref: "paper", x0: 1500, x1: 1500, y0: 0, y1: 1 }); + expect(shapes[0].line?.color).toBe("#e0533d"); + expect(shapes[1]).toMatchObject({ type: "rect", xref: "x", yref: "paper", x0: 1600, x1: 1700, layer: "below" }); + expect(annotations).toHaveLength(1); // only the labelled vline gets a text label + expect(annotations[0]).toMatchObject({ x: 1500, text: "LM onset" }); + expect(traces).toHaveLength(0); +}); + +test("hline and point attach to their panel's y axis; hidden panels are skipped", () => { + const list: Annotation[] = [ + { id: "a", kind: "hline", panel: "amplitude", y: 3.5, label: "threshold" }, + { id: "b", kind: "point", panel: "spec", t: 2000, y: 12.5 }, + { id: "c", kind: "point", panel: "phase", t: 2000, y: 0 }, // phase panel not shown + ]; + const { shapes, annotations, traces } = annotationsToPlotly(list, axisOf, "#fff"); + expect(shapes).toHaveLength(1); + expect(shapes[0]).toMatchObject({ xref: "paper", yref: "y2", y0: 3.5, y1: 3.5, x0: 0, x1: 1 }); + expect(annotations[0]).toMatchObject({ yref: "y2", text: "threshold" }); + expect(traces).toHaveLength(1); + expect(traces[0]).toMatchObject({ xaxis: "x", yaxis: "y", x: [2000], y: [12.5], mode: "markers" }); +}); + +test("parseAnnotations validates untrusted JSON", () => { + const out = parseAnnotations([ + { id: "ok", kind: "vline", t: 1 }, + { kind: "span", t0: 5, t1: 2, color: "#123456" }, // reversed edges, no id + { kind: "hline", panel: "nope", y: 1 }, // unknown panel + { kind: "point", panel: "phase", t: 1 }, // missing y + { kind: "vline", t: "1" }, // wrong type + { kind: "vline", t: 2, color: "red; x" }, // bad color dropped + null, 7, + ]); + expect(out).toHaveLength(3); + expect(out[0]).toEqual({ id: "ok", kind: "vline", t: 1 }); + expect(out[1]).toMatchObject({ kind: "span", t0: 2, t1: 5, color: "#123456" }); + expect(out[1].id).toBeTruthy(); + expect(out[2]).not.toHaveProperty("color"); + expect(parseAnnotations({ not: "an array" })).toEqual([]); +}); diff --git a/gui/web/src/lib/annotations.ts b/gui/web/src/lib/annotations.ts new file mode 100644 index 0000000..cc20bdb --- /dev/null +++ b/gui/web/src/lib/annotations.ts @@ -0,0 +1,148 @@ +// User-placed plot annotations for the Compare view: vertical time markers, shaded +// time spans, horizontal reference levels and labelled points. Pure data + pure +// mapping to Plotly shapes/annotations/traces, so the geometry is unit-testable and +// the store only holds plain JSON (persisted to localStorage, exportable as a file). +import type Plotly from "plotly.js-dist-min"; + +/** Panels of the Compare figure — the target of a panel-anchored annotation. The fixed + * analysis panels, plus one `sig:` panel per plasma signal (Ip, Bt, κ, …). */ +export type FixedPanelId = "spec" | "mode_over_time" | "mode_amplitude" | "phi_t" | "amplitude" | "phase"; +export type PanelId = FixedPanelId | `sig:${string}`; + +export const PANEL_IDS: FixedPanelId[] = ["spec", "mode_over_time", "mode_amplitude", "phi_t", "amplitude", "phase"]; + +interface Base { + id: string; + label?: string; + color?: string; // literal hex — Plotly doesn't resolve CSS custom properties +} +export type Annotation = + | (Base & { kind: "vline"; t: number }) + | (Base & { kind: "span"; t0: number; t1: number }) + | (Base & { kind: "hline"; panel: PanelId; y: number }) + | (Base & { kind: "point"; panel: PanelId; t: number; y: number }); + +export type AnnotationKind = Annotation["kind"]; +/** An annotation before the store assigns its id (distributes over the union). */ +export type AnnotationInput = Annotation extends infer A ? (A extends Annotation ? Omit : never) : never; +/** An in-place edit: any position / text field; `kind` and `id` never change. */ +export type AnnotationPatch = Partial<{ + t: number; t0: number; t1: number; y: number; panel: PanelId; label: string; color: string; +}>; + +// Readable on both the dark and the light plot background. +export const ANNOTATION_COLORS = ["#e8a33c", "#e0533d", "#4c9be8", "#3fb56b", "#a86bd1", "#8a8f98"]; + +export const DEFAULT_COLOR: Record = { + vline: ANNOTATION_COLORS[0], + span: ANNOTATION_COLORS[2], + hline: ANNOTATION_COLORS[3], + point: ANNOTATION_COLORS[1], +}; + +export function newAnnotationId(): string { + return typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `a${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; +} + +const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); +const isPanel = (v: unknown): v is PanelId => + PANEL_IDS.includes(v as FixedPanelId) || (typeof v === "string" && /^sig:[A-Za-z0-9_]+$/.test(v)); + +/** Validate untrusted JSON (localStorage / an imported file) into annotations. + * Invalid entries are dropped, not thrown on; missing ids are regenerated. */ +export function parseAnnotations(raw: unknown): Annotation[] { + if (!Array.isArray(raw)) return []; + const out: Annotation[] = []; + for (const r of raw) { + if (!r || typeof r !== "object") continue; + const o = r as Record; + const base: Base = { + id: typeof o.id === "string" && o.id ? o.id : newAnnotationId(), + ...(typeof o.label === "string" && o.label ? { label: o.label } : {}), + ...(typeof o.color === "string" && /^#[0-9a-fA-F]{6}$/.test(o.color) ? { color: o.color } : {}), + }; + if (o.kind === "vline" && isNum(o.t)) out.push({ ...base, kind: "vline", t: o.t }); + else if (o.kind === "span" && isNum(o.t0) && isNum(o.t1)) + out.push({ ...base, kind: "span", t0: Math.min(o.t0, o.t1), t1: Math.max(o.t0, o.t1) }); + else if (o.kind === "hline" && isPanel(o.panel) && isNum(o.y)) + out.push({ ...base, kind: "hline", panel: o.panel, y: o.y }); + else if (o.kind === "point" && isPanel(o.panel) && isNum(o.t) && isNum(o.y)) + out.push({ ...base, kind: "point", panel: o.panel, t: o.t, y: o.y }); + } + return out; +} + +export interface AnnotationLayers { + shapes: Partial[]; + annotations: Partial[]; + traces: Partial[]; +} + +/** + * Map annotations onto a (possibly multi-panel) Plotly figure. + * `axisOf` maps each visible panel to its y-axis id ("y", "y2", …); annotations on a + * hidden panel are skipped. Time-anchored kinds (vline, span) span the whole figure + * height so a marker reads across every stacked panel at once. + */ +export function annotationsToPlotly( + list: Annotation[], + axisOf: Partial>, + textColor: string, +): AnnotationLayers { + const shapes: Partial[] = []; + const annotations: Partial[] = []; + const traces: Partial[] = []; + const font = { color: textColor, size: 10 }; + + for (const a of list) { + const color = a.color ?? DEFAULT_COLOR[a.kind]; + if (a.kind === "vline") { + shapes.push({ + type: "line", xref: "x", yref: "paper", x0: a.t, x1: a.t, y0: 0, y1: 1, + line: { color, width: 1.5 }, + }); + if (a.label) + annotations.push({ + x: a.t, xref: "x", y: 1, yref: "paper", yanchor: "bottom", text: a.label, + showarrow: false, font: { ...font, color }, + }); + } else if (a.kind === "span") { + shapes.push({ + type: "rect", xref: "x", yref: "paper", x0: a.t0, x1: a.t1, y0: 0, y1: 1, + fillcolor: color, opacity: 0.18, layer: "below", line: { width: 0 }, + }); + if (a.label) + annotations.push({ + x: (a.t0 + a.t1) / 2, xref: "x", y: 1, yref: "paper", yanchor: "bottom", text: a.label, + showarrow: false, font: { ...font, color }, + }); + } else { + const yref = axisOf[a.panel]; + if (!yref) continue; + if (a.kind === "hline") { + shapes.push({ + type: "line", xref: "paper", yref: yref as Plotly.YAxisName, x0: 0, x1: 1, y0: a.y, y1: a.y, + line: { color, width: 1.5, dash: "dot" }, + }); + if (a.label) + annotations.push({ + x: 1, xref: "paper", xanchor: "right", y: a.y, yref: yref as Plotly.YAxisName, + yanchor: "bottom", text: a.label, showarrow: false, font: { ...font, color }, + }); + } else { + traces.push({ + type: "scatter", mode: a.label ? "text+markers" : "markers", + x: [a.t], y: [a.y], xaxis: "x", yaxis: yref, + text: a.label ? [a.label] : undefined, textposition: "top center", + textfont: { ...font, color }, + marker: { color, size: 9, symbol: "diamond", line: { color: textColor, width: 1 } }, + name: a.label || "point", showlegend: false, + hovertemplate: `${a.label ? a.label + "
" : ""}t=%{x:.2f} ms, y=%{y:.4g}`, + } as Partial); + } + } + } + return { shapes, annotations, traces }; +} diff --git a/gui/web/src/lib/compareLayout.test.ts b/gui/web/src/lib/compareLayout.test.ts new file mode 100644 index 0000000..72a9d0c --- /dev/null +++ b/gui/web/src/lib/compareLayout.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, test } from "vitest"; +import { + axisId, buildCompareFigure, movePanel, orderPanels, panelDomains, panelForAxis, timeExtents, type PanelSpec, +} from "./compareLayout"; +import { PLOT_CHROME } from "./colormaps"; + +const chrome = PLOT_CHROME.dark; +const line = (x: number[]) => ({ type: "scatter" as const, x, y: x.map(() => 0), name: "1" }); +const heat = (x: number[]) => ({ type: "heatmap" as const, x, y: [0, 1], z: [x, x], colorbar: { thickness: 12 } }); + +test("panelDomains stacks top → bottom, sized by weight, with gaps, within [0, 1]", () => { + for (const n of [1, 2, 3, 4, 5]) { + const d = panelDomains(Array(n).fill(1), 0.05); + expect(d).toHaveLength(n); + expect(d[0][1]).toBe(1); + expect(d[n - 1][0]).toBeCloseTo(0, 6); + for (let i = 1; i < n; i++) expect(d[i - 1][0] - d[i][1]).toBeCloseTo(0.05, 6); // gap + } + const [a, b] = panelDomains([2, 1], 0); + expect(a[1] - a[0]).toBeCloseTo(2 * (b[1] - b[0]), 5); + expect(panelDomains([])).toEqual([]); +}); + +test("timeExtents: union and overlap of the series' time ranges", () => { + expect(timeExtents([[0, 5, 10], [4, 20], undefined, []])).toEqual({ union: [0, 20], overlap: [4, 10] }); + expect(timeExtents([[0, 1], [2, 3]]).overlap).toBeNull(); // disjoint + expect(timeExtents([])).toEqual({ union: null, overlap: null }); +}); + +test("buildCompareFigure puts every panel on one shared x axis with its own y axis", () => { + const panels: PanelSpec[] = [ + { id: "spec", traces: [heat([0, 10])], yaxis: { title: { text: "f" } }, weight: 2 }, + { id: "amplitude", traces: [line([5, 15])], yaxis: {}, legend: "n" }, + { id: "phase", traces: [line([5, 15])], yaxis: { range: [-180, 180] } }, + ]; + const fig = buildCompareFigure(panels, { chrome, xTitle: "time (ms)", xRange: [2, 8] }); + expect(fig.axisOf).toEqual({ spec: "y", amplitude: "y2", phase: "y3" }); + expect(fig.data.map((t) => [t.xaxis, t.yaxis])).toEqual([["x", "y"], ["x", "y2"], ["x", "y3"]]); + const L = fig.layout as Record; + expect(L.yaxis.anchor).toBe("x"); + expect(L.yaxis3.range).toEqual([-180, 180]); + // x axis hangs off the bottom panel, carries the shared range + expect(L.xaxis.anchor).toBe("y3"); + expect(L.xaxis.range).toEqual([2, 8]); + // heatmap colorbar is confined to its own panel's domain + const cb = (fig.data[0] as { colorbar: { y: number; len: number; thickness: number } }).colorbar; + const [lo, hi] = L.yaxis.domain!; + expect(cb.y).toBeCloseTo((lo + hi) / 2, 6); + expect(cb.len).toBeCloseTo(hi - lo, 6); + expect(cb.thickness).toBe(12); + // only the legend panel contributes legend entries + expect(fig.layout.showlegend).toBe(true); + expect(fig.data[2].showlegend).toBe(false); + expect(fig.data[1].showlegend).toBeUndefined(); +}); + +test("no xRange → autorange; panelForAxis maps a clicked trace's y axis back to its panel", () => { + const fig = buildCompareFigure( + [{ id: "phi_t", traces: [line([0, 1])], yaxis: {} }, { id: "phase", traces: [line([0, 1])], yaxis: {} }], + { chrome, xTitle: "t" }, + ); + expect((fig.layout.xaxis as { autorange?: boolean }).autorange).toBe(true); + expect(panelForAxis(fig.axisOf, axisId(1))).toBe("phase"); + expect(panelForAxis(fig.axisOf, undefined)).toBe("phi_t"); + expect(panelForAxis(fig.axisOf, "y9")).toBeNull(); +}); + +describe("panel reordering", () => { + it("sorts by the saved order, unknown panels after in default order", () => { + expect(orderPanels(["a", "b", "c", "d"], ["c", "a"])).toEqual(["c", "a", "b", "d"]); + expect(orderPanels(["a", "b"], [])).toEqual(["a", "b"]); + }); + it("moves a panel and keeps hidden panels' saved slots", () => { + const order = movePanel(["a", "b", "c"], ["x", "a"], "c", 0); + expect(order).toEqual(["c", "a", "b", "x"]); + expect(orderPanels(["a", "b", "c", "x"], order)).toEqual(["c", "a", "b", "x"]); + expect(movePanel(["a", "b", "c"], [], "a", 99)).toEqual(["b", "c", "a"]); + expect(movePanel(["a"], ["a"], "zz", 0)).toEqual(["a"]); + }); +}); diff --git a/gui/web/src/lib/compareLayout.ts b/gui/web/src/lib/compareLayout.ts new file mode 100644 index 0000000..15bf58f --- /dev/null +++ b/gui/web/src/lib/compareLayout.ts @@ -0,0 +1,157 @@ +// Pure figure builder for the Compare view: the rotating-mode and quasi-stationary +// main plots stacked as vertical panels of ONE Plotly figure that share a single +// time x-axis. Because every panel is a y-axis on the same `xaxis`, a zoom/pan in any +// panel moves them all, and time-anchored annotations (lines, spans) draw straight +// through every panel. Kept free of React so the axis/domain math is unit-testable. +import type Plotly from "plotly.js-dist-min"; +import type { PanelId } from "./annotations"; +import type { PlotChrome } from "./colormaps"; + +export interface PanelSpec { + id: PanelId; + traces: Partial[]; + yaxis: Partial; + /** Relative height (default 1). */ + weight?: number; + /** Show a legend (with this title) for this panel's named traces. Plotly has one + * legend, so at most one panel should set it. */ + legend?: string; +} + +/** Plotly's id for the k-th (0-based) y-axis: "y", "y2", "y3", … */ +export const axisId = (k: number): string => (k === 0 ? "y" : `y${k + 1}`); +/** The matching layout key: "yaxis", "yaxis2", … */ +export const axisKey = (k: number): string => (k === 0 ? "yaxis" : `yaxis${k + 1}`); + +/** Vertical [lo, hi] paper domains for panels listed top → bottom, sized by weight + * and separated by `gap` (paper fraction). */ +export function panelDomains(weights: number[], gap = 0.035): [number, number][] { + const n = weights.length; + if (!n) return []; + const usable = Math.max(0, 1 - gap * (n - 1)); + const total = weights.reduce((a, w) => a + Math.max(w, 0), 0) || n; + const out: [number, number][] = []; + let top = 1; + for (const w of weights) { + const h = (usable * (Math.max(w, 0) || 0)) / total; + const lo = Math.max(0, top - h); + out.push([round(lo), round(top)]); + top = lo - gap; + } + return out; +} +const round = (v: number) => Math.round(v * 1e6) / 1e6; + +/** First/last x of every non-empty series → [min start, max end] (union) and + * [max start, min end] (overlap; null when the ranges don't intersect). */ +export function timeExtents(xs: (number[] | undefined)[]): { + union: [number, number] | null; + overlap: [number, number] | null; +} { + const r = xs.filter((x): x is number[] => !!x && x.length > 0).map((x) => [x[0], x[x.length - 1]] as const); + if (!r.length) return { union: null, overlap: null }; + const lo = r.map(([a, b]) => Math.min(a, b)); + const hi = r.map(([a, b]) => Math.max(a, b)); + const union: [number, number] = [Math.min(...lo), Math.max(...hi)]; + const o: [number, number] = [Math.max(...lo), Math.min(...hi)]; + return { union, overlap: o[0] < o[1] ? o : null }; +} + +export interface CompareFigure { + data: Partial[]; + layout: Partial; + /** Visible panel → its y-axis id (for annotations and click → panel lookup). */ + axisOf: Partial>; +} + +export function buildCompareFigure( + panels: PanelSpec[], + opts: { chrome: PlotChrome; xTitle: string; xRange?: [number, number] | null; uirevision?: string }, +): CompareFigure { + const domains = panelDomains(panels.map((p) => p.weight ?? 1)); + const axisStyle = { + gridcolor: opts.chrome.gridcolor, + zerolinecolor: opts.chrome.zerolinecolor, + linecolor: opts.chrome.linecolor, + ticks: "outside" as const, + tickcolor: opts.chrome.tickcolor, + automargin: true, + }; + const data: Partial[] = []; + const layout: Record = { + uirevision: opts.uirevision, + hovermode: "x", + showlegend: panels.some((p) => p.legend != null), + margin: { l: 70, r: 110, t: 24, b: 48 }, + }; + const axisOf: Partial> = {}; + + panels.forEach((p, k) => { + const yid = axisId(k); + const [lo, hi] = domains[k]; + axisOf[p.id] = yid; + layout[axisKey(k)] = { ...axisStyle, ...p.yaxis, domain: [lo, hi], anchor: "x" }; + for (const t of p.traces) { + const tr: Record = { ...t, xaxis: "x", yaxis: yid }; + // Heatmap colorbars sit beside their own panel instead of spanning the figure. + if (t.type === "heatmap" && t.showscale !== false) { + tr.colorbar = { + ...(t.colorbar as object), + y: (lo + hi) / 2, yanchor: "middle", len: hi - lo, lenmode: "fraction", x: 1.01, + }; + } + // Only the legend panel contributes legend entries. + if (p.legend == null) tr.showlegend = false; + data.push(tr as Partial); + } + if (p.legend != null) { + layout.legend = { + x: 1.01, xanchor: "left", y: hi, yanchor: "top", font: { size: 10 }, traceorder: "normal", + title: { text: p.legend, font: { size: 10 } }, + }; + } + }); + + // The x axis hangs off the bottom panel so its ticks/title sit under the stack; + // spikes give a cross-panel time read-out at the mouse. + layout.xaxis = { + ...axisStyle, + anchor: panels.length ? axisId(panels.length - 1) : "y", + title: { text: opts.xTitle }, + showspikes: true, spikemode: "across", spikesnap: "cursor", spikethickness: 1, spikedash: "dot", + ...(opts.xRange ? { range: opts.xRange, autorange: false } : { autorange: true }), + }; + return { data, layout: layout as Partial, axisOf }; +} + +/** Which panel a Plotly click landed in, from the clicked trace's y-axis id. */ +export function panelForAxis(axisOf: Partial>, yaxis: string | undefined): PanelId | null { + const id = yaxis ?? "y"; + for (const [panel, ax] of Object.entries(axisOf)) if (ax === id) return panel as PanelId; + return null; +} + +// ── User panel order (Compare "reorder") ───────────────────────────── +// `order` is the user's saved top→bottom sequence of panel ids. Panels it doesn't +// mention (never reordered, or new — e.g. a newly selected plasma signal) keep their +// default relative order and go after the ordered ones. + +/** `ids` (default order) re-sorted by the user's `order`. */ +export function orderPanels(ids: T[], order: string[]): T[] { + const rank = (id: T, i: number) => { + const k = order.indexOf(id); + return k >= 0 ? k : order.length + i; + }; + return ids.map((id, i) => [id, rank(id, i)] as const).sort((a, b) => a[1] - b[1]).map(([id]) => id); +} + +/** New saved order after moving `id` to position `to` among the `shown` panels + * (already in display order). Hidden panels keep their saved slots at the end so + * they return where they were when re-enabled. */ +export function movePanel(shown: string[], order: string[], id: string, to: number): string[] { + const from = shown.indexOf(id); + if (from < 0) return order; + const next = shown.filter((p) => p !== id); + next.splice(Math.max(0, Math.min(next.length, to)), 0, id); + return [...next, ...order.filter((p) => !next.includes(p))]; +} diff --git a/gui/web/src/lib/flattop.test.ts b/gui/web/src/lib/flattop.test.ts new file mode 100644 index 0000000..13580ec --- /dev/null +++ b/gui/web/src/lib/flattop.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { withFlattopCut } from "./flattop"; + +describe("withFlattopCut", () => { + it("adds the flag when on and strips a stale one when off", () => { + expect(withFlattopCut({ fmin: 0 }, true)).toEqual({ fmin: 0, cut_flattop: 1 }); + expect(withFlattopCut({ fmin: 0, cut_flattop: 1 }, false)).toEqual({ fmin: 0 }); + expect(withFlattopCut({ ns: "1,2" }, false)).toEqual({ ns: "1,2" }); + }); +}); diff --git a/gui/web/src/lib/flattop.ts b/gui/web/src/lib/flattop.ts new file mode 100644 index 0000000..58b1bb3 --- /dev/null +++ b/gui/web/src/lib/flattop.ts @@ -0,0 +1,12 @@ +// The "stop the analysis at the Ip flattop end" option (store.cutFlattop): every +// analysis fetch carries `cut_flattop=1` when it's on, and the backend ends the QS fit +// window, the rotating tracks and the (t, f) maps at the flattop end it finds from Ip. + +type Params = Record; + +/** `params` with `cut_flattop` set when `on`, and removed when not (params published + * by another tab may already carry it). */ +export function withFlattopCut

(params: P, on: boolean): P { + const { cut_flattop: _drop, ...rest } = params; // eslint-disable-line @typescript-eslint/no-unused-vars + return (on ? { ...rest, cut_flattop: 1 } : rest) as unknown as P; +} diff --git a/gui/web/src/lib/plotTraces.ts b/gui/web/src/lib/plotTraces.ts new file mode 100644 index 0000000..21fc819 --- /dev/null +++ b/gui/web/src/lib/plotTraces.ts @@ -0,0 +1,135 @@ +// Shared Plotly trace builders for the main analysis plots, so the Quasi-stationary, +// Rotating and Compare views draw the same node identically (one place to restyle). +import type Plotly from "plotly.js-dist-min"; +import type { ContourNode, HeatmapNode, LineNode } from "./contract"; +import { MODE_PALETTE, POWER_SEQUENTIAL } from "./colormaps"; + +// ── Colorblind-safe palette (Wong 2011) — for sensor/channel traces ── +export const LINE_PALETTE = ["#0072B2", "#E69F00", "#56B4E9", "#D55E00", "#CC79A7", "#009E73", "#F0E442"]; + +// ── Quasi-stationary mode-number palette — green/purple/red for n=1,2,3,… ── +// Clearly distinct hues so each mode reads immediately, not blue/orange. +export const QS_MODE_PALETTE = ["#2ca02c", "#9467bd", "#d62728", "#8c564b", "#e377c2", "#bcbd22", "#17becf"]; + +export function hexToRgba(hex: string, alpha: number): string { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r},${g},${b},${alpha})`; +} + +// Build Plotly traces from a LineNode, optionally with per-series visibility overrides. +export function lineTraces( + node: LineNode, + opts?: { visible?: boolean[]; opacity?: number[]; palette?: string[] }, +): Partial[] { + const pal = opts?.palette ?? LINE_PALETTE; + const traces: Partial[] = []; + + node.series.forEach((s, i) => { + const color = pal[i % pal.length]; + const vis = opts?.visible?.[i] !== false; + const opacity = opts?.opacity?.[i] ?? 1; + + // ±1σ band from the contract's typed lower/upper fields (also what the + // HDF5 export writes, so the band on screen matches the downloaded data). + if (s.lower && s.upper && vis) { + traces.push({ + type: "scatter", mode: "lines", x: s.x, + y: s.upper, + line: { width: 0, color }, showlegend: false, hoverinfo: "skip", + opacity, + } as Partial); + traces.push({ + type: "scatter", mode: "lines", x: s.x, + y: s.lower, + fill: "tonexty", fillcolor: hexToRgba(color, 0.45 * opacity), + line: { width: 0, color }, showlegend: false, hoverinfo: "skip", + opacity, + } as Partial); + } + + traces.push({ + type: "scatter", mode: "lines", name: s.name, x: s.x, y: s.y, + line: { color, width: 1.5 }, + visible: vis ? true : "legendonly", + opacity, + } as Partial); + }); + + return traces; +} + +// ── φ–t contour colormaps (QS) ─────────────────────────────────────── +export type PhiColormap = "rdbu" | "cividis" | "viridis"; +export function phiColormapProps(c: PhiColormap) { + if (c === "cividis") return { colorscale: "Cividis", reversescale: false }; + if (c === "viridis") return { colorscale: "Viridis", reversescale: false }; + return { colorscale: "RdBu", reversescale: true }; // RdBu_r ≈ notebook matplotlib +} + +/** δBp(φ, t) heatmap + (optional) per-time peak-φ markers. */ +export function phiTimeTraces( + node: ContourNode, + peak: number[] | null, + cmap: PhiColormap = "rdbu", +): Partial[] { + const [zmin, zmax] = node.zrange ?? [-42, 42]; + const traces: Partial[] = [{ + type: "heatmap" as const, + x: node.x, y: node.y, z: node.z, + ...phiColormapProps(cmap), + zmin, zmax, + zsmooth: false, + showscale: true, + colorbar: { title: { text: "Fit" }, thickness: 12, outlinewidth: 0 }, + } as Partial]; + if (peak) { + traces.push({ + type: "scatter" as const, mode: "markers" as const, + x: node.x, y: peak, + marker: { symbol: "circle-open" as const, size: 4, color: "white", line: { width: 1, color: "white" } }, + hoverinfo: "skip" as const, showlegend: false, + } as Partial); + } + return traces; +} + +/** The MODESPEC spectrogram heatmap — discrete |n| palette for the mode-number map, + * sequential for log power. */ +export function spectrogramTrace(node: HeatmapNode): Partial { + const colorscale: [number, string][] = node.discrete + ? (() => { + const n = MODE_PALETTE.length; + const s: [number, string][] = []; + for (let i = 0; i < n; i++) { + s.push([i / n, MODE_PALETTE[i]], [(i + 1) / n, MODE_PALETTE[i]]); + } + return s; + })() + : POWER_SEQUENTIAL; + const zr = node.zrange; + return { + type: "heatmap" as const, + x: node.x, + y: node.y, + z: node.z, + colorscale, + zmin: zr?.[0], + zmax: zr?.[1], + zsmooth: (node.discrete ? false : "best") as false | "best" | "fast" | undefined, + colorbar: { + title: { text: node.axes.z ?? "" }, + thickness: 12, + outlinewidth: 0, + ...(node.discrete + ? { + // one tick per integer mode-number magnitude |n| = 0 … 6 + tickvals: Array.from({ length: 7 }, (_, i) => i), + ticktext: Array.from({ length: 7 }, (_, i) => `${i}`), + tickmode: "array" as const, + } + : {}), + }, + } as Partial; +} diff --git a/gui/web/src/lib/qsTransforms.ts b/gui/web/src/lib/qsTransforms.ts index 1d1ccbc..7bfbde0 100644 --- a/gui/web/src/lib/qsTransforms.ts +++ b/gui/web/src/lib/qsTransforms.ts @@ -37,3 +37,19 @@ export function phiRms(z: number[][]): number[] { } return out; } + +/** The quasi-stationary fit's starting settings, as the /api/node query params they + * become (time window omitted → the shot's own tmin/tmax). Shared by the QS tab's + * initial control values and the Compare view's "QS tab not run yet" fallback. */ +export const QS_DEFAULTS = { + ns: "1,2,3", + ms: "0", + channel_filter: "Bp LFS midplane", + detrend_type: "baseline", + sigma: "2e-5", + energy: "0.98", + fit_basis: "sinusoidal-integral", + fit_cond: "1000", // OMFIT SLCONTOUR inversion cutoff (1/rcond), not the K>10 trust threshold + cutoff_lo: "5.0", + cutoff_hi: "250.0", +} as const satisfies Record; diff --git a/gui/web/src/lib/rotatingTransforms.test.ts b/gui/web/src/lib/rotatingTransforms.test.ts index cdf9534..fd828ae 100644 --- a/gui/web/src/lib/rotatingTransforms.test.ts +++ b/gui/web/src/lib/rotatingTransforms.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import { gatePosToPct, medianStep, percentile } from "./rotatingTransforms"; +import { ROT_DEFAULTS, gatePosToPct, medianStep, percentile, rotFetchParams } from "./rotatingTransforms"; test("percentile: endpoints and linear interpolation", () => { expect(percentile([10, 20, 30, 40], 0)).toBe(10); @@ -38,3 +38,11 @@ test("gatePosToPct: monotonic, clamped, log-headroom mapping", () => { expect(gatePosToPct(5000)).toBeCloseTo(99.5, 1); // clamped above expect(gatePosToPct(500)).toBeGreaterThan(gatePosToPct(100)); // monotonic }); + +test("rotFetchParams: defaults gate on the power floor, no smoothing", () => { + const { spec, mode } = rotFetchParams(ROT_DEFAULTS); + expect(spec).toMatchObject({ slice_duration: 0.002, max_columns: 1000, fmin: 0, fmax: 50, denoise: 1, power_floor_k: 1, smooth: 0 }); + expect(mode).toMatchObject({ slice_duration: 0.002, n_gate: 0.3, n_amp_pct: ROT_DEFAULTS.powerGate, smooth: 0 }); + const off = rotFetchParams({ ...ROT_DEFAULTS, powerGate: 0, smoothOn: true }); + expect(off.spec).toMatchObject({ denoise: 0, power_floor_k: 0, smooth: 1, smooth_t_cells: 3 }); +}); diff --git a/gui/web/src/lib/rotatingTransforms.ts b/gui/web/src/lib/rotatingTransforms.ts index d1056cc..7176b81 100644 --- a/gui/web/src/lib/rotatingTransforms.ts +++ b/gui/web/src/lib/rotatingTransforms.ts @@ -42,3 +42,59 @@ export function medianStep(axis: number[] | undefined): number { const mid = Math.floor(diffs.length / 2); return diffs.length % 2 ? diffs[mid] : (diffs[mid - 1] + diffs[mid]) / 2; } + +// ── Spectral fetch params ──────────────────────────────────────────────────── +// The Rotating tab's knobs → the /api/node query params of its `spectrogram` (and +// `coherence`) and `mode_number` fetches. One builder so the Compare view's +// "not yet run" fallback requests exactly what the Rotating tab starts with. +export const GATE_POS_DEFAULT = 227; // slider position that yields ≈70% (a sensible noise floor) + +export interface RotSettings { + specSliceMs: number; // STFT slice duration (ms) + fmin: number; // band crop (kHz) + fmax: number; + smoothing: number; // coherence-estimation window + coherenceMin: number; // 2-point γ² gate (0 = off) + powerGate: number; // per-frequency power-floor percentile (0 = off) + nGate: number; // n-map mode-coherence gate + smoothOn: boolean; // 2-D Gaussian pre-smoothing + smoothTcells: number; + smoothFcells: number; +} + +export const ROT_DEFAULTS: RotSettings = { + specSliceMs: 2, fmin: 0, fmax: 50, smoothing: 5, coherenceMin: 0, + powerGate: gatePosToPct(GATE_POS_DEFAULT), nGate: 0.3, + smoothOn: false, smoothTcells: 3, smoothFcells: 1.5, +}; + +export function rotFetchParams(s: RotSettings): { spec: Record; mode: Record } { + // Server-side denoise: the coherence gate and the per-frequency power floor both run in + // the core (denoise_spectrogram) so the spectrogram, 2-point n-map, and n-spectrum all + // threshold on ONE consistent (t, f) grid. 0 on both = no-op. + const denoiseOn = s.coherenceMin > 0 || s.powerGate > 0; + // 2-D Gaussian pre-smoothing, shared by every spectral node so all views smooth on one + // basis. Effective only when the toggle is on and a σ is non-zero (else a server no-op). + const smoothActive = s.smoothOn && (s.smoothTcells > 0 || s.smoothFcells > 0); + const smoothParams = { + smooth: smoothActive ? 1 : 0, + smooth_t_cells: smoothActive ? s.smoothTcells : 0, + smooth_f_cells: smoothActive ? s.smoothFcells : 0, + }; + return { + spec: { + slice_duration: s.specSliceMs / 1000, max_columns: 1000, fmin: s.fmin, fmax: s.fmax, + smoothing: s.smoothing, + denoise: denoiseOn ? 1 : 0, + coherence_min: s.coherenceMin, + power_floor_k: s.powerGate > 0 ? 1.0 : 0, + floor_percentile: s.powerGate, + ...smoothParams, + }, + mode: { + slice_duration: s.specSliceMs / 1000, fmin: s.fmin, fmax: s.fmax, + n_amp_pct: s.powerGate, n_gate: s.nGate, + ...smoothParams, + }, + }; +} diff --git a/gui/web/src/lib/signalUnits.test.ts b/gui/web/src/lib/signalUnits.test.ts new file mode 100644 index 0000000..373f4d8 --- /dev/null +++ b/gui/web/src/lib/signalUnits.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { displayScale, signalAxisTitle } from "./signalUnits"; + +describe("displayScale", () => { + it("shows plasma current in MA and coil currents in kA", () => { + expect(displayScale("A", [0, 9.8e5, -1e3])).toEqual({ factor: 1e-6, unit: "MA" }); + expect(displayScale("A", [-1100, 2264])).toEqual({ factor: 1e-3, unit: "kA" }); + expect(displayScale("A", [3, 8])).toEqual({ factor: 1, unit: "A" }); + }); + + it("shows small fields in gauss but keeps Bt in tesla", () => { + expect(displayScale("T", [-0.0413, 0.0407])).toEqual({ factor: 1e4, unit: "G" }); + expect(displayScale("T", [-2.0, -1.9])).toEqual({ factor: 1, unit: "T" }); + }); + + it("leaves dimensionless and unknown units alone, ignoring NaN", () => { + expect(displayScale("", [1.7, NaN])).toEqual({ factor: 1, unit: "" }); + expect(displayScale(undefined, [5])).toEqual({ factor: 1, unit: "" }); + expect(displayScale("T/s", [12])).toEqual({ factor: 1, unit: "T/s" }); + }); +}); + +describe("signalAxisTitle", () => { + it("appends the unit only when there is one", () => { + expect(signalAxisTitle("ip", { factor: 1e-6, unit: "MA" })).toBe("ip (MA)"); + expect(signalAxisTitle("kappa", { factor: 1, unit: "" })).toBe("kappa"); + }); +}); diff --git a/gui/web/src/lib/signalUnits.ts b/gui/web/src/lib/signalUnits.ts new file mode 100644 index 0000000..d6fd839 --- /dev/null +++ b/gui/web/src/lib/signalUnits.ts @@ -0,0 +1,24 @@ +// Display scaling for raw plasma / coil / sensor time traces. The backend serves +// native units (A, T, T/s, or "" for dimensionless EFIT scalars like κ); this picks +// a readable display unit from the trace's magnitude (Ip 1e6 A → 1 MA, a 3D-coil +// 2e3 A → 2 kA, a 4e-3 T probe → 40 G). + +export interface DisplayScale { factor: number; unit: string } + +export function displayScale(unit: string | undefined, values: ArrayLike): DisplayScale { + let peak = 0; + for (let i = 0; i < values.length; i++) { + const v = Math.abs(values[i]); + if (Number.isFinite(v) && v > peak) peak = v; + } + if (unit === "A") { + if (peak >= 1e5) return { factor: 1e-6, unit: "MA" }; + if (peak >= 1e2) return { factor: 1e-3, unit: "kA" }; + return { factor: 1, unit: "A" }; + } + if (unit === "T" && peak > 0 && peak < 0.1) return { factor: 1e4, unit: "G" }; + return { factor: 1, unit: unit ?? "" }; +} + +/** Axis title for a scaled trace, e.g. "ip (MA)" or just "kappa". */ +export const signalAxisTitle = (name: string, s: DisplayScale) => (s.unit ? `${name} (${s.unit})` : name); diff --git a/gui/web/src/lib/timeRange.test.ts b/gui/web/src/lib/timeRange.test.ts new file mode 100644 index 0000000..c3a149d --- /dev/null +++ b/gui/web/src/lib/timeRange.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { sharedXAxis, timeRangeFromRelayout } from "./timeRange"; + +describe("timeRangeFromRelayout", () => { + it("reads a zoom/pan range in either Plotly form", () => { + expect(timeRangeFromRelayout({ "xaxis.range[0]": 1200, "xaxis.range[1]": 2400 })).toEqual([1200, 2400]); + expect(timeRangeFromRelayout({ "xaxis.range": [10, 20] })).toEqual([10, 20]); + }); + it("maps a double-click reset to null and ignores events that don't touch x", () => { + expect(timeRangeFromRelayout({ "xaxis.autorange": true, "yaxis.autorange": true })).toBeNull(); + expect(timeRangeFromRelayout({ "yaxis.range[0]": 0, "yaxis.range[1]": 1 })).toBeUndefined(); + expect(timeRangeFromRelayout({ autosize: true })).toBeUndefined(); + }); +}); + +describe("sharedXAxis", () => { + it("fixes the range when set and autoranges otherwise", () => { + expect(sharedXAxis([1, 2])).toEqual({ range: [1, 2], autorange: false }); + expect(sharedXAxis(null)).toEqual({ autorange: true }); + }); +}); diff --git a/gui/web/src/lib/timeRange.ts b/gui/web/src/lib/timeRange.ts new file mode 100644 index 0000000..374e0c6 --- /dev/null +++ b/gui/web/src/lib/timeRange.ts @@ -0,0 +1,29 @@ +// The global time axis shared by every time-series plot (plasma-signal strip, QS +// traces, rotating spectrogram/tracks, Compare). Plots read `timeRange` from the +// store and report user zoom/pan back through `timeRangeFromRelayout`. + +/** The x-range change in a Plotly relayout event: a [t0, t1] range, `null` for an + * autorange reset (double-click), or `undefined` when the event doesn't touch x + * (a y-only zoom, a resize, …) and the shared range should stay as it is. */ +export function timeRangeFromRelayout(e: Record): [number, number] | null | undefined { + if (e["xaxis.autorange"] === true) return null; + if (e["xaxis.range[0]"] != null && e["xaxis.range[1]"] != null) + return [Number(e["xaxis.range[0]"]), Number(e["xaxis.range[1]"])]; + if (Array.isArray(e["xaxis.range"])) { + const [a, b] = e["xaxis.range"] as number[]; + return [Number(a), Number(b)]; + } + return undefined; +} + +/** Plotly x-axis props for the shared range: fixed when set, else autorange. */ +export const sharedXAxis = (range: [number, number] | null) => + range ? { range, autorange: false } : { autorange: true }; + + +/** A double-click handler that clears the shared range. Deferred one tick so it lands + * AFTER Plotly's own double-click relayout, which otherwise re-reports the range the + * plot was first drawn with (a stale zoom when the plot mounted while zoomed). */ +export const resetTimeRangeOnDoubleClick = (setTimeRange: (r: null) => void) => () => { + setTimeout(() => setTimeRange(null), 0); +}; diff --git a/gui/web/src/store.test.ts b/gui/web/src/store.test.ts index 3fd436a..338c516 100644 --- a/gui/web/src/store.test.ts +++ b/gui/web/src/store.test.ts @@ -65,3 +65,22 @@ test("the presets are evenly spaced from S up to a boosted XL", () => { expect(M - S).toBeCloseTo(L - M); expect(L - M).toBeCloseTo(XL - L); }); + +test("annotations: add / update / remove per shot, persisted to localStorage", () => { + const s = useStore.getState(); + s.setAnnotations("190000", []); + s.addAnnotation("190000", { kind: "vline", t: 1500, label: "onset" }); + s.addAnnotation("190000", { kind: "hline", panel: "amplitude", y: 2 }); + let list = useStore.getState().annotations["190000"]; + expect(list).toHaveLength(2); + expect(list[0].id).toBeTruthy(); + expect(JSON.parse(window.localStorage.getItem("magnetics-annotations")!)["190000"]).toHaveLength(2); + + useStore.getState().updateAnnotation("190000", list[0].id, { t: 1600 }); + list = useStore.getState().annotations["190000"]; + expect(list[0]).toMatchObject({ kind: "vline", t: 1600, label: "onset" }); + + useStore.getState().removeAnnotation("190000", list[0].id); + useStore.getState().removeAnnotation("190000", list[1].id); + expect(useStore.getState().annotations["190000"]).toBeUndefined(); // empty list → key dropped +}); diff --git a/gui/web/src/store.ts b/gui/web/src/store.ts index c3d18a8..2197e45 100644 --- a/gui/web/src/store.ts +++ b/gui/web/src/store.ts @@ -1,6 +1,7 @@ // Global UI state (zustand). Deliberately small: the selected machine/shot, the -// active tab, and the shared time cursor that links the quasi-stationary and -// rotating views (VISION §6.4). Views read what they need and render nodes from +// active tab, the shared time cursor that links the quasi-stationary and +// rotating views (VISION §6.4), the analysis params each of those views last ran +// (so the Compare view plots the same analysis), and the per-shot annotations. Views read what they need and render nodes from // the API; heavy data stays in the nodes, not here. import { create } from "zustand"; import { @@ -11,8 +12,11 @@ import { type DeviceInfo, type MachineInfo, } from "./lib/api"; +import { + newAnnotationId, parseAnnotations, type Annotation, type AnnotationInput, type AnnotationPatch, +} from "./lib/annotations"; -export type TabId = "sensors" | "qs" | "rotating"; +export type TabId = "sensors" | "qs" | "rotating" | "compare"; export type Theme = "dark" | "light"; // Backend + DIII-D credentials for a live pull. Lifted out of PullControl so the @@ -73,6 +77,47 @@ export function applyFontScale(n: number) { document.documentElement.style.setProperty("--font-scale", String(n)); } +// ── Plasma-signal strip (every tab) ───────────────────────────────────────── +// Global (not per shot) and persisted: the traces to plot, whether the strip is open, +// and whether every analysis stops at the Ip flattop end (lib/flattop.ts). +const TRACE_SIGNALS_KEY = "magnetics-trace-signals"; +const TRACE_OPEN_KEY = "magnetics-trace-open"; +const CUT_FLATTOP_KEY = "magnetics-cut-flattop"; +export const DEFAULT_TRACE_SIGNALS = ["ip", "bt", "kappa"]; +const isBool = (v: string) => v === "true" || v === "false"; +const isNameList = (v: string) => { + try { const a: unknown = JSON.parse(v); return Array.isArray(a) && a.every((x) => typeof x === "string"); } catch { return false; } +}; +const savePref = (key: string, value: unknown) => window.localStorage.setItem(key, JSON.stringify(value)); + +// ── Per-shot plot annotations (Compare view) ──────────────────────────────── +// Keyed by machine id, persisted as JSON. Every read is validated (parseAnnotations) +// and storage failures are swallowed — annotations are a convenience, never fatal. +const ANNOTATIONS_KEY = "magnetics-annotations"; +function loadAnnotations(): Record { + if (typeof window === "undefined") return {}; + try { + const raw: unknown = JSON.parse(window.localStorage.getItem(ANNOTATIONS_KEY) ?? "{}"); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(raw)) { + const list = parseAnnotations(v); + if (list.length) out[k] = list; + } + return out; + } catch { + return {}; + } +} +function saveAnnotations(all: Record) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(ANNOTATIONS_KEY, JSON.stringify(all)); + } catch { + /* quota / private mode — keep the in-memory copy */ + } +} + // Apply synchronously at module load so the first paint matches (no flash). applyTheme(loadTheme()); applyFontScale(loadFontScale()); @@ -84,10 +129,22 @@ interface State { device: string; // selected device id (single source of truth) tab: TabId; cursorMs: number; // shared time cursor across views + // Shared time axis (ms) of every time-series plot — strip, QS, rotating, Compare; + // null = autorange. Zooming any of them sets it; a shot change clears it. + timeRange: [number, number] | null; loadingMachines: boolean; theme: Theme; fetchCreds: FetchCreds; // shared by PullControl + the QS custom-signal panel fontScale: number; + // Params of the last spectrogram / mode-number fetch the Rotating tab made, and of + // the last committed quasi-stationary fit — null until that tab has run (the + // Compare view then falls back to the same defaults those tabs start from). + rotParams: { spec: Record; mode: Record } | null; + qsParams: Record | null; + annotations: Record; // keyed by machine id + traceSignals: string[]; // plasma-signal strip selection (every tab) + traceOpen: boolean; + cutFlattop: boolean; // stop every analysis at the Ip flattop end (lib/flattop.ts) init: () => Promise; removeMachine: (id: string) => Promise; @@ -96,9 +153,19 @@ interface State { setDevice: (id: string) => void; setTab: (t: TabId) => void; setCursorMs: (t: number) => void; + setTimeRange: (r: [number, number] | null) => void; toggleTheme: () => void; setFetchCreds: (patch: Partial) => void; setFontScale: (n: number) => void; + setRotParams: (p: { spec: Record; mode: Record }) => void; + setQsParams: (p: Record) => void; + addAnnotation: (machine: string, a: AnnotationInput) => void; + updateAnnotation: (machine: string, id: string, patch: AnnotationPatch) => void; + removeAnnotation: (machine: string, id: string) => void; + setAnnotations: (machine: string, list: Annotation[]) => void; + setTraceSignals: (names: string[]) => void; + setTraceOpen: (open: boolean) => void; + setCutFlattop: (on: boolean) => void; } export const useStore = create((set) => ({ @@ -108,6 +175,7 @@ export const useStore = create((set) => ({ device: "", tab: "sensors", cursorMs: 0, + timeRange: null, loadingMachines: true, theme: loadTheme(), // Default to the fast cluster path (remote); PullControl's device snap adjusts it. @@ -119,6 +187,12 @@ export const useStore = create((set) => ({ duoPasscode: "", }, fontScale: loadFontScale(), + rotParams: null, + qsParams: null, + annotations: loadAnnotations(), + traceSignals: readPref(TRACE_SIGNALS_KEY, isNameList, DEFAULT_TRACE_SIGNALS, (v) => JSON.parse(v) as string[]), + traceOpen: readPref(TRACE_OPEN_KEY, isBool, true, (v) => v === "true"), + cutFlattop: readPref(CUT_FLATTOP_KEY, isBool, false, (v) => v === "true"), async init() { // fetchDevices() guards its own errors and returns [] (no live backend / no @@ -154,10 +228,27 @@ export const useStore = create((set) => ({ const machines = await fetchMachines(); set({ machines, machine: machines[0]?.id ?? null }); }, - setMachine: (id) => set({ machine: id }), + setMachine: (id) => set((s) => (s.machine === id ? {} : { machine: id, timeRange: null })), setDevice: (id) => set({ device: id }), setTab: (t) => set({ tab: t }), setCursorMs: (t) => set({ cursorMs: t }), + setTimeRange: (r) => + set((s) => (r === s.timeRange || (r && s.timeRange && r[0] === s.timeRange[0] && r[1] === s.timeRange[1]) + ? {} : { timeRange: r })), + setRotParams: (p) => set({ rotParams: p }), + setQsParams: (p) => set({ qsParams: p }), + addAnnotation: (machine, a) => + set((s) => withAnnotations(s, machine, [...(s.annotations[machine] ?? []), { ...a, id: newAnnotationId() } as Annotation])), + updateAnnotation: (machine, id, patch) => + set((s) => + withAnnotations(s, machine, (s.annotations[machine] ?? []).map((a) => (a.id === id ? ({ ...a, ...patch } as Annotation) : a))), + ), + removeAnnotation: (machine, id) => + set((s) => withAnnotations(s, machine, (s.annotations[machine] ?? []).filter((a) => a.id !== id))), + setAnnotations: (machine, list) => set((s) => withAnnotations(s, machine, list)), + setTraceSignals: (names) => { savePref(TRACE_SIGNALS_KEY, names); set({ traceSignals: names }); }, + setTraceOpen: (open) => { savePref(TRACE_OPEN_KEY, open); set({ traceOpen: open }); }, + setCutFlattop: (on) => { savePref(CUT_FLATTOP_KEY, on); set({ cutFlattop: on }); }, setFetchCreds: (patch) => set((s) => ({ fetchCreds: { ...s.fetchCreds, ...patch } })), toggleTheme: () => set((s) => { @@ -176,6 +267,15 @@ export const useStore = create((set) => ({ }, })); +// Replace one machine's annotation list (dropping the key when empty) and persist. +function withAnnotations(s: State, machine: string, list: Annotation[]): Pick { + const annotations = { ...s.annotations }; + if (list.length) annotations[machine] = list; + else delete annotations[machine]; + saveAnnotations(annotations); + return { annotations }; +} + // Keep the theme in sync across browser tabs: toggleTheme writes localStorage, so a // `storage` event fires in every OTHER tab — mirror it into the store + the DOM. if (typeof window !== "undefined") { diff --git a/gui/web/src/theme.css b/gui/web/src/theme.css index 3b52fdd..faed14b 100644 --- a/gui/web/src/theme.css +++ b/gui/web/src/theme.css @@ -208,3 +208,26 @@ code, .mono { font-family: "IBM Plex Mono", ui-monospace, monospace; } .draggable-divider:hover .draggable-divider-line, .draggable-divider:focus-visible .draggable-divider-line { background: var(--accent); } .draggable-divider:focus-visible { outline: none; } + +/* Compare tab: toolbars, inline inputs, colour swatches and the annotation table */ +.cmp-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.923em; } +.cmp-toolbar .metrics-title { margin: 0; } +.cmp-check { display: inline-flex; align-items: center; gap: 4px; color: var(--text-dim); cursor: pointer; } +.cmp-sep { width: 1px; height: 16px; background: var(--border); margin: 0 4px; } +.cmp-dim-row { display: flex; gap: 8px; flex-wrap: wrap; font-size: 0.846em; } +.cmp-dim { color: var(--text-dim); font-size: 0.923em; } +.cmp-warn { color: var(--warn); font-size: 0.923em; } +.cmp-input { + padding: 2px 6px; font-size: 0.923em; font-family: inherit; + background: var(--panel-2); color: var(--text); border: 1px solid var(--border); border-radius: 3px; +} +.cmp-swatches { display: inline-flex; gap: 3px; vertical-align: middle; } +.cmp-swatch { width: 14px; height: 14px; padding: 0; border-radius: 3px; border: 1px solid var(--border); cursor: pointer; } +.cmp-swatch.active { outline: 2px solid var(--text); outline-offset: 1px; } +.cmp-ann { display: flex; flex-direction: column; gap: 8px; border-top: 1px solid var(--border); padding-top: 10px; } +.cmp-table { border-collapse: collapse; font-size: 0.923em; } +.cmp-table th { text-align: left; color: var(--text-dim); font-weight: 600; padding: 3px 8px; border-bottom: 1px solid var(--border); } +.cmp-table td { padding: 3px 8px; border-bottom: 1px solid var(--border); } +.cmp-del { background: none; border: none; cursor: pointer; color: var(--text-dim); font-size: 16px; line-height: 1; } +.cmp-del:hover { color: var(--danger, #e5484d); } +.seg-btn:disabled { opacity: 0.5; cursor: default; } diff --git a/src/magnetics/core/mode_shape.py b/src/magnetics/core/mode_shape.py index 8ddcadb..ed058fa 100644 --- a/src/magnetics/core/mode_shape.py +++ b/src/magnetics/core/mode_shape.py @@ -522,6 +522,76 @@ def ridge_track_from_spectrum( ) +@dataclass(slots=True) +class ModeAmplitudeByN: + kind: str # "mode_amplitude_by_n" + t_ms: NDArray[np.floating] # slice-center times (ms) + abs_n: NDArray[np.integer] # |n| = 0 … n_max, one row of ``amplitude`` each + amplitude: NDArray[np.floating] # (n_abs, n_times); NaN where |n| owns no cell + freq_khz: NDArray[np.floating] # (n_abs, n_times) frequency of each peak; NaN likewise + + +def mode_amplitude_by_n( + spectrum, + angle_deg: NDArray[np.floating], + *, + fmin: float = 1000.0, + fmax: float = 25000.0, + n_slices: int = 240, + t_range: tuple[float, float] | None = None, + n_max: int = 5, +) -> ModeAmplitudeByN: + """Rotating-mode amplitude vs time for *each* toroidal |n|. + + At every sampled (time, in-band frequency) cell the complex array pattern ``Z_p`` is + projected onto each toroidal harmonic, ``R_n = Σ_p Z_p e^{+inφ_p}`` (the same + projection as ``spectral.array_mode_spectrogram``), and the cell is assigned to its + best-fit n. The |n| amplitude at a time is the largest ``|R_n| / P`` among the cells + assigned to ±n — the probe-averaged STFT magnitude of that mode at its own frequency. + + Only cells *owned* by an n count toward it: with unevenly spaced probes a strong n=1 + pattern also projects partly onto the n=2, 3 templates, so a plain max over + frequency of ``|R_2|`` would echo the n=1 trace. Where no in-band cell is owned by + an |n| the amplitude is NaN (a gap) — that n is not the dominant structure at any + frequency then. Same slices as ``ridge_track_from_spectrum`` for the same + ``n_slices``/``t_range``, so the traces line up with n(t). + """ + times = np.asarray(spectrum.time, dtype=np.float64) + freqs = np.asarray(spectrum.freq_band, dtype=np.float64) + band = np.flatnonzero((freqs >= fmin) & (freqs <= fmax)) + if band.size == 0: + band = np.arange(freqs.size) + idx = _slice_indices(times, n_slices, t_range) + + spec = np.asarray(spectrum.spec) + z = spec[np.ix_(np.arange(spec.shape[0]), idx, band)] # (P, T, F) + phi = np.deg2rad(np.asarray(angle_deg, dtype=np.float64)) + ns = np.arange(-int(n_max), int(n_max) + 1) + basis = np.exp(1j * ns[:, None] * phi[None, :]) # (M, P) + amp = np.abs(np.einsum("mp,ptf->mtf", basis, z)) / max(z.shape[0], 1) # (M, T, F) + owner = np.abs(ns[np.argmax(amp, axis=0)]) # |n*| per cell (T, F) + best = amp.max(axis=0) # |R_{n*}|/P per cell (T, F) + + abs_n = np.arange(int(n_max) + 1) + out = np.full((abs_n.size, idx.size), np.nan) + fout = np.full((abs_n.size, idx.size), np.nan) + for i, n in enumerate(abs_n): + masked = np.where(owner == n, best, -np.inf) # (T, F) + j = np.argmax(masked, axis=1) # strongest owned cell per time + peak = masked[np.arange(idx.size), j] + ok = np.isfinite(peak) + out[i, ok] = peak[ok] + fout[i, ok] = freqs[band[j[ok]]] / 1e3 + + return ModeAmplitudeByN( + kind="mode_amplitude_by_n", + t_ms=times[idx] * 1e3, + abs_n=abs_n, + amplitude=out, + freq_khz=fout, + ) + + # --------------------------------------------------------------------------- # 2-D (θ, φ) modal pattern (eq 23) # --------------------------------------------------------------------------- diff --git a/src/magnetics/core/plasma.py b/src/magnetics/core/plasma.py new file mode 100644 index 0000000..d85c920 --- /dev/null +++ b/src/magnetics/core/plasma.py @@ -0,0 +1,54 @@ +"""Plasma-scenario helpers from global signals (Ip, Bt, …) — device-agnostic, pure.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +from numpy.typing import NDArray + + +@dataclass(slots=True) +class Flattop: + kind: str # "ip_flattop" + t_start_ms: float # first time |Ip| reaches the flattop level + t_end_ms: float # last time |Ip| is at the flattop level (ramp-down / quench after) + ip_peak: float # peak of the smoothed |Ip| (signal units) + frac: float # flattop level as a fraction of the peak + + +def ip_flattop( + t_ms: NDArray[np.floating], + ip: NDArray[np.floating], + *, + frac: float = 0.95, + smooth_ms: float = 10.0, +) -> Flattop: + """Plasma-current flattop: the span where the smoothed |Ip| is at least ``frac`` of + its peak, from the first to the last such time. + + |Ip| makes the result sign-agnostic (KSTAR stores Ip negative), and a ``smooth_ms`` + moving average keeps single-sample spikes from setting the peak or the edges. Brief + dips inside the flattop don't split it — the end is the LAST time at the level, i.e. + where the ramp-down or quench begins, which is what an analysis cut-off wants. + """ + t = np.asarray(t_ms, dtype=np.float64) + a = np.abs(np.asarray(ip, dtype=np.float64)) + ok = np.isfinite(t) & np.isfinite(a) + t, a = t[ok], a[ok] + if t.size < 2: + raise ValueError("Ip trace too short to find a flattop") + dt = float(np.median(np.diff(t))) + w = max(1, int(round(smooth_ms / dt))) if dt > 0 else 1 + s = np.convolve(a, np.ones(w) / w, mode="same") if w > 1 else a + peak = float(s.max()) + if peak <= 0.0: + raise ValueError("Ip is zero throughout — no flattop") + on = np.flatnonzero(s >= frac * peak) + return Flattop( + kind="ip_flattop", + t_start_ms=float(t[on[0]]), + t_end_ms=float(t[on[-1]]), + ip_peak=peak, + frac=float(frac), + ) diff --git a/src/magnetics/service/nodes.py b/src/magnetics/service/nodes.py index 0e18e3c..f260092 100644 --- a/src/magnetics/service/nodes.py +++ b/src/magnetics/service/nodes.py @@ -19,7 +19,7 @@ import numpy as np -from ..core import contracts, geometry, mode_shape, qs_bridge, spectral +from ..core import contracts, geometry, mode_shape, plasma, qs_bridge, spectral from ..data import device_geom, devices, diiid_geometry, h5source logger = logging.getLogger(__name__) @@ -164,6 +164,7 @@ def refresh() -> None: _stack_cached, _array_spectrum, _array_mode_spec, + _flattop, _qs_run, _dev_geom, _real_theta, # channel→θ map derives from channel_names: stale after a re-pull @@ -182,6 +183,62 @@ def _device_key(shot): return None, None +# ── Ip flattop: the optional analysis cut-off (``cut_flattop=1``) ───────────── +def _ip_channel(shot) -> str | None: + """The shot file's plasma-current channel: the device file's ``plasma pointnames → + current`` name (DIII-D ``ip``, KSTAR ``Ip``), else a common spelling; None if absent.""" + names = set(h5source.channel_names(str(shot))) + cands = [] + try: + dev = devices.load_device(_dev_geom(str(shot)).device_id) + name = ((dev.get("plasma pointnames") or {}).get("current") or {}).get("name") + if name: + cands.append(name) + except Exception: # noqa: BLE001 — no device file → common spellings only + pass + return next((c for c in [*cands, "ip", "Ip", "IP"] if c in names), None) + + +@lru_cache(maxsize=16) +def _flattop(shot): + """The shot's Ip flattop (``plasma.ip_flattop``), or None without a usable Ip.""" + ch = _ip_channel(shot) + if ch is None: + return None + t_ms, ip = h5source.load_channel(str(shot), ch) + try: + return plasma.ip_flattop(t_ms, ip) + except ValueError: + return None + + +def _flattop_cut_s(shot, params) -> float | None: + """End of the Ip flattop (s) when the GUI asks to stop the analysis there + (``cut_flattop=1``) and the shot has an Ip trace; else None (no cut).""" + if not _flag(params, "cut_flattop"): + return None + ft = _flattop(str(shot)) + return ft.t_end_ms / 1e3 if ft else None + + +def _flattop_ms(shot) -> list[float] | None: + ft = _flattop(str(shot)) + return [round(ft.t_start_ms, 1), round(ft.t_end_ms, 1)] if ft else None + + +def _crop_time(node: dict, t_end_ms: float) -> dict: + """Drop the time columns after ``t_end_ms`` from a time-x heatmap node (z is + row-major [i_y][i_x]).""" + x = node.get("x") or [] + keep = [i for i, t in enumerate(x) if t <= t_end_ms] + if len(keep) == len(x): + return node + node["x"] = [x[i] for i in keep] + node["z"] = [[row[i] for i in keep] for row in node.get("z", [])] + node.setdefault("meta", {})["cut_at_ms"] = round(t_end_ms, 1) + return node + + def _arrays(shot): """(dev, arrays) when this shot's device declares its rotating-array sets (``device["arrays"] = {"toroidal": , "poloidal": }``), else (None, None). @@ -1296,6 +1353,9 @@ def _mode_track(shot, params=None) -> dict: # Sample finely, but only over the active-signal window — the full record is mostly # dead time, which both coarsens the trace and biases the dominant mode toward n=0. t_lo, t_hi = mode_shape.active_time_window(spec) + cut = _flattop_cut_s(shot, params) + if cut is not None: + t_hi = max(t_lo, min(t_hi, cut)) tr = mode_shape.track_from_spectrum( spec, phis, f_khz * 1e3, n_slices=n_slices, t_range=(t_lo, t_hi) ) @@ -1336,6 +1396,9 @@ def _mode_over_time(shot, params=None) -> dict: phis = np.array([p for _, p in arr], dtype=float) spec = _array_spectrum(str(shot), tuple(n for n, _ in arr)) t_lo, t_hi = mode_shape.active_time_window(spec) + cut = _flattop_cut_s(shot, params) + if cut is not None: + t_hi = max(t_lo, min(t_hi, cut)) tr = mode_shape.ridge_track_from_spectrum(spec, phis, n_slices=n_slices, t_range=(t_lo, t_hi)) vals, counts = np.unique(tr.n_by_time, return_counts=True) f_lo, f_hi = ( @@ -1364,6 +1427,46 @@ def _mode_over_time(shot, params=None) -> dict: ) +# ── mode_amplitude: rotating-mode amplitude vs time, one trace per |n| ─────── +def _mode_amplitude(shot, params=None) -> dict: + """Rotating-mode amplitude vs time for each toroidal |n| (0…5): the probe-averaged + STFT magnitude |δḂp| of the strongest in-band cell whose best-fit n is ±n. Each + (t, f) cell counts toward only its own n, so a strong n=1 doesn't echo into n=2; + gaps (null) mark times where an |n| owns no cell. Same slices as ``mode_over_time``.""" + arr = _toroidal_arr(str(shot)) + phis = np.array([p for _, p in arr], dtype=float) + spec = _array_spectrum(str(shot), tuple(n for n, _ in arr)) + t_lo, t_hi = mode_shape.active_time_window(spec) + cut = _flattop_cut_s(shot, params) + if cut is not None: + t_hi = max(t_lo, min(t_hi, cut)) + n_slices = _i(params, "n_slices", 300) + res = mode_shape.mode_amplitude_by_n(spec, phis, n_slices=n_slices, t_range=(t_lo, t_hi)) + t = res.t_ms.tolist() + + def _nulls(row): + return [None if not np.isfinite(v) else float(v) for v in row] + + series = [ + {"name": f"n={int(n)}", "x": t, "y": _nulls(res.amplitude[i])} + for i, n in enumerate(res.abs_n) + ] + return contracts.line( + series, + {"x": "time (ms)", "y": "mean probe |δḂp| (arb.)"}, + meta={ + "abs_n": [int(n) for n in res.abs_n], + "freq_kHz": [_nulls(np.round(r, 2)) for r in res.freq_khz], + "n_probes": len(arr), + "n_slices": int(res.t_ms.size), + "shot": str(shot), + "legend_title": "|n|", + "note": "per-|n| probe-averaged STFT magnitude of dBp/dt at that n's strongest " + "in-band frequency; each (t, f) cell counts only toward its best-fit n", + }, + ) + + # ── quasi-stationary fit (SLCONTOUR via the core.qs_* pipeline) ───────────── _QS_RUN_LOCK = threading.Lock() @@ -1520,6 +1623,9 @@ def _prep_qs_ds(shot, params): tmax_ms_str = params.get("tmax_ms") if params else None tmin_s = float(tmin_ms_str) / 1e3 if tmin_ms_str else tmin_s_auto tmax_s = float(tmax_ms_str) / 1e3 if tmax_ms_str else tmax_s_auto + cut = _flattop_cut_s(shot, params) + if cut is not None and cut > tmin_s: # stop the fit at the Ip flattop end + tmax_s = min(tmax_s, cut) # Serialize concurrent calls with the same args: only one thread runs # run_steps; the others wait and then read the cached result instantly. @@ -1662,6 +1768,10 @@ def _fit_residuals(shot, params=None) -> dict: return qs_bridge.fit_to_fit_residuals_node(_prep_qs_ds(shot, params).fit) +# Native units of the plotted raw signals, for the GUI's display scaling. +_SIGNAL_UNITS = {"ip": "A", "bt": "T", "coil": "A", "Bp": "T", "Br": "T", "T/s": "T/s"} + + def _extra_signals(shot, params=None) -> dict: """User-requested raw signals (Ip, Dα, …) as time series → LineNode. @@ -1669,11 +1779,20 @@ def _extra_signals(shot, params=None) -> dict: HDF5), then reads them here. `signals` is a comma-separated pointname list; each found channel becomes one series (downsampled to keep the line light), each missing name is reported in ``meta.missing`` so the panel can warn. + ``meta.available`` groups every channel in the file (plasma / coil / sensor) for the + plasma-signal strip, with ``meta.units`` and the Ip ``meta.flattop_ms``. """ raw = params.get("signals", "") if params else "" names = [n.strip() for n in str(raw).split(",") if n.strip()] have = set(h5source.channel_names(str(shot))) - series, found, missing = [], [], [] + geom = _dev_geom(str(shot)) + groups: dict[str, list[str]] = {"plasma": [], "coil": [], "sensor": []} + for nm in sorted(have, key=str.lower): + kind = geom.kind_of(nm) + groups[ + "plasma" if kind in ("aux", "other") else kind if kind == "coil" else "sensor" + ].append(nm) + series, found, missing, units = [], [], [], {} for name in names: if name not in have: missing.append(name) @@ -1686,11 +1805,14 @@ def _extra_signals(shot, params=None) -> dict: t_ms, d = t_ms[sel], d[sel] series.append({"name": name, "x": t_ms.tolist(), "y": d.tolist()}) found.append(name) + kind = "T/s" if geom.family_of(name) == "MPI_BDOT" else geom.kind_of(name) + units[name] = _SIGNAL_UNITS.get(name, _SIGNAL_UNITS.get(kind, "")) return contracts.line( series, {"x": "time (ms)", "y": "signal"}, - meta={"shot": str(shot), "found": found, "missing": missing, "requested": names}, + meta={"shot": str(shot), "found": found, "missing": missing, "requested": names} + | {"available": groups, "units": units, "flattop_ms": _flattop_ms(shot)}, ) @@ -1723,6 +1845,7 @@ def _extra_signals(shot, params=None) -> dict: "mode_pattern": _mode_pattern, "mode_track": _mode_track, "mode_over_time": _mode_over_time, + "mode_amplitude": _mode_amplitude, # rotating array views: raw wave-stripes + poloidal phase fit + raw trace "toroidal_stripes": _toroidal_stripes, "poloidal_stripes": _poloidal_stripes, @@ -1735,4 +1858,14 @@ def build_node(shot: str, node_id: str, params: dict | None = None) -> dict: if node_id not in _BUILDERS: raise KeyError(f"unknown node {node_id!r}; have {', '.join(sorted(_BUILDERS))}") h5source.shot_file(shot) # raises KeyError if the shot isn't available - return _BUILDERS[node_id](shot, params) + node = _BUILDERS[node_id](shot, params) + # Rotating (t, f) maps run over the whole record; with cut_flattop=1 drop the + # columns after the Ip flattop. (The QS fit and the rotating tracks apply the cut + # inside their builders, so their fits/metadata see only the flattop.) + cut = _flattop_cut_s(shot, params) if node_id in _TF_MAPS else None + if cut is not None and node.get("kind") == "heatmap": + node = _crop_time(node, cut * 1e3) + return node + + +_TF_MAPS = {"spectrogram", "mode_number", "coherence"} diff --git a/tests/test_mode_amplitude_by_n.py b/tests/test_mode_amplitude_by_n.py new file mode 100644 index 0000000..d6be32d --- /dev/null +++ b/tests/test_mode_amplitude_by_n.py @@ -0,0 +1,46 @@ +"""Per-|n| rotating-mode amplitude (mode_shape.mode_amplitude_by_n) on a synthetic, +unevenly spaced toroidal array carrying two modes at different frequencies.""" + +import numpy as np + +from magnetics.core.mode_shape import mode_amplitude_by_n +from magnetics.core.spectral import array_shape_spectrum + +# Uneven DIII-D-like toroidal spacing: a strong n=1 projects partly onto n=2/3 here. +PHI = np.array([20, 67, 97, 127, 132, 137, 157, 200, 247, 277, 307, 312, 322, 340.0]) + + +def _two_modes(a1=2.0, a2=1.0, noise=0.02, seed=0): + rng = np.random.default_rng(seed) + fs = 200_000 + t = np.linspace(0, 0.05, int(fs * 0.05), endpoint=False) + sigs = np.vstack( + [ + a1 * np.sin(2 * np.pi * 6000.0 * t - np.deg2rad(1 * p)) + + a2 * np.sin(2 * np.pi * 12000.0 * t - np.deg2rad(2 * p)) + + noise * rng.standard_normal(t.size) + for p in PHI + ] + ) + return array_shape_spectrum(sigs, t), t + + +def test_one_trace_per_abs_n_with_the_right_amplitudes_and_frequencies(): + spec, _ = _two_modes() + r = mode_amplitude_by_n(spec, PHI, n_slices=20) + assert list(r.abs_n) == [0, 1, 2, 3, 4, 5] + assert r.amplitude.shape == r.freq_khz.shape == (6, r.t_ms.size) + a1, a2 = np.nanmedian(r.amplitude[1]), np.nanmedian(r.amplitude[2]) + assert np.isclose(a1 / a2, 2.0, rtol=0.1) # 2:1 input ratio recovered + assert np.allclose(r.freq_khz[1], 6.0, atol=1.0) + assert np.allclose(r.freq_khz[2], 12.0, atol=1.0) + + +def test_strong_mode_does_not_echo_into_other_n(): + # n=1 alone: the n=2…5 traces may only carry noise-owned cells, far below n=1. + spec, _ = _two_modes(a2=0.0) + r = mode_amplitude_by_n(spec, PHI, n_slices=20) + a1 = np.nanmedian(r.amplitude[1]) + for n in (2, 3, 4, 5): + row = r.amplitude[n] + assert np.all(np.isnan(row)) or np.nanmax(row) < 0.1 * a1 diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 50677da..34dcfd1 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -175,6 +175,19 @@ def test_mode_over_time_node(): assert n["meta"].get("dominant_n") is not None +def test_mode_amplitude_node_one_trace_per_n(): + # amplitude(t) per |n| = 0…5: same slices as n(t); values >= 0 or null (gap) + shot = _first_shot() + n = nodes.build_node(shot, "mode_amplitude") + assert n["kind"] == "line" + assert [s["name"] for s in n["series"]] == [f"n={k}" for k in range(6)] + x = nodes.build_node(shot, "mode_over_time")["series"][0]["x"] + for s in n["series"]: + assert s["x"] == x and len(s["y"]) == len(x) + assert all(y is None or y >= 0.0 for y in s["y"]) + assert any(y is not None for s in n["series"] for y in s["y"]) + + def test_mode_number_amp_pct_knob_widens_visible_cells(): # n_amp_pct is the amplitude-percentile floor: a lower percentile keeps weaker # cells, so the n-map shows at least as many as a stricter (higher) floor. @@ -300,6 +313,20 @@ def test_extra_signals_serves_found_and_reports_missing(): assert len(node["series"][0]["x"]) == len(node["series"][0]["y"]) +def test_extra_signals_lists_channels_units_and_flattop(): + """With no `signals` the node is just the plasma-strip listing: every channel grouped + plasma / coil / sensor, plus the Ip flattop; a requested series carries its units.""" + from magnetics.data import h5source + + shot = _first_shot() + meta = nodes.build_node(shot, "extra_signals")["meta"] + assert sorted(sum(meta["available"].values(), [])) == sorted(h5source.channel_names(shot)) + assert "ip" in meta["available"]["plasma"] and meta["flattop_ms"][0] < meta["flattop_ms"][1] + assert nodes.build_node(shot, "extra_signals", {"signals": "ip"})["meta"]["units"] == { + "ip": "A" + } + + def test_quality_for_k_thresholds(): # mirrors contract.ts qualityForK assert contracts.quality_for_k(5) == "good" @@ -404,3 +431,26 @@ def spy(*a, **k): assert captured["fmax"] == 50_000.0 # within the default step finally: nodes.refresh() # don't leak spy-built cache entries + + +def test_cut_flattop_stops_every_time_analysis_at_the_flattop_end(monkeypatch): + """cut_flattop=1 ends the rotating maps and tracks and the QS fit at the Ip + flattop end; without it (or without an Ip flattop) nothing is cut.""" + from magnetics.core.plasma import Flattop + + shot = _first_shot() + full = nodes.build_node(shot, "spectrogram") + t0, t1 = full["x"][0], full["x"][-1] + end = t0 + 0.6 * (t1 - t0) + monkeypatch.setattr(nodes, "_flattop", lambda s: Flattop("ip_flattop", t0, end, 1.0e6, 0.95)) + cut = {"cut_flattop": "1"} + + spec = nodes.build_node(shot, "spectrogram", cut) + assert spec["x"][-1] <= end < t1 and spec["meta"]["cut_at_ms"] == round(end, 1) + assert len(spec["z"][0]) == len(spec["x"]) + for nid in ("mode_over_time", "mode_amplitude", "mode_track"): + xs = nodes.build_node(shot, nid, cut)["series"][0]["x"] + assert xs[-1] <= end + 1e-6, nid + amp = nodes.build_node(shot, "amplitude", cut) + assert amp["series"][0]["x"][-1] <= end + 1.0 # QS fit window clamped (rounded ms) + assert nodes.build_node(shot, "spectrogram")["x"][-1] == t1 # flag off → uncut diff --git a/tests/test_plasma.py b/tests/test_plasma.py new file mode 100644 index 0000000..711078d --- /dev/null +++ b/tests/test_plasma.py @@ -0,0 +1,38 @@ +"""Ip flattop detection (core.plasma.ip_flattop) on synthetic current traces.""" + +import numpy as np +import pytest + +from magnetics.core.plasma import ip_flattop + + +def _shot(sign=1.0, spike=False, dip=False): + t = np.arange(0.0, 5000.0, 0.5) # ms + ip = np.interp(t, [0, 1500, 4300, 4450, 5000], [0, 1.0e6, 1.0e6, 0, 0]) + if spike: + ip[np.searchsorted(t, 800.0)] = 5.0e6 # one-sample glitch during ramp-up + if dip: + ip[(t > 2500) & (t < 2520)] *= 0.8 # brief dip inside the flattop + return t, sign * ip + + +def test_finds_ramp_up_and_ramp_down_edges(): + ft = ip_flattop(*_shot()) + assert ft.t_start_ms == pytest.approx(1500 * 0.95, abs=15) # 95 % on the ramp-up + assert ft.t_end_ms == pytest.approx(4300 + 150 * 0.05, abs=15) # start of ramp-down + assert ft.ip_peak == pytest.approx(1.0e6, rel=0.01) + + +def test_sign_agnostic_spike_robust_and_dips_do_not_split(): + base = ip_flattop(*_shot()) + for kw in ({"sign": -1.0}, {"spike": True}, {"dip": True}): + ft = ip_flattop(*_shot(**kw)) + assert ft.t_end_ms == pytest.approx(base.t_end_ms, abs=5), kw + assert ft.t_start_ms == pytest.approx(base.t_start_ms, abs=5), kw + + +def test_rejects_empty_or_zero_current(): + with pytest.raises(ValueError): + ip_flattop(np.array([0.0]), np.array([1.0])) + with pytest.raises(ValueError): + ip_flattop(np.arange(10.0), np.zeros(10))