Skip to content

Commit 76b1e47

Browse files
authored
Merge pull request #88 from OpenFUSIONToolkit/bugfix/review-findings
Fix correctness bugs from the full codebase review
2 parents 8bc9b7a + 8f0731b commit 76b1e47

35 files changed

Lines changed: 2238 additions & 260 deletions

.github/workflows/ci.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,14 @@ jobs:
6565
if: ${{ matrix.python == '3.12' }}
6666
run: uvx ruff format --check .
6767

68-
- name: Tests (pytest; tolerate "no tests collected" while skeletal)
69-
run: |
70-
set +e
71-
uv run --python ${{ matrix.python }} --with pytest pytest -q
72-
code=$?
73-
set -e
74-
# exit 5 = no tests collected yet (skeleton); treat as success
75-
if [ "$code" -ne 0 ] && [ "$code" -ne 5 ]; then exit "$code"; fi
68+
# Coverage floor: a regression guard, not a target. Today's suite sits at
69+
# ~79% line coverage; 75 leaves headroom for legitimately-untestable code
70+
# (live transports, notebook plotting) while catching a big untested
71+
# feature landing. Raise it as coverage grows; never lower it to merge.
72+
- name: Tests (pytest + coverage floor)
73+
run: >
74+
uv run --python ${{ matrix.python }} --with pytest --with pytest-cov
75+
pytest -q --cov=magnetics --cov-report=term --cov-fail-under=75
7676
7777
typecheck:
7878
name: Typecheck (ty)

