Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions gui/web/src/components/PullControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
424 changes: 374 additions & 50 deletions gui/web/src/components/tabs/QuasiStationaryTab.tsx

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions gui/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export interface FetchBody {
decimate?: number;
device?: string; // data/device/<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
}
Expand Down
4 changes: 2 additions & 2 deletions gui/web/src/lib/contract.test.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand Down
4 changes: 2 additions & 2 deletions gui/web/src/lib/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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";
Expand Down
22 changes: 22 additions & 0 deletions gui/web/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html> → the whole app restyles from CSS/plot layer. `theme` and
Expand Down Expand Up @@ -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<void>;
Expand All @@ -85,6 +97,7 @@ interface State {
setTab: (t: TabId) => void;
setCursorMs: (t: number) => void;
toggleTheme: () => void;
setFetchCreds: (patch: Partial<FetchCreds>) => void;
setFontScale: (n: number) => void;
}

Expand All @@ -97,6 +110,14 @@ export const useStore = create<State>((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() {
Expand Down Expand Up @@ -137,6 +158,7 @@ export const useStore = create<State>((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";
Expand Down
2 changes: 1 addition & 1 deletion src/magnetics/core/qs_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 θ",
},
)

Expand Down
18 changes: 18 additions & 0 deletions src/magnetics/data/device/diiid.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
5 changes: 4 additions & 1 deletion src/magnetics/data/fetch/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ def run_remote(
decimate=1,
device="diiid",
sensor_set=None,
raw_pointnames=None,
local_out_dir=None,
progress=None,
) -> str:
Expand Down Expand Up @@ -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]
Expand Down
129 changes: 90 additions & 39 deletions src/magnetics/data/fetch/toksearch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>``, same place ``kappa`` comes from),
so ``ptdata2`` returns nothing and they'd read back "missing". The device file's
``derived signals`` block ({"tree": <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]

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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": <optional>}: 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": <optional>}: 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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/magnetics/service/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ class FetchRequest(BaseModel):
# signal selection (None → fetcher defaults: device "diiid", analysis groups)
device: str | None = None # data/device/<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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading