Skip to content
Open
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ src/magnetics/service/webapp/
data/cache/
*.nc

# Per-shot bad-channel exclusions (#56): local operator state written next to the
# shot data at runtime, not repo content.
data/exclusions.json

# HDF5 shot data — large, fetched/generated locally, not committed
*.h5
*.hdf5
Expand Down
156 changes: 134 additions & 22 deletions gui/web/src/components/tabs/SensorsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// The node is a scatter2d (R-Z points) whose `meta` carries the full per-sensor
// records + the vessel outline; the backend owns every device specific (which
// family is Bp vs a saddle loop, the wall shape), so this view is device-agnostic.
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type * as Plotly from "plotly.js";
import { useStore } from "../../store";
import { useNode } from "../../lib/useNode";
Expand Down Expand Up @@ -102,6 +102,9 @@ export default function SensorsTab({ machine }: { machine: string }) {
const wallInk = dark ? "rgba(255,255,255,0.30)" : "rgba(20,34,46,0.35)";
const fluxInk = dark ? "rgba(120,170,255,0.55)" : "rgba(40,90,180,0.55)";
const vvInk = dark ? "rgba(255,255,255,0.16)" : "rgba(20,34,46,0.18)";
// Excluded sensors: desaturated so they read as inactive against every kind's
// colour, but still clearly visible (you must be able to find and restore one).
const excludedInk = dark ? "rgba(255,255,255,0.45)" : "rgba(20,34,46,0.40)";
// 3D vessel shell — brighter/whiter than the 2D outline so it reads in the
// dark scene; lighter mode bumped up slightly too.
const wallSurface = dark ? "rgb(225,232,245)" : "rgb(70,90,110)";
Expand Down Expand Up @@ -167,6 +170,26 @@ export default function SensorsTab({ machine }: { machine: string }) {
return meta.sensors.filter((s) => names.has(s.name));
}, [meta, sets, selectedSets]);

// ── bad-channel exclusions (#56) ──────────────────────────────────────────
// Click a sensor to drop it from BOTH analyses. Excluded sensors stay on the
// map, greyed, so you can see what you dropped and click it back in.
const excluded = useStore((s) => s.excluded);
const loadExclusions = useStore((s) => s.loadExclusions);
const toggleExcluded = useStore((s) => s.toggleExcluded);
const clearExclusions = useStore((s) => s.clearExclusions);
useEffect(() => {
if (machine) void loadExclusions(machine);
}, [machine, loadExclusions]);
const excludedSet = useMemo(() => new Set(excluded), [excluded]);
const onSensorClick = useCallback(
(e: Plotly.PlotMouseEvent) => {
// `text` carries the sensor name on every sensor trace (points and loops).
const name = e.points?.[0]?.text;
if (machine && typeof name === "string" && name) void toggleExcluded(machine, name);
},
[machine, toggleExcluded],
);

