diff --git a/gui/web/src/components/PullControl.tsx b/gui/web/src/components/PullControl.tsx index c1124f6..9cb7e80 100644 --- a/gui/web/src/components/PullControl.tsx +++ b/gui/web/src/components/PullControl.tsx @@ -20,17 +20,20 @@ export default function PullControl() { const setDevice = useStore((s) => s.setDevice); const [shot, setShot] = useState("184927"); const [analysis, setAnalysis] = useState("rotating"); - // Default to the fast cluster path. `mdsthin` (laptop tunnel) streams raw float - // over the SSH tunnel — minutes; `remote` fetches on the cluster and ships back a - // compressed h5 — tens of seconds. Users without cluster access pick mdsthin. - const [backend, setBackend] = useState("remote"); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - // Duo two-factor for the flux gateway (DIII-D / NSTX): either a push (sends "1") - // or a typed passcode — offered as a dropdown. A KSTAR/KFE VPN pull has no push, so - // its 2FA is always a typed passcode (the dropdown is hidden for that device). - const [duoMode, setDuoMode] = useState<"push" | "passcode">("push"); - const [duoPasscode, setDuoPasscode] = useState(""); + // Backend + creds live in the store so the QS custom-signal panel reuses them + // (entered once). `mdsthin` (laptop tunnel) streams raw float over the SSH tunnel + // — minutes; `remote` fetches on the cluster and ships back a compressed h5 — tens + // of seconds. Users without cluster access pick mdsthin. Duo two-factor for the flux + // gateway (DIII-D/NSTX): a push (sends "1") or a typed passcode; KSTAR/KFE VPN has no + // push, so its 2FA is always a typed passcode (the dropdown hides for that device). + const fetchCreds = useStore((s) => s.fetchCreds); + const setFetchCreds = useStore((s) => s.setFetchCreds); + const { backend, username, password, duoMode, duoPasscode } = fetchCreds; + const setBackend = (v: string) => setFetchCreds({ backend: v }); + const setUsername = (v: string) => setFetchCreds({ username: v }); + const setPassword = (v: string) => setFetchCreds({ password: v }); + const setDuoMode = (v: "push" | "passcode") => setFetchCreds({ duoMode: v }); + const setDuoPasscode = (v: string) => setFetchCreds({ duoPasscode: v }); // Prefill a sensible DIII-D flat-top window (ms): transfer time is linear in the // samples pulled, so cropping the default ~5 s shot to its active window roughly // halves the wire payload. Visible + editable (not a silent backend crop, which diff --git a/gui/web/src/components/tabs/QuasiStationaryTab.tsx b/gui/web/src/components/tabs/QuasiStationaryTab.tsx index e60f8e4..53e0928 100644 --- a/gui/web/src/components/tabs/QuasiStationaryTab.tsx +++ b/gui/web/src/components/tabs/QuasiStationaryTab.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type Plotly from "plotly.js-dist-min"; import { useStore } from "../../store"; +import { apiBase, startFetch } from "../../lib/api"; import { useNode } from "../../lib/useNode"; import NodeView from "../../lib/NodeView"; import Plot from "../../lib/Plot"; @@ -14,6 +15,19 @@ 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"]; +// 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. +const EXCLUDED_LINE = { color: "#888", width: 1, dash: "dot" as const }; + +// A valid PTDATA pointname (DIII-D custom-signal entry): letters/digits/underscore, +// e.g. `Ip`, `betan`, `bt`, `MPI66M020D`. Anything else is rejected before fetch. +const POINTNAME_RE = /^[A-Za-z0-9_]+$/; + +// Shown when a fetch is attempted (or would stall) without the left-rail credentials. +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"]; @@ -25,6 +39,13 @@ function useDarkMode(): boolean { return useStore((s) => s.theme === "dark"); } +// The shared wrapper's baseLayout() themes axis colors + base font for both +// light and dark, so themedLayout is a thin passthrough kept for the QS plot call +// sites (returns the caller's overrides; the wrapper applies the theme). +function themedLayout(_dark: boolean, overrides: Partial): Partial { + 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); @@ -165,9 +186,26 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { // ── Section collapse state ──────────────────────────────────────── const [fitQualityOpen, setFitQualityOpen] = useState(false); + const [channelsMapOpen, setChannelsMapOpen] = useState(false); // fit-channel excludes + φ-θ map + const [customOpen, setCustomOpen] = useState(false); // custom-signal panel const [svdOpen, setSvdOpen] = useState(false); const [sensorMapOpen, setSensorMapOpen] = useState(false); + // ── Sensor-signals view: overlay (default) or one axes per sensor ── + const [signalStacked, setSignalStacked] = useState(false); + + // ── Channels the user has deselected from the fit (checkbox panel). These are + // dropped from the quasi-stationary fit (fit_exclude) but stay drawn — greyed — on the + // sensor maps and signal plots. Reset when the array (channelFilter) changes. + const [excludedChannels, setExcludedChannels] = useState>(new Set()); + const toggleExcluded = useCallback((ch: string) => { + setExcludedChannels(prev => { + const next = new Set(prev); + if (next.has(ch)) next.delete(ch); else next.add(ch); + return next; + }); + }, []); + // ── Deferred fetch: only compute when user clicks Plot ──────────── const [committedParams, setCommittedParams] = useState | null>(null); @@ -189,10 +227,13 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { } if (tminMs) p.tmin_ms = tminMs; if (tmaxMs) p.tmax_ms = tmaxMs; + // 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, + uncertainty, energyFraction, fitBasis, fitCond, cutoffLo, cutoffHi, excludedChannels, ]); useEffect(() => { @@ -213,6 +254,12 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { setTimeRange(null); }, [tminMs, tmaxMs]); + // A different array has different channels, so stale exclusions don't apply. + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- clear exclusions on array change + setExcludedChannels(new Set()); + }, [channelFilter]); + // Auto-commit on mount so plots load immediately without requiring a click. useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- initial commit to trigger fetch on mount @@ -248,8 +295,9 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { const { node: ampNode, error: ampError } = useNode(fetchMachine, "amplitude", committedParams ?? {}); const { node: phaseTimeNode } = useNode(fetchMachine, "phase_t", committedParams ?? {}); - // Sensor maps, signal conditioning, fit quality time series - const { node: sensorRzRaw } = useNode(fetchMachine, "sensor_map_rz", committedParams ?? {}); + // Sensor maps (R-Z cross-section + unrolled φ-θ), signal conditioning, fit quality + // time series. + const { node: sensorRzRaw } = useNode(fetchMachine, "sensor_map_rz", committedParams ?? {}); const { node: sensorCylRaw } = useNode(fetchMachine, "sensor_map_cylindrical", committedParams ?? {}); const { node: signalRaw } = useNode(fetchMachine, "signal_conditioning", committedParams ?? {}); const { node: chiSqRaw } = useNode(fetchMachine, "chi_sq_t", committedParams ?? {}); @@ -259,7 +307,7 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { // No-data guard: 404 means the shot's HDF5 file hasn't been pulled yet. const noData = committedParams !== null && ampError?.includes("fetch failed (404)") === true; - // Fit-unavailable guard: a non-404 error means the SLCONTOUR fit couldn't run + // Fit-unavailable guard: a non-404 error means the quasi-stationary fit couldn't run // (most often the shot was pulled for rotating-mode analysis and lacks the Bp // LFS midplane array). Show the reason instead of a perpetual "loading…". const fitUnavailable = committedParams !== null && !noData && ampError != null; @@ -273,6 +321,17 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { const svdEnergyNode = svdEnergyRaw?.kind === "line" ? (svdEnergyRaw as LineNode) : null; const svdCondNode = svdCondRaw?.kind === "line" ? (svdCondRaw as LineNode) : null; + // ── Custom user signals (Ip, Dα, …): fetch → merge into the h5 → plot ───── + const fetchCreds = useStore((s) => s.fetchCreds); + const device = useStore((s) => s.device); + const [customText, setCustomText] = useState(""); // entry box (persisted) + const [committedSignals, setCommittedSignals] = useState(""); // comma list, drives the node + const [customBusy, setCustomBusy] = useState(false); + const [customFrac, setCustomFrac] = useState(0); + const [customMsg, setCustomMsg] = useState(null); + const customEsRef = useRef(null); + useEffect(() => () => customEsRef.current?.close(), []); // close stream on unmount + // ── Channel checkboxes for signal conditioning ──────────────────── const [enabledChannels, setEnabledChannels] = useState>(new Set()); // Signature of the channel list we last initialized from. Re-seed the enabled set @@ -290,7 +349,6 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { setEnabledChannels(new Set(pairs.map(p => p.channel))); } }, [signalNode]); - const toggleChannel = useCallback((ch: string) => { setEnabledChannels(prev => { const next = new Set(prev); @@ -299,6 +357,96 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { }); }, []); + const { node: extraRaw } = useNode( + committedSignals ? machine : null, "extra_signals", { signals: committedSignals }, + ); + const extraNode = extraRaw?.kind === "line" ? (extraRaw as LineNode) : null; + const extraMissing = (extraNode?.meta?.missing as string[] | undefined) ?? []; + + // Tokenise the entry box (comma- or space-separated) and validate each name as a + // PTDATA pointname before we spend a network round-trip. Invalid tokens block the + // fetch and are surfaced to the user; "not found" (a valid but absent pointname) + // is a separate, server-side signal reported via extraMissing. + const customTokens = useMemo( + () => customText.split(/[\s,]+/).map(s => s.trim()).filter(Boolean), + [customText], + ); + const invalidTokens = useMemo( + () => customTokens.filter(t => !POINTNAME_RE.test(t)), + [customTokens], + ); + const customValid = customTokens.length > 0 && invalidTokens.length === 0; + + // Fetching new data needs the same backend + credentials as the left-rail pull. + // remote/mdsthin both require a GA username (password/Duo too unless key auth) — + // without it the cluster job hangs at 0%, so we block up-front with a clear hint. + const needsCreds = fetchCreds.backend === "remote" || fetchCreds.backend === "mdsthin"; + const credsMissing = needsCreds && !fetchCreds.username.trim(); + + const plotCustomSignals = useCallback(() => { + const names = customText.split(/[\s,]+/).map(s => s.trim()).filter(Boolean); + if (!names.length || names.some(n => !POINTNAME_RE.test(n))) return; + if (!apiBase()) { setCustomMsg("✗ no live backend configured — set VITE_API_BASE to fetch data"); return; } + if (credsMissing) { setCustomMsg(`✗ ${CREDS_HINT}`); return; } + setCustomBusy(true); setCustomFrac(0); setCustomMsg("fetching…"); + void (async () => { + try { + const { job_id } = await startFetch({ + shot: Number(machine), + signals: names, + backend: fetchCreds.backend, + username: fetchCreds.username || undefined, + password: fetchCreds.password || undefined, + duo: fetchCreds.duoMode === "push" ? "1" : fetchCreds.duoPasscode || undefined, + device: device || undefined, + }); + customEsRef.current?.close(); + const es = new EventSource(`${apiBase()}/api/fetch/${job_id}/stream`); + customEsRef.current = es; + // Watchdog: if the job never moves off 0% it is almost always a stuck + // login (missing/incorrect password or an unanswered Duo push). Surface a + // credentials hint instead of an eternal 0% spinner. Held in a const box so + // `close` can clear it without a use-before-assign dance. + const timer: { id?: ReturnType } = {}; + const close = () => { + clearTimeout(timer.id); + es.close(); + if (customEsRef.current === es) customEsRef.current = null; + }; + let moved = false; + timer.id = setTimeout(() => { + close(); + setCustomMsg(`✗ no progress after 30s — likely a login issue. ${CREDS_HINT}`); + setCustomBusy(false); + }, 30000); + es.onmessage = (e: MessageEvent) => { + const f = JSON.parse(e.data as string); + setCustomFrac(f.progress ?? 0); + setCustomMsg(f.msg ?? null); + if (!moved && (f.progress ?? 0) > 0) { moved = true; clearTimeout(timer.id); } + if (f.status === "done") { + close(); + setCommittedSignals(names.join(",")); // triggers the extra_signals node fetch + setCustomMsg(`✓ fetched ${names.length} signal(s)`); + setCustomBusy(false); + } else if (f.status === "error") { + close(); + setCustomMsg(`✗ ${f.error}`); + setCustomBusy(false); + } + }; + es.onerror = () => { + close(); + setCustomMsg("✗ progress stream lost (the pull may still be running)"); + setCustomBusy(false); + }; + } catch (e) { + setCustomMsg(String(e)); + setCustomBusy(false); + } + })(); + }, [customText, machine, device, fetchCreds, credsMissing]); + const phiTimePlot = phiTimeNode?.kind === "contour" ? (phiTimeNode as ContourNode) : null; const phiPeak = useMemo( @@ -422,9 +570,11 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { name: s.name, x: (s.x as number[]).map(v => v > 180 ? v - 360 : v), y: s.y, - line: { color: LINE_PALETTE[i % LINE_PALETTE.length], width: 2 }, + line: excludedChannels.has(s.name) + ? EXCLUDED_LINE + : { color: LINE_PALETTE[i % LINE_PALETTE.length], width: 2 }, } as Partial)); - }, [sensorCylNode]); + }, [sensorCylNode, excludedChannels]); const sensorCylLayout = useMemo(() => ({ @@ -454,50 +604,61 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { }, [signalYRange, fitResNode]); // ── Signal conditioning plots ───────────────────────────────────── - const signalData = useMemo((): Partial[] => { + // Channel raw/prepared pairs: the master channel list for this array (fit_exclude + // does not drop channels from prep, so every array channel appears here). + const signalPairs = signalNode?.meta?.pairs as + { channel: string; prepared_idx: number; raw_idx: number }[] | undefined; + + // Build the two traces (prepared + raw) for one sensor pair. Reused by the overlay + // plot and the stacked one-axes-per-sensor view. Excluded sensors are greyed. + const pairTraces = useCallback(( + pair: { channel: string; prepared_idx: number; raw_idx: number }, pIdx: number, + ): Partial[] => { if (!signalNode) return []; - const pairs = signalNode.meta?.pairs as { channel: string; prepared_idx: number; raw_idx: number }[] | undefined; - if (!pairs) return lineTraces(signalNode); - + const excluded = excludedChannels.has(pair.channel); // dropped from the fit → grey + dotted + // conditioning checkbox (enabledChannels): unchecked → hide from the plot (legendonly) + const visible: boolean | "legendonly" = enabledChannels.has(pair.channel) ? true : "legendonly"; + const color = excluded ? "#888" : LINE_PALETTE[pIdx % LINE_PALETTE.length]; + const prep = signalNode.series[pair.prepared_idx]; + const raw = signalNode.series[pair.raw_idx]; const traces: Partial[] = []; - pairs.forEach((pair, pIdx) => { - const isEnabled = enabledChannels.has(pair.channel); - const color = LINE_PALETTE[pIdx % LINE_PALETTE.length]; - const prep = signalNode.series[pair.prepared_idx]; - const raw = signalNode.series[pair.raw_idx]; - if (prep) { - traces.push({ - type: "scatter" as const, mode: "lines" as const, - name: prep.name, x: prep.x, y: prep.y, - line: { color, width: 1.5 }, - visible: isEnabled ? true : "legendonly", - } as Partial); - } - if (raw) { - traces.push({ - type: "scatter" as const, mode: "lines" as const, - name: raw.name, x: raw.x, y: raw.y, - line: { color, width: 1, dash: "dot" as const }, - opacity: 0.55, - visible: isEnabled ? true : "legendonly", - showlegend: false, - } as Partial); - } - }); + if (prep) { + traces.push({ + type: "scatter" as const, mode: "lines" as const, + name: prep.name, x: prep.x, y: prep.y, + line: { color, width: 1.5, ...(excluded ? { dash: "dot" as const } : {}) }, + visible, + } as Partial); + } + if (raw) { + traces.push({ + type: "scatter" as const, mode: "lines" as const, + name: raw.name, x: raw.x, y: raw.y, + line: { color, width: 1, dash: "dot" as const }, + opacity: 0.55, showlegend: false, + visible, + } as Partial); + } return traces; - }, [signalNode, enabledChannels]); + }, [signalNode, excludedChannels, enabledChannels]); + + const signalData = useMemo((): Partial[] => { + if (!signalNode) return []; + if (!signalPairs) return lineTraces(signalNode); + return signalPairs.flatMap((pair, pIdx) => pairTraces(pair, pIdx)); + }, [signalNode, signalPairs, pairTraces]); const signalLayout = useMemo(() => - signalNode ? ({ + signalNode ? themedLayout(dark, { xaxis: { ...timeXAxis, title: { text: signalNode.axes.x }, showticklabels: false }, yaxis: { title: { text: signalNode.axes.y }, ...(sharedSigResRange ? { range: sharedSigResRange } : {}), }, showlegend: false, - margin: { t: 4, b: 4, l: 60, r: 20 }, + margin: { t: 4, b: 34, l: 60, r: 20 }, } as Partial) : {}, - [signalNode, timeXAxis, sharedSigResRange]); + [signalNode, timeXAxis, sharedSigResRange, dark]); // ── Dynamic chi² y-range ────────────────────────────────────────── const chiSqYRange = useMemo(() => { @@ -724,9 +885,6 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { } as Partial) : {}, [fontScale, phaseTimeNode, timeXAxis]); - // ── Signal conditioning channel pairs ───────────────────────────── - const signalPairs = signalNode?.meta?.pairs as { channel: string; prepared_idx: number; raw_idx: number }[] | undefined; - return (
@@ -864,13 +1022,56 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) {
No quasi-stationary fit for shot {machine}.
- The SLCONTOUR fit needs the Bp LFS midplane array; this shot was most likely + The quasi-stationary fit needs the Bp LFS midplane array; this shot was most likely fetched for rotating-mode analysis only. Re-fetch it with the quasi-stationary channels, or choose a QS-capable shot.
reason: {fitError}
) : (<> + {/* ── Fit channels + sensor map (φ-θ) — collapsed by default, above the main plots ── */} +
+ setChannelsMapOpen(o => !o)}> + fit channels & sensor map{excludedChannels.size > 0 ? ` · ${excludedChannels.size} excluded` : ""} + + {channelsMapOpen && ( +
+ {/* left — checkbox grid: deselect sensors from the fit */} +
+
+ unchecked sensors are dropped from the fit (they stay drawn, greyed, on the map) — click Plot to apply +
+ {signalPairs ? ( +
+ {signalPairs.map((pair, i) => { + const included = !excludedChannels.has(pair.channel); + return ( + + ); + })} +
+ ) :
loading channels…
} +
+ {/* right — φ-θ unrolled sensor map (R-Z lives in the Sensors tab) */} +
+
unrolled φ-θ · {channelFilter}
+ {sensorCylNode + ? + :
loading…
+ } +
+
+ )} +
+ {/* ── Section D+E: Time-series results — PRIMARY, at top ────────── */}
{/* Section 8: Contour φ–t heatmap */} @@ -920,7 +1121,135 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { )}
- {/* ── Section C: Fit Quality — collapsible, collapsed by default ── */} + {/* ── Custom user signals (Ip, Dα, …) — collapsible, above the sensor signals ── */} +
+ setCustomOpen(o => !o)}> + custom signals + + {customOpen && ( +
+
+ Enter one or more signal names — PTDATA pointnames or + EFIT scalars — comma- or space-separated (e.g. Ip, betan, bt). + Names are letters, digits and underscores only; each is fetched via the + same backend/credentials as the left-rail pull and merged into this shot. +
+
+ setCustomText(e.target.value)} + placeholder="Ip, betan, bt" + title="Comma- or space-separated signal names — PTDATA pointnames or EFIT scalars (letters, digits, underscore). Example: Ip, betan, bt" + aria-label="custom PTDATA pointnames" + aria-invalid={invalidTokens.length > 0} + onKeyDown={e => { if (e.key === "Enter") plotCustomSignals(); }} + style={{ flex: 1, minWidth: 200, fontSize: 11, background: "var(--panel)", color: "var(--text)", + border: `1px solid ${invalidTokens.length > 0 ? "var(--danger, #d64550)" : "var(--border)"}`, + borderRadius: 3, padding: "2px 6px" }} /> + +
+ {invalidTokens.length > 0 && ( +
+ not a valid pointname: {invalidTokens.join(", ")} — use letters, digits and underscores only +
+ )} + {credsMissing && invalidTokens.length === 0 && ( +
+ ⚠ {CREDS_HINT} +
+ )} + {customBusy && ( +
+ )} + {customMsg &&
{customMsg}
} + {extraMissing.length > 0 && ( +
not found: {extraMissing.join(", ")}
+ )} + {extraNode?.series.map((s, i) => ( + ]} + layout={themedLayout(dark, { + xaxis: { ...timeXAxis, title: { text: "time (ms)" } }, + yaxis: { title: { text: s.name, font: { size: 9 } } }, + showlegend: false, + margin: { t: 4, b: 34, l: 64, r: 20 }, + } as Partial)} + onClick={seekTo} onRelayout={handleTimeRelayout} + exportName={xn(`custom_${s.name}`)} /> + ))} +
+ )} +
+ + {/* ── Sensor signals (raw + prepared) — PRIMARY; overlay by default, stackable ── */} +
+
+
sensor signals · raw + prepared
+ + {/* One style legend for the whole section: solid = prepared, dotted = raw. */} + + + + prepared + + + + raw + + +
+ {!signalNode ? ( +
loading signals…
+ ) : signalStacked && signalPairs ? ( +
+ {signalPairs.map((pair, i) => { + const isLast = i === signalPairs.length - 1; + return ( + )} + onClick={seekTo} onRelayout={handleTimeRelayout} + exportName={xn(`signal_${pair.channel}`)} /> + ); + })} +
+ ) : ( + + )} +
+ + {/* ── Section C: Fit Quality — collapsible (residuals + χ² + metrics) ── */}
setFitQualityOpen(o => !o)}> fit quality @@ -928,12 +1257,7 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { {fitQualityOpen && (
- {/* Signal conditioning — top of stack, full width */} - {signalNode - ? - :
loading signals…
- } - {/* Residuals — middle */} + {/* Residuals — top */} {fitResNode ? :
loading residuals…
diff --git a/gui/web/src/lib/api.ts b/gui/web/src/lib/api.ts index ee84eb5..c4f1bf9 100644 --- a/gui/web/src/lib/api.ts +++ b/gui/web/src/lib/api.ts @@ -91,6 +91,7 @@ export interface FetchBody { decimate?: number; device?: string; // data/device/.json (default: diiid) sensor_set?: string; // a set under the device's sensor_sets; overrides analysis + signals?: string[]; // custom PTDATA pointnames; merged into an existing shot file ssh_user?: string; // SSH login for devices that gateway over ssh (e.g. KSTAR) ssh_password?: string; // sent to the local backend only; not stored } diff --git a/gui/web/src/lib/contract.test.ts b/gui/web/src/lib/contract.test.ts index 33d5829..899608f 100644 --- a/gui/web/src/lib/contract.test.ts +++ b/gui/web/src/lib/contract.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'vitest' import { qualityForK } from './contract' -// SLCONTOUR condition-number thresholds: warn > 10, error > 20. +// quasi-stationary fit condition-number thresholds: warn > 10, error > 20. describe('qualityForK', () => { - it('classifies K by the SLCONTOUR thresholds', () => { + it('classifies K by the quasi-stationary fit thresholds', () => { expect(qualityForK(5)).toBe('good') expect(qualityForK(10)).toBe('good') expect(qualityForK(15)).toBe('warn') diff --git a/gui/web/src/lib/contract.ts b/gui/web/src/lib/contract.ts index 66d6b04..c41d747 100644 --- a/gui/web/src/lib/contract.ts +++ b/gui/web/src/lib/contract.ts @@ -22,7 +22,7 @@ export interface Overlay { symbol?: "square" | "circle" | "cross"; } -/** Filled contour of a 2-D field — the SLCONTOUR φ–θ map. z is row-major [y][x]. */ +/** Filled contour of a 2-D field — the quasi-stationary φ–θ map. z is row-major [y][x]. */ export interface ContourNode { kind: "contour"; x: number[]; @@ -110,7 +110,7 @@ export type Node = | EquilibriumNode | MetricsNode; -/** SLCONTOUR condition-number thresholds (warn > 10, error > 20). */ +/** quasi-stationary fit condition-number thresholds (warn > 10, error > 20). */ export function qualityForK(K: number): Quality { if (!Number.isFinite(K) || K > 20) return "bad"; if (K > 10) return "warn"; diff --git a/gui/web/src/store.ts b/gui/web/src/store.ts index 621ecc3..c3d18a8 100644 --- a/gui/web/src/store.ts +++ b/gui/web/src/store.ts @@ -15,6 +15,17 @@ import { export type TabId = "sensors" | "qs" | "rotating"; export type Theme = "dark" | "light"; +// Backend + DIII-D credentials for a live pull. Lifted out of PullControl so the +// QS tab's custom-signal panel can reuse the same creds — the user enters them +// once. Sent to the LOCAL backend (localhost) only, never persisted. +export interface FetchCreds { + backend: string; + username: string; + password: string; + duoMode: "push" | "passcode"; + duoPasscode: string; +} + // ── global appearance preferences ────────────────────────────────────────── // Each preference follows one shape: a persisted value → an `apply*` function // that mutates → the whole app restyles from CSS/plot layer. `theme` and @@ -75,6 +86,7 @@ interface State { cursorMs: number; // shared time cursor across views loadingMachines: boolean; theme: Theme; + fetchCreds: FetchCreds; // shared by PullControl + the QS custom-signal panel fontScale: number; init: () => Promise; @@ -85,6 +97,7 @@ interface State { setTab: (t: TabId) => void; setCursorMs: (t: number) => void; toggleTheme: () => void; + setFetchCreds: (patch: Partial) => void; setFontScale: (n: number) => void; } @@ -97,6 +110,14 @@ export const useStore = create((set) => ({ cursorMs: 0, loadingMachines: true, theme: loadTheme(), + // Default to the fast cluster path (remote); PullControl's device snap adjusts it. + fetchCreds: { + backend: "remote", + username: "", + password: "", + duoMode: "push", + duoPasscode: "", + }, fontScale: loadFontScale(), async init() { @@ -137,6 +158,7 @@ export const useStore = create((set) => ({ setDevice: (id) => set({ device: id }), setTab: (t) => set({ tab: t }), setCursorMs: (t) => set({ cursorMs: t }), + setFetchCreds: (patch) => set((s) => ({ fetchCreds: { ...s.fetchCreds, ...patch } })), toggleTheme: () => set((s) => { const theme: Theme = s.theme === "dark" ? "light" : "dark"; diff --git a/src/magnetics/core/qs_bridge.py b/src/magnetics/core/qs_bridge.py index fb9c271..3c42c88 100644 --- a/src/magnetics/core/qs_bridge.py +++ b/src/magnetics/core/qs_bridge.py @@ -312,7 +312,7 @@ def fit_to_phi_t_node(fit_ds, theta_fixed_deg: float = 0.0, n_phi: int = 73) -> "theta_fixed_deg": theta_fixed_deg, "condition_number": round(K, 2), "shot": str(fit_ds.attrs.get("shot", "")), - "note": "SLCONTOUR φ–t at fixed θ", + "note": "φ–t at fixed θ", }, ) diff --git a/src/magnetics/data/device/diiid.json b/src/magnetics/data/device/diiid.json index 00d3aab..4bb30a4 100644 --- a/src/magnetics/data/device/diiid.json +++ b/src/magnetics/data/device/diiid.json @@ -550,6 +550,24 @@ "tree": "efit01" } }, + "derived signals": { + "tree": "efit01", + "names": [ + "betan", + "betap", + "li", + "q95", + "qmin", + "wmhd", + "kappa", + "tribot", + "tritop", + "aminor", + "rout", + "volume", + "area" + ] + }, "sensors": { "MPI66M020D": { "segments": [ diff --git a/src/magnetics/data/fetch/remote.py b/src/magnetics/data/fetch/remote.py index 4131096..d064ad2 100644 --- a/src/magnetics/data/fetch/remote.py +++ b/src/magnetics/data/fetch/remote.py @@ -108,6 +108,7 @@ def run_remote( decimate=1, device="diiid", sensor_set=None, + raw_pointnames=None, local_out_dir=None, progress=None, ) -> str: @@ -213,7 +214,9 @@ def run(cmd, **kw): "--out", remote_out, ] - if sensor_set: + if raw_pointnames: + fetch += ["--pointnames", ",".join(raw_pointnames)] + elif sensor_set: fetch += ["--sensor-set", sensor_set] else: fetch += ["--analysis", analysis] diff --git a/src/magnetics/data/fetch/toksearch.py b/src/magnetics/data/fetch/toksearch.py index 7a29c49..a997118 100644 --- a/src/magnetics/data/fetch/toksearch.py +++ b/src/magnetics/data/fetch/toksearch.py @@ -167,6 +167,34 @@ def resolve_sensor_set(dev: dict, name: str, _seen=None) -> list[str]: return _dedup(out) +def split_custom_signals(dev: dict, names): + """Split GUI custom-signal names into (ptdata_pointnames, tree_signals). + + Most custom names are PTDATA pointnames (``ip``, ``bt``, …). Some — the EFIT + scalars like ``betan``/``li``/``q95`` — are NOT in PTDATA; they live in an + MDSplus tree (``\\top.results.aeqdsk:``, same place ``kappa`` comes from), + so ``ptdata2`` returns nothing and they'd read back "missing". The device file's + ``derived signals`` block ({"tree": , "names": [...]}) names those; any + listed name (case-insensitive) is routed to the tree-fetch path with the usual + bare-node + AEQDSK-fallback candidates. Unknown names stay PTDATA. + """ + derived = dev.get("derived signals", {}) or {} + dtree = derived.get("tree") + dnames = {n.lower() for n in derived.get("names", [])} if dtree else set() + pointnames: list[str] = [] + tree_signals: dict[str, list[tuple[str, str]]] = {} + for raw in names: + name = str(raw).strip() + if not name: + continue + if name.lower() in dnames: + _, cands = _plasma_signal({"name": name, "tree": dtree}) + tree_signals[name] = cands + else: + pointnames.append(name) + return _dedup(pointnames), tree_signals + + # A progress callback: (fraction_done in [0,1], human message) -> None. Progress = Callable[[float, str], None] @@ -1099,6 +1127,7 @@ def fetch_shot( backend: str = "mdsthin", device: str = "diiid", sensor_set: str | None = None, + raw_pointnames: list[str] | None = None, username: str | None = None, password: str | None = None, duo: str | None = None, @@ -1193,6 +1222,7 @@ def fetch_shot( decimate=decimate, device=device, sensor_set=sensor_set, + raw_pointnames=raw_pointnames, local_out_dir=(str(Path(out).parent) if out else None), progress=progress, **kw, @@ -1235,49 +1265,59 @@ def fetch_shot( "or use --tcp for a direct connection" ) - # Signal selection. A device sensor set (preferred) overrides the analysis - # sensor groups: pull the set's signals plus the device's plasma params. When - # no set is named, a device that drives selection through its own sensor sets - # (it declares an `arrays` block -- e.g. KSTAR, which has no DIII-D PTDATA - # analysis groups) defaults to its toroidal+poloidal arrays; otherwise fall - # back to the per-analysis groups (DIII-D). + # Signal selection. An explicit `raw_pointnames` list (GUI custom-signal panel: + # Ip, betan, …) wins over everything — no device sensor map, no plasma extras — + # so it merges cleanly into an existing shot file. Names are split into PTDATA + # pointnames vs EFIT/derived tree signals (betan, li, …) so tree-only quantities + # actually fetch. Otherwise a device sensor set (preferred) overrides the analysis + # sensor groups; a device that drives selection through its own sensor sets (an + # `arrays` block -- e.g. KSTAR, no DIII-D PTDATA groups) defaults to its + # toroidal+poloidal arrays; else the per-analysis groups (DIII-D). stride = max(1, int(decimate)) - if sensor_set: - set_names = [sensor_set] - elif dev.get("arrays"): - arr = dev["arrays"] - set_names = _dedup([s for s in (arr.get("toroidal"), arr.get("poloidal")) if s]) - else: - set_names = [] - - if set_names: - sensors = _dedup([s for name in set_names for s in resolve_sensor_set(dev, name)]) - # Always add the device's plasma params (current, toroidal field, - # elongation). Each entry is {"name": ..., "tree": }: a "tree" - # means the quantity lives in an MDSplus tree (e.g. EFIT elongation), so - # it's fetched by (tree, node) -- not as a PTDATA pointname. - extras: list[str] = [] - tree_signals: dict[str, list[tuple[str, str]]] = {} - for entry in dev.get("plasma pointnames", {}).values(): - name, cands = _plasma_signal(entry) - if cands: - tree_signals[name] = cands - else: - extras.append(name) - pointnames = _dedup(sensors + extras) - label = "+".join(set_names) - # Never decimate a set carrying raw bdot (dB/dt) probes -- corrupts FFTs. + if raw_pointnames: + pointnames, tree_signals = split_custom_signals(dev, raw_pointnames) + label = "custom" + # bdot / raw dB/dt probes end in "D"; never decimate them (corrupts FFTs). if stride > 1 and any(p.endswith("D") for p in pointnames): - progress(0.0, "decimation disabled (set has bdot signals)") + progress(0.0, "decimation disabled (custom set has bdot signals)") stride = 1 else: - # Per-analysis reduction policy: never decimate FFT-critical signals. - if stride > 1 and not ms.decimate_allowed(analysis): - progress(0.0, f"decimation disabled for {analysis}") - stride = 1 - pointnames = ms.signals_for(analysis) - tree_signals = ms.tree_signals_for(analysis) - label = analysis + if sensor_set: + set_names = [sensor_set] + elif dev.get("arrays"): + arr = dev["arrays"] + set_names = _dedup([s for s in (arr.get("toroidal"), arr.get("poloidal")) if s]) + else: + set_names = [] + + if set_names: + sensors = _dedup([s for name in set_names for s in resolve_sensor_set(dev, name)]) + # Always add the device's plasma params (current, toroidal field, + # elongation). Each entry is {"name": ..., "tree": }: a "tree" + # means the quantity lives in an MDSplus tree (e.g. EFIT elongation), so + # it's fetched by (tree, node) -- not as a PTDATA pointname. + extras: list[str] = [] + tree_signals: dict[str, list[tuple[str, str]]] = {} + for entry in dev.get("plasma pointnames", {}).values(): + name, cands = _plasma_signal(entry) + if cands: + tree_signals[name] = cands + else: + extras.append(name) + pointnames = _dedup(sensors + extras) + label = "+".join(set_names) + # Never decimate a set carrying raw bdot (dB/dt) probes -- corrupts FFTs. + if stride > 1 and any(p.endswith("D") for p in pointnames): + progress(0.0, "decimation disabled (set has bdot signals)") + stride = 1 + else: + # Per-analysis reduction policy: never decimate FFT-critical signals. + if stride > 1 and not ms.decimate_allowed(analysis): + progress(0.0, f"decimation disabled for {analysis}") + stride = 1 + pointnames = ms.signals_for(analysis) + tree_signals = ms.tree_signals_for(analysis) + label = analysis # Shot-aware pointname resolution: map canonical ids -> the pointnames valid # at THIS shot, dropping channels the shot can't have so we never query them. @@ -1583,6 +1623,12 @@ def main(argv=None) -> int: "given, pulls that set's signals + plasma current/field/" "elongation instead of the --analysis groups", ) + ap.add_argument( + "--pointnames", + default=None, + help="comma-separated PTDATA pointnames to fetch verbatim (e.g. 'ip,dalpha'); " + "overrides --sensor-set/--analysis and merges into an existing shot file", + ) ap.add_argument( "--gateway", default=None, @@ -1646,6 +1692,11 @@ def main(argv=None) -> int: backend=args.backend, device=args.device, sensor_set=args.sensor_set, + raw_pointnames=( + [p.strip() for p in args.pointnames.split(",") if p.strip()] + if args.pointnames + else None + ), username=args.username, duo=args.duo, gateway=args.gateway, diff --git a/src/magnetics/service/app.py b/src/magnetics/service/app.py index 688f1a8..393353f 100644 --- a/src/magnetics/service/app.py +++ b/src/magnetics/service/app.py @@ -203,6 +203,7 @@ class FetchRequest(BaseModel): # signal selection (None → fetcher defaults: device "diiid", analysis groups) device: str | None = None # data/device/.json sensor_set: str | None = None # a set under the device's sensor_sets; overrides analysis + signals: list[str] | None = None # custom PTDATA pointnames; merged into an existing shot file # remote backend overrides (None → device file's network.cluster block: explicit # omega.gat.com host + auto cybele ProxyJump + env python — no ssh-config alias) remote_host: str | None = None @@ -245,6 +246,9 @@ def post_fetch(req: FetchRequest) -> dict: for k, v in { "device": req.device, "sensor_set": req.sensor_set, + # custom-signal panel: fetch these PTDATA pointnames verbatim and merge + # them into the shot's existing HDF5 (skips the sensor-set/analysis map). + "raw_pointnames": req.signals, "remote_host": req.remote_host, "ssh_jump": req.ssh_jump, "remote_dir": req.remote_dir, diff --git a/src/magnetics/service/nodes.py b/src/magnetics/service/nodes.py index 147c509..54502e6 100644 --- a/src/magnetics/service/nodes.py +++ b/src/magnetics/service/nodes.py @@ -13,6 +13,7 @@ import dataclasses import logging +import re import threading from functools import lru_cache @@ -630,7 +631,7 @@ def _contour(shot, params=None) -> dict: meta={ "channels": n_ch, "shot": str(shot), - "note": "raw toroidal δBp(φ,t) — SLCONTOUR φ–θ fit pending", + "note": "raw toroidal δBp(φ,t) — φ–θ fit pending", }, ) @@ -1309,6 +1310,7 @@ def _qs_run( fit_basis: str, fit_cond: float, sigma: float | None, + fit_exclude: tuple = (), ): """Run the full SLCONTOUR pipeline (io_data → prep → fit) for one shot. @@ -1316,6 +1318,11 @@ def _qs_run( explicit cache-key arguments so the result is reused across node requests that share the same settings. tmin_s/tmax_s are in seconds and come from _prep_qs_ds (which reads HDF5 defaults and applies any user override). + + ``fit_exclude`` names channels to drop from the fit only (not from prep), so + the sensor maps and signal-conditioning plots still show excluded sensors — + the GUI greys them rather than hiding them. Each name is matched literally + (``fit.fit`` uses ``re.match``): anchored + ``re.escape``-d in _prep_qs_ds. """ from ..core.qs_run import run_steps @@ -1342,6 +1349,7 @@ def _qs_run( fit_basis=fit_basis, fit_cond=fit_cond, sigma_override=sigma, + **(dict(fit_exclude=fit_exclude) if fit_exclude else {}), ), verbose=False, ) @@ -1383,8 +1391,7 @@ def _prep_qs_ds(shot, params): # other devices instead of crashing deep in the SLCONTOUR shim. if _dev_geom(str(shot)).device_id != "diiid": raise ValueError( - "quasi-stationary (SLCONTOUR) analysis is DIII-D-only; " - "use the rotating-mode views for this device" + "quasi-stationary analysis is DIII-D-only; use the rotating-mode views for this device" ) ns_raw = params.get("ns", "1,2,3") if params else "1,2,3" @@ -1418,6 +1425,14 @@ def _prep_qs_ds(shot, params): sigma_str = params.get("sigma") if params else None sigma = float(sigma_str) if sigma_str is not None else None + # fit_exclude: comma-separated exact channel names the GUI checkbox-panel has + # deselected. fit.fit matches with re.match, so anchor + escape each name for a + # literal match. Excluded channels stay in prep (still drawn on the maps); only + # the fit drops them. Sort so the tuple is a stable _qs_run cache key. + excl_raw = params.get("fit_exclude", "") if params else "" + excl_names = sorted(x.strip() for x in str(excl_raw).split(",") if x.strip()) + fit_exclude = tuple(f"{re.escape(n)}$" for n in excl_names) + # Time trim: read shot-window defaults from HDF5, then apply any user override. path = h5source.shot_file(str(shot)) with h5py.File(str(path), "r") as f: @@ -1447,6 +1462,7 @@ def _prep_qs_ds(shot, params): fit_basis, fit_cond, sigma, + fit_exclude, ) @@ -1569,6 +1585,38 @@ def _fit_residuals(shot, params=None) -> dict: return qs_bridge.fit_to_fit_residuals_node(_prep_qs_ds(shot, params).fit) +def _extra_signals(shot, params=None) -> dict: + """User-requested raw signals (Ip, Dα, …) as time series → LineNode. + + The GUI custom-signal panel POSTs the names to /api/fetch (merged into the shot + 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. + """ + 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 = [], [], [] + for name in names: + if name not in have: + missing.append(name) + continue + t_ms, d = h5source.load_channel(str(shot), name) + t_ms = np.asarray(t_ms, dtype=float) + d = np.asarray(d, dtype=float) + if t_ms.size > 2000: # keep the line light + sel = np.linspace(0, t_ms.size - 1, 2000).astype(int) + t_ms, d = t_ms[sel], d[sel] + series.append({"name": name, "x": t_ms.tolist(), "y": d.tolist()}) + found.append(name) + + return contracts.line( + series, + {"x": "time (ms)", "y": "signal"}, + meta={"shot": str(shot), "found": found, "missing": missing, "requested": names}, + ) + + _BUILDERS = { "geometry": _geometry, "spectrogram": _spectrogram, @@ -1591,6 +1639,7 @@ def _fit_residuals(shot, params=None) -> dict: "svd_condition": _svd_design_condition, "fit_signals": _fit_signals, "fit_residuals": _fit_residuals, + "extra_signals": _extra_signals, # rotating eigspec (develop): GP mode shapes + patterns + tracks "mode_shape": _mode_shape, "poloidal_shape": _poloidal_shape, diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 6d2baac..ece9701 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -291,6 +291,22 @@ def test_unknown_node_raises(): nodes.build_node(shot, "does_not_exist") +def test_extra_signals_serves_found_and_reports_missing(): + """The custom-signal panel reads merged pointnames via `extra_signals`: a known + channel becomes a series; an unknown name is reported in meta.missing (not an + error), so the panel can warn without failing.""" + from magnetics.data import h5source + + shot = _first_shot() + known = h5source.channel_names(shot)[0] + node = nodes.build_node(shot, "extra_signals", {"signals": f"{known}, NOPE_NOT_A_REAL_SIGNAL"}) + assert node["kind"] == "line" + assert node["meta"]["found"] == [known] + assert "NOPE_NOT_A_REAL_SIGNAL" in node["meta"]["missing"] + assert [s["name"] for s in node["series"]] == [known] + assert len(node["series"][0]["x"]) == len(node["series"][0]["y"]) + + def test_quality_for_k_thresholds(): # mirrors contract.ts qualityForK assert contracts.quality_for_k(5) == "good" diff --git a/tests/test_qs_pipeline.py b/tests/test_qs_pipeline.py index 3a538a9..7ce7e67 100644 --- a/tests/test_qs_pipeline.py +++ b/tests/test_qs_pipeline.py @@ -131,3 +131,30 @@ def test_qs_fit_recovers_injected_mode_amplitudes(synthetic_shot): assert recovered_ratio == pytest.approx(injected_ratio, rel=0.2), ( f"recovered n=1/n=2 ratio {recovered_ratio:.3f} != injected {injected_ratio:.3f}" ) + + +def _channels_field(node) -> int: + for f in node["fields"]: + if f["label"] == "channels": + return int(f["value"]) + raise AssertionError(f"no 'channels' field in {node['fields']}") + + +def test_fit_exclude_drops_one_channel_from_the_fit(synthetic_shot): + """The GUI fit-channels checkboxes send a comma-separated ``fit_exclude``. A + single excluded channel must leave the fit with exactly one fewer channel while + staying a real (finite) fit — the channel is dropped from fit(), not prep().""" + base = nodes.build_node(synthetic_shot, "fit_quality") + n0 = _channels_field(base) + + # A real channel that IS in the fit — take one from the prepared signal pairs. + pairs = nodes.build_node(synthetic_shot, "signal_conditioning")["meta"]["pairs"] + assert pairs, "synthetic QS shot has no signal-conditioning pairs" + victim = pairs[0]["channel"] + + excl = nodes.build_node(synthetic_shot, "fit_quality", {"fit_exclude": victim}) + assert _channels_field(excl) == n0 - 1, (n0, victim) + + # The reduced fit is still finite (exclusion did not break the SVD). + fit = nodes.build_node(synthetic_shot, "qs_fit", {"fit_exclude": victim}) + assert np.all(np.isfinite(np.asarray(fit["z"], dtype=float))) diff --git a/tests/test_service_api.py b/tests/test_service_api.py index e7aa69c..3999c99 100644 --- a/tests/test_service_api.py +++ b/tests/test_service_api.py @@ -45,6 +45,34 @@ def test_unknown_node_id_is_404(synthetic_shot): assert r.status_code == 404 +def test_extra_signals_forwards_signals_query_param(synthetic_shot): + """The custom-signal panel reads /api/node/{shot}/extra_signals?signals=...; the + named channel comes back as a series, an unknown name lands in meta.missing.""" + from magnetics.data import h5source + + known = h5source.channel_names(synthetic_shot)[0] + r = client.get(f"/api/node/{synthetic_shot}/extra_signals", params={"signals": f"{known},NOPE"}) + assert r.status_code == 200, r.text[:200] + node = r.json() + assert node["meta"]["found"] == [known] + assert "NOPE" in node["meta"]["missing"] + + +def test_fit_exclude_query_param_reaches_the_fit(synthetic_shot): + """A fit_exclude query param drops the named channel from the fit_quality count.""" + + def channels(node): + return next(int(f["value"]) for f in node["fields"] if f["label"] == "channels") + + base = client.get(f"/api/node/{synthetic_shot}/fit_quality").json() + pairs = client.get(f"/api/node/{synthetic_shot}/signal_conditioning").json()["meta"]["pairs"] + victim = pairs[0]["channel"] + excl = client.get( + f"/api/node/{synthetic_shot}/fit_quality", params={"fit_exclude": victim} + ).json() + assert channels(excl) == channels(base) - 1 + + def test_unknown_shot_is_404(): r = client.get("/api/node/100/geometry") assert r.status_code == 404 diff --git a/tests/test_toksearch_tree.py b/tests/test_toksearch_tree.py index a5a56c6..c72c089 100644 --- a/tests/test_toksearch_tree.py +++ b/tests/test_toksearch_tree.py @@ -284,3 +284,28 @@ def test_window_trim_is_applied_to_tree_signal(): assert len(out) == 1 and out[0].ok np.testing.assert_allclose(out[0].time, [2500.0, 3000.0]) np.testing.assert_allclose(out[0].data, [1.4, 1.5]) + + +def test_split_custom_signals_routes_efit_scalars_to_the_tree(): + """GUI custom signals: PTDATA pointnames stay PTDATA, but EFIT scalars named in + the device's `derived signals` (betan, q95, …) route to the tree-fetch path with + the AEQDSK-fallback node — otherwise ptdata2 returns nothing and they read back + 'missing' (the betan bug).""" + from magnetics.data.devices import load_device + from magnetics.data.fetch.toksearch import split_custom_signals + + dev = load_device("diiid") + pts, trees = split_custom_signals(dev, ["Ip", "betan", "bt", "BETAN"]) + + assert pts == ["Ip", "bt"] # PTDATA, order-preserving + deduped + assert "betan" in trees and "BETAN" in trees # case-insensitive membership + # the real node lives under the AEQDSK results path — must be a candidate + assert ("efit01", r"\top.results.aeqdsk:betan") in trees["betan"] + + +def test_split_custom_signals_no_catalog_is_all_ptdata(): + """A device without a `derived signals` block routes everything to PTDATA.""" + from magnetics.data.fetch.toksearch import split_custom_signals + + pts, trees = split_custom_signals({}, ["ip", "betan"]) + assert pts == ["ip", "betan"] and trees == {}