CLAUDE.md

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ The full **fetch → process → service → GUI** path runs end-to-end for **bo
1212
`data/datafile/` (gitignored); read back via `magnetics.data.h5source`. The GUI can trigger a
1313
pull from the left rail (`PullControl``POST /api/fetch`).
1414
- **Process:** `core/spectral.py` (MODESPEC) is real and pure. The SLCONTOUR quasi-stationary fit
15-
runs end-to-end via the reference pipeline in `magnetics._slcontour/` (xarray, self-contained
16-
OMFIT shim) adapted by `core/qs_bridge` — real K / χ² / modes for shots pulled with the Bp LFS
17-
midplane array. A pure `core/quasistationary` port exists but is not yet wired in production (#40).
15+
runs end-to-end via the shim-free `core/qs_*` modules (`qs_io_data``qs_prep``qs_fit`,
16+
adapted to nodes by `qs_bridge`; the former `_slcontour` translation, promoted into core) — real
17+
K / χ² / modes for shots pulled with the Bp LFS midplane array.
1818
- **Service:** `service/app.py``GET /api/node/{shot}/{node_id}` serves `kind`-nodes from
1919
`service/nodes.py`; `/api/machines` lists fetched shots (mock fallback when none).
2020
- **Nodes / seam:** `nodes.py` forwards GUI query params and serves the core's real `mode_number` /
@@ -31,9 +31,10 @@ The full **fetch → process → service → GUI** path runs end-to-end for **bo
3131
the rotating/MODESPEC nodes and the Sensors view render those shots too.
3232

3333
Known gaps / open work: per-sensor σ from the data layer (the QS fit currently uses a constant σ;
34-
helicity is computed from Ip·Bt); finishing the pure `core/quasistationary` port and wiring it in
35-
place of the `_slcontour` reference pipeline (#40); real equilibrium plotting in the Sensors tab
36-
(#43); Br saddle-loop geometry corrections (#44).
34+
helicity is computed from Ip·Bt); real equilibrium plotting in the Sensors tab (#43); the
35+
xarray-heavy `qs_*` modules are excluded from the `ty` typecheck (see `[tool.ty.src]` in
36+
pyproject.toml); the cursor analyses (`phase_fit` / mode shape) read from a 1–25 kHz cached
37+
array spectrum, so modes above 25 kHz aren't phase-fittable at the cursor.
3738

3839
## The API contract is FLEXIBLE — change it, don't fake around it
3940
The `kind`-node contract (`core/contracts.py``gui/web/src/lib/contract.ts`, plus the
@@ -59,16 +60,27 @@ rather than fabricating data in the GUI. Keep `contracts.py` and `contract.ts` i
5960

6061
## Layout
6162
The Python project **is the repo root** (a uv project, served as a webapp). `src/magnetics/`:
62-
- `core/` — device-agnostic math (geometry, basis, design metrics, `quasistationary`, `spectral`).
63+
- `core/` — device-agnostic math: `spectral` (MODESPEC), `mode_shape`, `geometry`, the
64+
quasi-stationary fit (`qs_io_data`/`qs_prep`/`qs_fit`/`qs_bridge`/`qs_device`/`qs_run`/
65+
`qs_plots`), and the `contracts` node shapes.
6366
- `data/` — sources + `fetch/` (toksearch/mdsthin pulls, cluster orchestration); device configs
6467
in `data/device/*.json`.
6568
- `service/` — FastAPI; the built GUI is bundled at `service/webapp/` and served here.
66-
- `_slcontour/` — the reference SLCONTOUR translation (self-contained OMFIT shim), pending port
67-
into `core/quasistationary` (issue #40); **excluded from lint/typecheck** until then.
6869

6970
Tests in `tests/`, maintainer scripts in `scripts/`. `gui/web/` — React + Vite + TypeScript
7071
frontend (its `dist/` is staged into `service/webapp/` for the wheel).
7172

73+
## Running the tests
74+
- **Python:** `uv run pytest` from the repo root — the whole suite is offline and deterministic
75+
(~5 s; synthetic fixtures are generated at collection time, no real data needed). Live-network
76+
tests (GA gateway / PPPL flux) are env-gated and skip unless `MAGNETICS_GA_USER` /
77+
`MAGNETICS_FLUX_USER` is set; they are manual-only, never wired into CI.
78+
- **Frontend:** `cd gui/web && npm run test` (vitest, one-shot; `npm run test:watch` to iterate).
79+
- **Everything CI runs:** `uv run ruff format --check .` + `uvx ruff check .` + `uv run pytest`
80+
+ `uv run ty check src/magnetics` (Python), and `npm run lint` + `npm run typecheck` +
81+
`npm run test` + `npm run build` (in `gui/web/`). Run these locally before committing —
82+
a plain `pytest` + `tsc` pass does NOT cover everything CI checks.
83+
7284
## Conventions
7385
- Physics lives in `src/magnetics/core` (pure, device-agnostic, testable); **no physics in the
7486
service routes**.

gui/web/src/components/PullControl.tsx

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
// decimation to make a pull seconds instead of minutes.
99
import { useEffect, useRef, useState } from "react";
1010
import { apiBase, startFetch, usingLiveBackend, type DeviceInfo } from "../lib/api";
11+
import { deviceDefaults } from "../lib/deviceDefaults";
1112
import { useStore } from "../store";
1213

1314
export default function PullControl() {
@@ -57,21 +58,33 @@ export default function PullControl() {
5758
const esRef = useRef<EventSource | null>(null);
5859

5960
// Snap backend / sensor-set / window / shot to sensible per-device defaults on a
60-
// device change. A tree device (NSTX/KSTAR) has no cluster and no analysis→signal
61-
// map, so it uses mdsthin over a NARROW window (its raw signals are ~15 MHz and
62-
// seconds long — a wide window is gigabytes). An NSTX-style tree device requires a
63-
// named sensor set; KSTAR's transport defaults to its declared arrays when none is
64-
// chosen. Called from the device onChange handler — NOT a synchronous effect
65-
// (react-hooks/set-state-in-effect).
61+
// device change. The decision lives in lib/deviceDefaults.ts (pure + unit-tested,
62+
// issue #64); this just applies it to the form state.
6663
function snapDeviceDefaults(d: DeviceInfo) {
67-
const tree = d.access === "mdsplus_tree";
68-
setBackend(d.remote_capable ? "remote" : "mdsthin");
69-
setSensorSet(tree && !d.needs_ssh_creds ? (d.sensor_sets[0] ?? "") : "");
70-
setTmin(tree ? "250" : "1000");
71-
setTmax(tree ? "350" : "5000");
72-
if (d.default_shot != null) setShot(String(d.default_shot));
64+
const def = deviceDefaults(d);
65+
setBackend(def.backend);
66+
setSensorSet(def.sensorSet);
67+
setTmin(def.tmin);
68+
setTmax(def.tmax);
69+
if (def.shot != null) setShot(def.shot);
7370
}
7471

72+
// The store owns `device`, and the left rail's Device picker (App.tsx) changes it
73+
// WITHOUT going through this component's select — snapping only in the select's
74+
// onChange left the backend/window/sensor-set at the previous device's values (a
75+
// rail switch to a tree device then POSTed backend "remote" to a device with no
76+
// cluster while the select displayed "mdsthin"). Snap once per device id, from
77+
// wherever the change originated.
78+
const snappedDevice = useRef<string | null>(null);
79+
useEffect(() => {
80+
if (!device || snappedDevice.current === device) return;
81+
snappedDevice.current = device;
82+
const d = devices.find((x) => x.id === device);
83+
// eslint-disable-next-line react-hooks/set-state-in-effect -- per-device defaults must follow an external (rail) device change
84+
if (d) snapDeviceDefaults(d);
85+
// eslint-disable-next-line react-hooks/exhaustive-deps
86+
}, [device, devices]);
87+
7588
// close any open pull stream when the component unmounts
7689
useEffect(() => () => esRef.current?.close(), []);
7790
// Device is owned by the store; derive its capabilities. A tree device (NSTX/KSTAR)
@@ -150,11 +163,7 @@ export default function PullControl() {
150163
<h3>Pull a shot (live)</h3>
151164
{devices.length > 0 && (
152165
<select className="pull-input" value={device} aria-label="device"
153-
onChange={(e) => {
154-
const d = devices.find((x) => x.id === e.target.value);
155-
setDevice(e.target.value);
156-
if (d) snapDeviceDefaults(d);
157-
}}>
166+
onChange={(e) => setDevice(e.target.value)}>
158167
{devices.map((d) => (
159168
<option key={d.id} value={d.id}>{d.name}</option>
160169
))}

0 commit comments

Comments
 (0)