const traces2d = useMemo<Partial<Plotly.PlotData>[]>(() => {
if (!meta) return [];
const t: Partial<Plotly.PlotData>[] = [];
Expand Down Expand Up @@ -232,36 +255,77 @@ export default function SensorsTab({ machine }: { machine: string }) {
let legendShown = false;

if (points.length) {
t.push({
type: "scatter", mode: "markers", name: KIND_LABEL[kind],
legendgroup: kind, x: points.map((s) => s.r), y: points.map((s) => s.z),
text: points.map((s) => s.name), hoverinfo: "text",
marker: { size: 6, color: COLOR[kind], line: { color: wallInk, width: 0.5 } },
} as Partial<Plotly.PlotData>);
legendShown = true;
// Split live vs excluded so a dropped probe reads as inactive at a glance
// (grey, hollow) while staying clickable to put it back.
const live = points.filter((s) => !excludedSet.has(s.name));
const dead = points.filter((s) => excludedSet.has(s.name));
if (live.length) {
t.push({
type: "scatter", mode: "markers", name: KIND_LABEL[kind],
legendgroup: kind, x: live.map((s) => s.r), y: live.map((s) => s.z),
text: live.map((s) => s.name), hoverinfo: "text",
marker: { size: 6, color: COLOR[kind], line: { color: wallInk, width: 0.5 } },
} as Partial<Plotly.PlotData>);
legendShown = true;
}
if (dead.length) {
t.push({
type: "scatter", mode: "markers", name: `${KIND_LABEL[kind]} (excluded)`,
legendgroup: kind, showlegend: false,
x: dead.map((s) => s.r), y: dead.map((s) => s.z),
text: dead.map((s) => `${s.name} — excluded (click to restore)`),
hoverinfo: "text",
marker: {
size: 7, symbol: "circle-open", color: excludedInk,
line: { color: excludedInk, width: 1.5 },
},
} as Partial<Plotly.PlotData>);
legendShown = true;
}
}
if (loops.length) {
// Each loop projects onto R-Z as a segment of its poloidal length, oriented
// by the sensor's own tilt (its real angle in the R-Z plane, measured from
// +R toward +Z) — NOT the vessel tangent, which points off-midplane loops
// the wrong way. The segment is symmetric, so tilt's sign/wrap is moot.
const x: (number | null)[] = [], y: (number | null)[] = [], txt: (string | null)[] = [];
for (const s of loops) {
const seg = loopSegment2d(s);
x.push(seg.x[0], seg.x[1], null);
y.push(seg.y[0], seg.y[1], null);
txt.push(s.name, s.name, null);
// Excluded loops go in their own greyed trace, same as the point markers.
const seg2 = (group: Sensor[]) => {
const x: (number | null)[] = [], y: (number | null)[] = [], txt: (string | null)[] = [];
for (const s of group) {
const seg = loopSegment2d(s);
x.push(seg.x[0], seg.x[1], null);
y.push(seg.y[0], seg.y[1], null);
txt.push(s.name, s.name, null);
}
return { x, y, txt };
};
const liveLoops = loops.filter((s) => !excludedSet.has(s.name));
const deadLoops = loops.filter((s) => excludedSet.has(s.name));
if (liveLoops.length) {
const { x, y, txt } = seg2(liveLoops);
t.push({
type: "scatter", mode: "lines", name: KIND_LABEL[kind],
legendgroup: kind, showlegend: !legendShown,
x, y, text: txt, hoverinfo: "text",
line: { color: COLOR[kind], width: 2.5 },
} as Partial<Plotly.PlotData>);
}
if (deadLoops.length) {
const { x, y, txt } = seg2(deadLoops);
t.push({
type: "scatter", mode: "lines", name: `${KIND_LABEL[kind]} (excluded)`,
legendgroup: kind, showlegend: false,
x, y, text: txt, hoverinfo: "text",
line: { color: excludedInk, width: 2, dash: "dot" },
} as Partial<Plotly.PlotData>);
}
t.push({
type: "scatter", mode: "lines", name: KIND_LABEL[kind],
legendgroup: kind, showlegend: !legendShown,
x, y, text: txt, hoverinfo: "text",
line: { color: COLOR[kind], width: 2.5 },
} as Partial<Plotly.PlotData>);
}
}
return t;
}, [meta, wallInk, visibleSensors, equilibrium, fluxInk, showVV, showCoils, vvInk]);
}, [
meta, wallInk, visibleSensors, equilibrium, fluxInk, showVV, showCoils, vvInk,
excludedSet, excludedInk,
]);

