From a9a3b6746b928f499d753bb6e5af4a0ca27b562f Mon Sep 17 00:00:00 2001 From: logan-nc <6198372+logan-nc@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:19:37 -0400 Subject: [PATCH 1/3] QS backend: fit_exclude + custom-signal fetch/node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread a `fit_exclude` query param through _prep_qs_ds → _qs_run (cache key) → run_steps(fit_kwargs) → fit.fit(), anchored+escaped for literal channel matches. It only affects the fit, so excluded channels stay in `prepared` (the GUI greys them on the sensor maps rather than dropping them). Add a custom-signal path: `raw_pointnames` on fetch_shot pulls arbitrary PTDATA pointnames verbatim and merges them into an existing shot HDF5 (plumbed through the --pointnames CLI and run_remote for the cluster path); a `signals` field on FetchRequest wires it to /api/fetch; a new `extra_signals` node serves the merged signals as one series each, with found/missing reported in meta. Co-Authored-By: Claude Opus 4.8 --- src/magnetics/core/qs_bridge.py | 2 +- src/magnetics/data/fetch/remote.py | 5 ++- src/magnetics/data/fetch/toksearch.py | 29 ++++++++++++-- src/magnetics/service/app.py | 4 ++ src/magnetics/service/nodes.py | 55 +++++++++++++++++++++++++-- tests/test_nodes.py | 16 ++++++++ tests/test_qs_pipeline.py | 27 +++++++++++++ tests/test_service_api.py | 28 ++++++++++++++ 8 files changed, 158 insertions(+), 8 deletions(-) diff --git a/src/magnetics/core/qs_bridge.py b/src/magnetics/core/qs_bridge.py index 13358c0..1d3e7e1 100644 --- a/src/magnetics/core/qs_bridge.py +++ b/src/magnetics/core/qs_bridge.py @@ -309,7 +309,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/fetch/remote.py b/src/magnetics/data/fetch/remote.py index f8d8ff2..83f944a 100644 --- a/src/magnetics/data/fetch/remote.py +++ b/src/magnetics/data/fetch/remote.py @@ -85,6 +85,7 @@ def run_remote( decimate=1, device="diiid", sensor_set=None, + raw_pointnames=None, local_out_dir=None, progress=None, ) -> str: @@ -172,7 +173,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 f4ec417..417ec51 100644 --- a/src/magnetics/data/fetch/toksearch.py +++ b/src/magnetics/data/fetch/toksearch.py @@ -1016,6 +1016,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, @@ -1101,6 +1102,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, @@ -1144,10 +1146,20 @@ 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. + # Signal selection. An explicit `raw_pointnames` list (GUI custom-signal panel: + # Ip, dalpha, …) wins over everything and is fetched verbatim as PTDATA — no + # device sensor map, no plasma extras — so it merges cleanly into an existing + # shot file. A device sensor set is next; the analysis groups are the default. stride = max(1, int(decimate)) - if sensor_set: + if raw_pointnames: + pointnames = _dedup([p.strip() for p in raw_pointnames if p and p.strip()]) + tree_signals = {} + 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 (custom set has bdot signals)") + stride = 1 + elif sensor_set: sensors = resolve_sensor_set(dev, sensor_set) # Always add the device's plasma params (current, toroidal field, # elongation). Each entry is {"name": ..., "tree": }: a "tree" @@ -1426,6 +1438,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, @@ -1489,6 +1507,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, gateway=args.gateway, server=args.server, diff --git a/src/magnetics/service/app.py b/src/magnetics/service/app.py index ebee93b..dd6ac3f 100644 --- a/src/magnetics/service/app.py +++ b/src/magnetics/service/app.py @@ -170,6 +170,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 @@ -212,6 +213,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 105d947..96585ec 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 @@ -514,7 +515,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", }, ) @@ -1178,6 +1179,7 @@ def _qs_run( energy: float, tmin_s: float, tmax_s: float, + fit_exclude: tuple = (), ): """Run the full SLCONTOUR pipeline (io_data → prep → fit) for one shot. @@ -1185,6 +1187,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 .._slcontour.run import run_steps @@ -1207,6 +1214,7 @@ def _qs_run( detrend_type=detrend_type, detrend_band=(db_lo_s, db_hi_s), ), + fit_kwargs=dict(fit_exclude=fit_exclude) if fit_exclude else None, verbose=False, ) return r @@ -1247,8 +1255,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" @@ -1271,6 +1278,14 @@ def _prep_qs_ds(shot, params): cutoff_hi = float(params.get("cutoff_hi", 250.0)) if params else 250.0 energy = float(params.get("energy", 0.98)) if params else 0.98 + # 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: @@ -1297,6 +1312,7 @@ def _prep_qs_ds(shot, params): energy, tmin_s, tmax_s, + fit_exclude, ) @@ -1409,6 +1425,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, @@ -1429,6 +1477,7 @@ def _fit_residuals(shot, params=None) -> dict: "chi_sq_t": _chi_sq_t, "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 7786212..3c839ef 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -226,6 +226,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 8b6b6df..a642d84 100644 --- a/tests/test_qs_pipeline.py +++ b/tests/test_qs_pipeline.py @@ -95,3 +95,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 From 77b1cc97a2732a238d48016dedffbe51f6a2e7d8 Mon Sep 17 00:00:00 2001 From: logan-nc <6198372+logan-nc@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:19:46 -0400 Subject: [PATCH 2/3] QS tab: stacked signals, fit-channel excludes, custom-signal panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the raw+prepared sensor-signals plot into its own section with an overlay⇄stack toggle (one tightly-packed axes per sensor; only the bottom axes labels the time axis). Add a collapsed "fit channels" checkbox panel whose deselections ride the deferred Plot commit as `fit_exclude` and grey the excluded sensors on the R-Z and phi-theta maps. Add a collapsed custom-signal panel above the sensor signals: fetch a list of pointnames (streamed progress) into the shot's HDF5, then plot each via the `extra_signals` node in its own axes. Lift backend/credentials into a shared `fetchCreds` store slice so PullControl and the panel share one entry. Co-Authored-By: Claude Opus 4.8 --- gui/web/src/components/PullControl.tsx | 25 +- .../components/tabs/QuasiStationaryTab.tsx | 510 +++++++++++++----- gui/web/src/lib/api.ts | 1 + gui/web/src/lib/contract.test.ts | 4 +- gui/web/src/lib/contract.ts | 4 +- gui/web/src/store.ts | 24 + 6 files changed, 406 insertions(+), 162 deletions(-) diff --git a/gui/web/src/components/PullControl.tsx b/gui/web/src/components/PullControl.tsx index 206635e..c0dd7c7 100644 --- a/gui/web/src/components/PullControl.tsx +++ b/gui/web/src/components/PullControl.tsx @@ -15,16 +15,20 @@ export default function PullControl() { const setMachine = useStore((s) => s.setMachine); 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: a push (sends "1") or a typed passcode. A dropdown makes the - // choice explicit; the passcode box only appears when "passcode" is selected. - 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: a push + // (sends "1") or a typed passcode; the passcode box only shows for "passcode". + const fetchCreds = useStore((s) => s.fetchCreds); + const setFetchCreds = useStore((s) => s.setFetchCreds); + const { backend, username, password, duoMode, duoPasscode, deviceId } = 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 }); + const setDeviceId = (v: string) => setFetchCreds({ deviceId: 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 @@ -35,7 +39,6 @@ export default function PullControl() { // Optional sensor-set override: "" = pull the analysis groups (default); a named // set (from the device's sensor_sets) overrides analysis and pulls that array. const [devices, setDevices] = useState([]); - const [deviceId, setDeviceId] = useState(""); const [sensorSet, setSensorSet] = useState(""); const [busy, setBusy] = useState(false); const [frac, setFrac] = useState(0); diff --git a/gui/web/src/components/tabs/QuasiStationaryTab.tsx b/gui/web/src/components/tabs/QuasiStationaryTab.tsx index 98c0044..a0ea41e 100644 --- a/gui/web/src/components/tabs/QuasiStationaryTab.tsx +++ b/gui/web/src/components/tabs/QuasiStationaryTab.tsx @@ -1,9 +1,10 @@ // Quasi-stationary view — OWNED BY TEAMMATE A. // Branch: gui-quasistationary — build here, PR into `gui`. -// VISION §4.1, §7. Summaries: 04_SLCONTOUR_summary2019, 08_Slcontour_II_2023. -import { useCallback, useEffect, useMemo, useState } from "react"; +// VISION §4.1, §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"; @@ -13,6 +14,19 @@ import { phiPeak as phiPeakFn, phiRms as phiRmsFn } from "../../lib/qsTransforms // ── 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"]; @@ -144,7 +158,23 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { // ── Section collapse state ──────────────────────────────────────── const [fitQualityOpen, setFitQualityOpen] = useState(false); - const [sensorMapOpen, setSensorMapOpen] = useState(false); + const [channelsMapOpen, setChannelsMapOpen] = useState(false); // fit-channel excludes + φ-θ map + const [customOpen, setCustomOpen] = useState(false); // custom-signal panel + + // ── 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); @@ -161,8 +191,11 @@ 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]); + }, [ns, ms, channelFilter, detrendType, detrendLo, detrendHi, tminMs, tmaxMs, excludedChannels]); useEffect(() => { if (cursorMs === 0) setCursorMs(3140); @@ -178,6 +211,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 @@ -213,8 +252,8 @@ 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 map (φ-θ only; the R-Z cross-section lives in the Sensors tab), signal + // conditioning, fit quality time series. 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 ?? {}); @@ -222,38 +261,116 @@ 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; const fitError = ampError?.replace(/^Error:\s*fetch failed \(\d+\):\s*/, "") ?? ""; - const sensorRzNode = sensorRzRaw?.kind === "line" ? (sensorRzRaw as LineNode) : null; const sensorCylNode = sensorCylRaw?.kind === "line" ? (sensorCylRaw as LineNode) : null; const signalNode = signalRaw?.kind === "line" ? (signalRaw as LineNode) : null; const chiSqNode = chiSqRaw?.kind === "line" ? (chiSqRaw as LineNode) : null; const fitResNode = fitResRaw?.kind === "line" ? (fitResRaw as LineNode) : null; - // ── Channel checkboxes for signal conditioning ──────────────────── - const [enabledChannels, setEnabledChannels] = useState>(new Set()); - useEffect(() => { - if (!signalNode) return; - const pairs = signalNode.meta?.pairs as { channel: string }[] | undefined; - if (pairs && enabledChannels.size === 0) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- one-time init of the channel set when the node first loads - setEnabledChannels(new Set(pairs.map(p => p.channel))); - } - // Only populate when channel list first loads. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [signalNode]); - - const toggleChannel = useCallback((ch: string) => { - setEnabledChannels(prev => { - const next = new Set(prev); - if (next.has(ch)) next.delete(ch); else next.add(ch); - return next; - }); - }, []); + // ── Custom user signals (Ip, Dα, …): fetch → merge into the h5 → plot ───── + const fetchCreds = useStore((s) => s.fetchCreds); + 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 + + 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: fetchCreds.deviceId || 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, fetchCreds, credsMissing]); const phiTimePlot = phiTimeNode?.kind === "contour" ? (phiTimeNode as ContourNode) : null; @@ -293,35 +410,7 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { [timeRange, tMin, tMax], ); - // ── Sensor map plots ────────────────────────────────────────────── - const sensorRzData = useMemo((): Partial[] => { - if (!sensorRzNode) return []; - const traces: Partial[] = sensorRzNode.series.map((s, i) => ({ - type: "scatter" as const, mode: "lines" as const, - name: s.name, x: s.x, y: s.y, - line: { color: LINE_PALETTE[i % LINE_PALETTE.length], width: 2 }, - } as Partial)); - const wall = sensorRzNode.meta?.wall as { x: number[]; y: number[] } | null; - if (wall) { - traces.unshift({ - type: "scatter" as const, mode: "lines" as const, - name: "wall", x: wall.x, y: wall.y, - line: { color: dark ? "#888" : "#555", width: 1 }, - showlegend: false, - } as Partial); - } - return traces; - }, [sensorRzNode, dark]); - - const sensorRzLayout = useMemo(() => - themedLayout(dark, { - xaxis: { title: { text: sensorRzNode?.axes.x ?? "R (m)" }, scaleanchor: "y" as const }, - yaxis: { title: { text: sensorRzNode?.axes.y ?? "z (m)" } }, - showlegend: false, - margin: { t: 4, b: 40, l: 48, r: 8 }, - } as Partial), - [dark, sensorRzNode]); - + // ── Sensor map plot (φ-θ unrolled) ──────────────────────────────── // Remap phi values from 0–360 to –180–180 for the unrolled plot. const sensorCylData = useMemo((): Partial[] => { if (!sensorCylNode) return []; @@ -330,9 +419,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(() => themedLayout(dark, { @@ -362,48 +453,55 @@ 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); + 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 } : {}) }, + } 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, + } as Partial); + } return traces; - }, [signalNode, enabledChannels]); + }, [signalNode, excludedChannels]); + + 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 ? themedLayout(dark, { - xaxis: { ...timeXAxis, title: { text: signalNode.axes.x }, showticklabels: false }, + xaxis: { ...timeXAxis, title: { text: signalNode.axes.x } }, 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) : {}, [dark, signalNode, timeXAxis, sharedSigResRange]); @@ -558,9 +656,6 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { } as Partial) : {}, [dark, phaseTimeNode, timeXAxis]); - // ── Signal conditioning channel pairs ───────────────────────────── - const signalPairs = signalNode?.meta?.pairs as { channel: string; prepared_idx: number; raw_idx: number }[] | undefined; - return (
@@ -646,13 +741,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 */} @@ -697,7 +835,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 PTDATA pointnames — 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 PTDATA pointnames (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 @@ -705,12 +971,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…
@@ -721,57 +982,12 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { :
loading χ²…
}
-
- {signalPairs && ( -
-
channels
- {signalPairs.map((pair, i) => ( - - ))} -
- )} +
{qualityNode && }
)}
- - {/* ── Section A: Sensor Map — collapsible, at bottom ────────────── */} -
- setSensorMapOpen(o => !o)}> - sensor map · {channelFilter} - - {sensorMapOpen && ( -
-
-
cross-section (R-Z)
- {sensorRzNode - ? - :
loading…
- } -
-
-
unrolled φ-θ
- {sensorCylNode - ? - :
loading…
- } -
-
- )} -
)}
); diff --git a/gui/web/src/lib/api.ts b/gui/web/src/lib/api.ts index 7adcc7a..88ab361 100644 --- a/gui/web/src/lib/api.ts +++ b/gui/web/src/lib/api.ts @@ -86,6 +86,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 } /** A device config (data/device/.json) the backend can fetch from. */ 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 927b5cc..a8f144c 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 b50bf8a..c2b30ff 100644 --- a/gui/web/src/store.ts +++ b/gui/web/src/store.ts @@ -8,6 +8,18 @@ import { fetchMachines, type MachineInfo } from "./lib/api"; 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; + deviceId: string; +} + // Theme is a single global switch: it drives the `data-theme` attribute on // (so theme.css restyles the chrome) and is read by the plot layer and // Meg's useDarkMode() hook, so one toggle re-skins everything at once. @@ -32,12 +44,14 @@ interface State { cursorMs: number; // shared time cursor across views loadingMachines: boolean; theme: Theme; + fetchCreds: FetchCreds; // shared by PullControl + the QS custom-signal panel init: () => Promise; setMachine: (id: string) => void; setTab: (t: TabId) => void; setCursorMs: (t: number) => void; toggleTheme: () => void; + setFetchCreds: (patch: Partial) => void; } export const useStore = create((set) => ({ @@ -47,6 +61,15 @@ 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: "", + deviceId: "", + }, async init() { const machines = await fetchMachines(); @@ -59,6 +82,7 @@ export const useStore = create((set) => ({ setMachine: (id) => set({ machine: 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"; From cc007377902ea44656ba4270fd657a17e71ca379 Mon Sep 17 00:00:00 2001 From: logan-nc <6198372+logan-nc@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:21:11 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fetch:=20route=20EFIT=20scalars=20(betan,?= =?UTF-8?q?=20=E2=80=A6)=20to=20the=20tree=20path=20for=20custom=20signals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom-signal names were all fetched via ptdata2, but EFIT scalars like betan / li / q95 aren't PTDATA — they live in an MDSplus tree (\top.results.aeqdsk:, same place kappa comes from), so ptdata2 returned nothing and they read back "missing". Add a `derived signals` catalog to the device file and split custom names into PTDATA pointnames vs tree signals (split_custom_signals), routing the listed EFIT scalars through the existing openTree→get fetch. Broaden the GUI help text to "signal names — PTDATA pointnames or EFIT scalars". Co-Authored-By: Claude Opus 4.8 --- .../components/tabs/QuasiStationaryTab.tsx | 10 ++--- src/magnetics/data/device/diiid.json | 18 +++++++++ src/magnetics/data/fetch/toksearch.py | 38 ++++++++++++++++--- tests/test_toksearch_tree.py | 25 ++++++++++++ 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/gui/web/src/components/tabs/QuasiStationaryTab.tsx b/gui/web/src/components/tabs/QuasiStationaryTab.tsx index a0ea41e..f6d8eee 100644 --- a/gui/web/src/components/tabs/QuasiStationaryTab.tsx +++ b/gui/web/src/components/tabs/QuasiStationaryTab.tsx @@ -843,15 +843,15 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) { {customOpen && (
- Enter one or more PTDATA pointnames — 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. + 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 PTDATA pointnames (letters, digits, underscore). Example: 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(); }} 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/toksearch.py b/src/magnetics/data/fetch/toksearch.py index 417ec51..9143c03 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] @@ -1147,13 +1175,13 @@ def fetch_shot( ) # Signal selection. An explicit `raw_pointnames` list (GUI custom-signal panel: - # Ip, dalpha, …) wins over everything and is fetched verbatim as PTDATA — no - # device sensor map, no plasma extras — so it merges cleanly into an existing - # shot file. A device sensor set is next; the analysis groups are the default. + # 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. A device sensor set is next; the analysis groups are the default. stride = max(1, int(decimate)) if raw_pointnames: - pointnames = _dedup([p.strip() for p in raw_pointnames if p and p.strip()]) - tree_signals = {} + 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): 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 == {}