const traces3d = useMemo<Partial<Plotly.PlotData>[]>(() => {
if (!meta) return [];
Expand Down Expand Up @@ -436,11 +500,59 @@ export default function SensorsTab({ machine }: { machine: string }) {
</div>
</div>

{/* Bad-channel exclusions (#56): what's currently dropped from every
analysis, and the way back. Only shown once something is excluded —
the hint below the plot covers discovery. */}
{excluded.length > 0 && (
<div
style={{
display: "flex", alignItems: "center", flexWrap: "wrap", gap: 8,
margin: "8px 0", padding: "6px 10px", borderRadius: 6,
background: dark ? "rgba(255,180,80,0.10)" : "rgba(180,110,20,0.10)",
border: `1px solid ${dark ? "rgba(255,180,80,0.30)" : "rgba(180,110,20,0.30)"}`,
fontSize: "0.85em",
}}
>
<strong>{excluded.length} sensor{excluded.length === 1 ? "" : "s"} excluded</strong>
<span style={{ opacity: 0.85 }}>from all analysis:</span>
{excluded.map((name) => (
<button
key={name}
type="button"
title="Restore this sensor"
onClick={() => machine && void toggleExcluded(machine, name)}
style={{
font: "inherit", cursor: "pointer", padding: "1px 6px", borderRadius: 4,
border: "1px solid currentColor", background: "transparent", color: "inherit",
opacity: 0.9,
}}
>
{name} ✕
</button>
))}
<button
type="button"
onClick={() => machine && void clearExclusions(machine)}
style={{
font: "inherit", cursor: "pointer", padding: "1px 8px", borderRadius: 4,
border: "1px solid currentColor", background: "transparent", color: "inherit",
marginLeft: "auto",
}}
>
restore all
</button>
</div>
)}

{/* No time cursor here: sensor geometry is shot-static, and the only
time-dependent overlay (equilibrium) is a future backend node (#43). */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 16 }}>
<div style={{ flex: "1 1 360px", minWidth: 320 }}>
<Plot height={460} data={traces2d} config={PAN_CONFIG} layout={LAYOUT_2D} exportName={`shot_${machine}_sensors_2d`} />
<Plot height={460} data={traces2d} config={PAN_CONFIG} layout={LAYOUT_2D} onClick={onSensorClick} exportName={`shot_${machine}_sensors_2d`} />
<div style={{ fontSize: "0.8em", opacity: 0.7, marginTop: 4 }}>
Click a sensor to exclude it from all analysis (bad channel); click
again to restore. Excluded sensors stay on the map, greyed.
</div>
</div>
<div style={{ flex: "1 1 360px", minWidth: 320 }}>
<Plot height={460} data={traces3d} layout={LAYOUT_3D} exportName={`shot_${machine}_sensors_3d`} />
Expand Down
20 changes: 20 additions & 0 deletions gui/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,26 @@ export async function fetchChannelUsage(shot: string): Promise<ChannelUsage | nu
return getJSON<ChannelUsage>(`${API_BASE}/api/channels/${shot}`);
}

/** Channels the operator has marked bad for a shot (#56). Dropped from BOTH
* analyses; still drawn (greyed) on the sensor map so you can see what went. */
export async function fetchExclusions(shot: string): Promise<string[]> {
if (!LIVE) return [];
const r = await getJSON<{ excluded: string[] }>(`${API_BASE}/api/exclusions/${shot}`);
return r.excluded ?? [];
}

/** Replace a shot's exclusion set. Returns the stored (sorted) list. */
export async function saveExclusions(shot: string, excluded: string[]): Promise<string[]> {
if (!LIVE) throw new Error("no live backend (run the packaged app or set VITE_API_BASE)");
const res = await fetch(`${API_BASE}/api/exclusions/${shot}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ excluded }),
});
if (!res.ok) throw new Error(`save failed (${res.status}): ${await res.text()}`);
return ((await res.json()) as { excluded: string[] }).excluded ?? [];
}

/** Parameters for a live shot pull (POST /api/fetch). */
export interface FetchBody {
shot: number;
Expand Down
98 changes: 98 additions & 0 deletions gui/web/src/lib/exclusions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Bad-channel exclusions (#56) — the store half.
//
// The server owns the exclusion set; the store mirrors it for the Sensors view
// and bumps `excludedRev` so every open view re-fits. The parts worth pinning:
// the optimistic update greys the sensor immediately, and a FAILED save rolls
// back — a map showing an exclusion the analyses aren't honoring is a lie.
import { beforeEach, describe, expect, it, vi } from "vitest";

const fetchExclusions = vi.fn();
const saveExclusions = vi.fn();
vi.mock("./api", () => ({
fetchExclusions: (...a: unknown[]) => fetchExclusions(...a),
saveExclusions: (...a: unknown[]) => saveExclusions(...a),
// the store imports these at module load; never called in these tests
fetchMachines: vi.fn().mockResolvedValue([]),
fetchDevices: vi.fn().mockResolvedValue([]),
deleteMachine: vi.fn(),
deleteAllMachines: vi.fn(),
}));

import { useStore } from "../store";

beforeEach(() => {
fetchExclusions.mockReset();
saveExclusions.mockReset();
useStore.setState({ excluded: [], excludedRev: 0 });
});

describe("loadExclusions", () => {
it("mirrors the server's list for the shot", async () => {
fetchExclusions.mockResolvedValueOnce(["A", "B"]);
await useStore.getState().loadExclusions("184927");
expect(fetchExclusions).toHaveBeenCalledWith("184927");
expect(useStore.getState().excluded).toEqual(["A", "B"]);
});

it("degrades to empty with no live backend", async () => {
fetchExclusions.mockRejectedValueOnce(new Error("no backend"));
await useStore.getState().loadExclusions("1");
expect(useStore.getState().excluded).toEqual([]);
});
});

describe("toggleExcluded", () => {
it("adds a channel and persists it", async () => {
saveExclusions.mockResolvedValueOnce(["A"]);
await useStore.getState().toggleExcluded("1", "A");
expect(saveExclusions).toHaveBeenCalledWith("1", ["A"]);
expect(useStore.getState().excluded).toEqual(["A"]);
});

it("removes an already-excluded channel (click to restore)", async () => {
useStore.setState({ excluded: ["A", "B"] });
saveExclusions.mockResolvedValueOnce(["B"]);
await useStore.getState().toggleExcluded("1", "A");
expect(saveExclusions).toHaveBeenCalledWith("1", ["B"]);
expect(useStore.getState().excluded).toEqual(["B"]);
});

it("adopts the server's canonical (sorted) list", async () => {
useStore.setState({ excluded: ["Z"] });
saveExclusions.mockResolvedValueOnce(["A", "Z"]); // server sorts
await useStore.getState().toggleExcluded("1", "A");
expect(useStore.getState().excluded).toEqual(["A", "Z"]);
});

it("rolls back when the save fails", async () => {
useStore.setState({ excluded: ["A"] });
saveExclusions.mockRejectedValueOnce(new Error("500"));
await useStore.getState().toggleExcluded("1", "B");
// never leave the map claiming an exclusion the analyses don't have
expect(useStore.getState().excluded).toEqual(["A"]);
});

it("bumps excludedRev so every open view re-fits", async () => {
saveExclusions.mockResolvedValueOnce(["A"]);
const before = useStore.getState().excludedRev;
await useStore.getState().toggleExcluded("1", "A");
expect(useStore.getState().excludedRev).toBeGreaterThan(before);
});
});

describe("clearExclusions", () => {
it("restores everything in one call", async () => {
useStore.setState({ excluded: ["A", "B"] });
saveExclusions.mockResolvedValueOnce([]);
await useStore.getState().clearExclusions("1");
expect(saveExclusions).toHaveBeenCalledWith("1", []);
expect(useStore.getState().excluded).toEqual([]);
});

it("rolls back a failed clear", async () => {
useStore.setState({ excluded: ["A", "B"] });
saveExclusions.mockRejectedValueOnce(new Error("500"));
await useStore.getState().clearExclusions("1");
expect(useStore.getState().excluded).toEqual(["A", "B"]);
});
});
8 changes: 7 additions & 1 deletion gui/web/src/lib/useNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// transient failure was unrecoverable from the UI (the QS "Plot" button with
// unchanged params re-ran no effect).
import { useEffect, useState } from "react";
import { useStore } from "../store";
import { fetchNode } from "./api";
import type { Node } from "./contract";

Expand All @@ -24,7 +25,12 @@ export function useNode(
params?: Record<string, string | number>,
retryKey: number = 0,
) {
const key = `${machine}::${nodeId}::${params ? JSON.stringify(params) : ""}`;
// Bad-channel exclusions (#56) live server-side and change what EVERY node
// returns, but appear in no node's params — so fold the revision counter into
// the fetch key here rather than threading it through ~30 call sites. One
// toggle in the Sensors view then re-fits every open view.
const excludedRev = useStore((s) => s.excludedRev);
const key = `${machine}::${nodeId}::${params ? JSON.stringify(params) : ""}::x${excludedRev}`;
const [entry, setEntry] = useState<Entry>({ key, machine, node: null, error: null });

useEffect(() => {
Expand Down
Loading
Loading