Skip to content

Pairwise-difference basis for differential magnetic sensors#80

Open
priyanshlunia wants to merge 12 commits into
developfrom
qs-pairdiff-edit
Open

Pairwise-difference basis for differential magnetic sensors#80
priyanshlunia wants to merge 12 commits into
developfrom
qs-pairdiff-edit

Conversation

@priyanshlunia

Copy link
Copy Markdown
Collaborator

Pairwise-difference basis for differential magnetic sensors (+ helicity from Ip·Bt)

Draft PR body for qs-pairdiff-editdevelop. Delete this file after copying.

Summary

This PR gives the quasi-stationary fit correct handling of paired (differential) magnetic sensors
something the previous iteration of the port silently did not do. The fit now restores the OMFIT
magnetics pairwise-difference treatment, and goes one step further: it supports mixed paired /
unpaired sensor arrays
, which the original OMFIT treatment could not. One smaller improvement rides
along: helicity is now computed from the shot data instead of hardcoded.

Scope

This branch also merges in the advanced-GUI QS-tab work and the underlying shim-free core/qs_*
refactor. Those are covered by their own PRs and are not the focus here — this description
intentionally does not highlight them. The substantive new work in this PR is the pairwise-difference
treatment and the helicity fix below.


1. Paired sensors — the headline

The previous code ignored them

Many DIII-D magnetic sensors are differential: the MPID / ISLD / ESLD families (the "D" =
difference) physically report field(X) − field(pair), not the absolute field at one location. The
OMFIT magnetics fit_magnetics.py handles this by differencing the basis to match the measurement.

The previous iteration of our port dropped that entirely — it hardcoded dp = None and fit every
sensor against a single-location basis. This is not a corner case:

  • 120 of 192 sensors are paired, and the default production fit is affected — all 10 MPID66M*
    probes of Bp_LFS_midplane are paired.
  • Fitting a differential measurement against a single-location basis biases the recovered mode
    amplitudes/phases, inflates χ², and distorts the condition number K.

Now implemented with the OMFIT treatment

The design-matrix column for a paired channel is now the OMFIT formula
(fit_magnetics.py:340"subtract the same thing for the paired sensor if there is one"):

column(X) = ( basis(X)/σ_X − basis(pair)/σ_pair ) / 2

applied in both the main design-matrix loop and the ill-conditioned-refit branch (with the same
x0/y0 shift on the pair), plus the Gaussian path for parity. The signal side is left raw, exactly as
OMFIT does. The inverted n=0 warning is corrected (it now fires only for unpaired arrays), and the
fit reports an n_paired attribute.

The paired sensor's geometry is resolved from the full device table in
qs_device.sensor_geometry (emitted as pair_{phi,theta,z}_end1/2 vars) and rides the existing
geometry-attach path into the fit — so qs_fit stays device-agnostic and a pair need not be among the
fetched/fit channels.

Beyond OMFIT: mixed paired + unpaired arrays

OMFIT's detection is all-or-nothing: if any channel in the array is unpaired, it differences
nothing. This implementation is per-channel — it differences each channel that has a resolvable
pair and leaves unpaired channels single-location. So a mixed array is handled correctly, which was
not possible under the original OMFIT treatment (which would silently drop all pairing).

On any uniformly paired-or-unpaired array — i.e. every real DIII-D array (MPID/ISLD/ESLD fully
paired; MPI/coils fully unpaired) — the two are identical, and the per-sensor math is byte-for-byte
the OMFIT formula. The generalization only changes behavior on genuinely mixed arrays.

Tests

  • Unit (tests/test_qs_pairdiff.py): asserts a paired channel's design column equals
    (basis(X) − basis(pair))/2 and an unpaired one equals basis(X) — on a mixed 2-channel array.
  • Pipeline: Bp_LFS_midplane reports n_paired == 10.
  • The synthetic-shot fixture was made differential for paired sensors so the fixture stays
    physically consistent with the corrected fit.

2. Helicity computed from Ip·Bt (fidelity fix)

qs_io_data hardcoded the plasma helicity attr to -1, even though it loads the shot's ip/bt
traces. Helicity sets the fitted poloidal-mode (m) sign convention, so a blanket -1 is silently
wrong for reversed-Bt/Ip shots. It's now derived as sign(median Bt) / sign(median Ip) (matching OMFIT
init_magnetics); the -1 parameter is kept only as a fallback for shots missing those traces. No
effect on the default ms=(0,) fits (helicity only flips non-zero m).

Verification

  • uv run ruff format --check . + uv run ruff check . — clean.
  • uv run --extra service ty check src/magnetics — clean.
  • uv run --extra service pytest301 passed, 5 skipped.

Reviewer notes

  • The pairwise-difference treatment matches OMFIT exactly on real (uniform) arrays; the only deliberate
    divergence is the per-channel generalization for mixed arrays.
  • The 2019 ESLD wiring swap is still device-specific in qs_prep (kept intentionally; its JSON
    migration is a separate follow-up).
  • The diff includes the merged advanced-GUI + core/qs_* refactor commits (their own PRs) — review
    those there; focus review here on qs_fit / qs_device / qs_io_data and their tests.

🤖 Generated with Claude Code

priyanshlunia and others added 12 commits July 1, 2026 12:12
Retire the dead core/quasistationary.py (a partial, unwired port whose
reconstruct_grid used exp(+i(nφ+mθ)) — wrong for any phased mode) plus its
falsely-certifying test, and promote the *active* SLCONTOUR pipeline into core
under unified qs_ naming, deleting the omfit_compat OMFIT shim.

- Move _slcontour/{fit,prep,io_data,run,plots}.py -> core/qs_*.py.
- Delete omfit_compat.py, folding each symbol to its owner: printv/w/e -> logging;
  OMFITexception -> ValueError; delta_degrees -> qs_fit; cornernote/uband ->
  qs_plots; device readers -> new core/qs_device.py, which delegates to the data
  layer (data.devices / data.diiid_geometry) and keeps only the QS-specific extras
  (derived theta/end-coord sensor_geometry, resolve_channel_filter semantics).
- qs_device.load_wall now delegates to devices.feature_at, fixing the segmented-
  schema bug (the shim read a nonexistent top-level `wall` key -> (None, None)).
- Lock the -i reconstruction convention: invariant comments at the three sites
  + a discriminating δ≠0 regression test the old zero-phase test missed.
- qs_plots docstring clarified as standalone matplotlib (not GUI/service wired).
- Rewire service/nodes.py + tests; pyproject drops the ruff exclude and retargets
  a deferred ty exclude onto the qs_* files. Relocate the reference notebook/README
  to docs/qs_slcontour/.

ruff format/check + ty (with service extra) clean; 209 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Finish the core/qs_* promotion by cleaning up its reference material, which was
still written for the old OMFIT `magnetics-code` layout.

- Move example_magnetics.ipynb into examples/ and rewire it onto the new
  infrastructure: imports become magnetics.core.qs_{io_data,device,run} plus
  `qs_plots as plots` (so the plot cells are unchanged); drop the sys.path hack
  and the OMFIT-script mapping / "ported OMFIT scripts" framing.
- Replace docs/qs_slcontour/README.md with docs/quasistationary-mode-analysis.md:
  no OMFIT-magnetics plumbing (module↔OMFIT-script table, omfit_compat, cd
  analysis), instead a walkthrough of the qs_* pipeline (io_data → prep → fit →
  run, plus qs_bridge→service and standalone qs_plots), data inputs, quick start,
  and the -i reconstruction sign convention. Physics provenance (SLCONTOUR,
  VISION §4.1) kept.
- Remove the now-empty docs/qs_slcontour/.

Docs-only; no src/ behavior change. ruff format --check clean.
(docs/magnetics-code-architecture.md is also OMFIT-era — left for a later sweep.)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ond), wall-outline fix

- QS tab's Array dropdown now uses the same device sensor_sets list as the
  left sidebar instead of a hardcoded 9-item subset.
- New Advanced Options controls in the QS tab: uncertainty (sigma), fraction
  of energy included, basis function, fit condition - wired through to the
  SLCONTOUR fit via new sigma_override/fit_basis/fit_cond query params.
- Fix load_wall() reading the device JSON's legacy flat wall schema; it's
  been shot-segmented since 28c75c6, so the wall outline silently stopped
  rendering in the QS Sensor Map cross-section plot.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Prioritizes the incoming SLCONTOUR restructuring (_slcontour/* -> core/qs_*,
qs_device.py replacing omfit_compat.py) over this branch's prior fixes where
they overlapped:
- Dropped this branch's _segment_fields-based load_wall fix on the legacy
  `wall` key in favor of the incoming qs_device.load_wall, which resolves
  the newer, canonical `first_wall` key via devices.feature_at.
- Kept this branch's additive work with no incoming equivalent: the QS tab's
  Advanced Options UI, and the sigma_override/fit_basis/fit_cond query-param
  wiring in _qs_run/_prep_qs_ds (now pointed at core.qs_run/core.qs_device
  instead of the deleted _slcontour module).
- Merged both branches' new tests in test_qs_bridge.py and
  test_slcontour_geometry.py rather than dropping either side's coverage.
The QS fit hardcoded `dp = None` and never differenced the basis for
differential magnetic sensors (the MPID/ISLD/ESLD families), so a sensor that
physically reports field(X) - field(pair) was fit against a single-location
basis. This affected the default production fit: all 10 MPID66M* probes of
Bp_LFS_midplane are paired. Restores the OMFIT fit_magnetics.py treatment
(design-matrix column = (basis(X)/sigma - basis(pair)/sigma_pair) / 2), applied
per-channel so mixed paired/unpaired arrays are handled.

- qs_device.sensor_geometry: resolve each channel's pair in the full device
  table and emit pair_{phi,theta,z}_end1/2 vars (NaN when unpaired), riding the
  existing geometry-attach path to the fit.
- qs_fit.fit: difference the basis where has_pair (main loop + ill-conditioned
  refit, with the x0/y0 shift on the pair too); psigma = sigma under the constant
  per-shot sigma; fix the n=0 warning to fire only when unpaired; emit an
  n_paired attr. Gaussian path gets the same treatment for parity.
- synthetic_shot: make paired sensors differential (signal(X) = field(X) -
  field(pair)) so the fixture stays physically consistent with the fit.
- tests/test_qs_pairdiff.py: unit test that the paired design column equals
  (basis(X) - basis(pair)) / 2 and the unpaired one equals basis(X); pipeline
  test that Bp_LFS_midplane reports n_paired == 10.

Per-channel detection is identical to OMFIT's all-or-nothing on every real
(uniformly paired/unpaired) DIII-D array. ruff/ty clean; 218 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Brings develop through #46 (gui-sensors): NSTX/NSTX-U device-generic fetch,
per-node plot + HDF5 export, EFIT tree-fetch, and the data-layer device_geom
refactor. No merge conflicts; the qs_* pipeline works against develop's
refactored data layer.

Verified on the merged tree: ruff format/check clean, ty clean, 290 passed /
5 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…core

Two device-specific bits removed from the QS core (issue #40 device-agnostic
goal), without touching data/device/*.json or the data layer:

- qs_device: delete the hardcoded `_DEVICE_ALIAS = {"DIII-D": "diiid"}` and
  `_device_key`; `load_device` now delegates to `devices.resolve_device_id`,
  which derives the config id from each device JSON's `name` field.
- qs_fit: remove the advisory `is_device("DIII-D")` + `C.*/IL.*/IU.*` coil-basis
  warning and its now-unused `is_device` import.

The 2019 ESLD wiring swap in qs_prep is intentionally kept for now (migrating it
needs a JSON `corrections` block + reader, deferred). ruff/ty clean; 290 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Out of this branch's original (pairwise-difference) scope — an opportunistic
fidelity fix spotted while investigating the example notebook.

qs_io_data hardcoded the plasma `helicity` attr to -1, even though it loads the
shot's `ip`/`bt` traces. helicity sets the fitted poloidal-mode (m) sign
convention, so a blanket -1 is silently wrong for reversed-Bt/Ip shots. Now
`_compute_helicity` derives it as sign(median Bt)/sign(median Ip) (matching OMFIT
init_magnetics); the `helicity=-1` argument is kept only as a fallback for shots
missing those traces.

- core/qs_io_data.py: add `_compute_helicity`, wire into `_build_plasma`, clarify
  the fallback-only role of the parameter.
- tests: unit coverage of the sign logic + a pipeline check that the synthetic
  shot's helicity is the computed +1, not the fallback.
- docs (quasistationary-mode-analysis.md, CLAUDE.md): helicity is no longer an
  open item; the remaining QS-fidelity gap is per-sensor sigma.

No effect on the default ms=(0,) fits (helicity only flips non-zero m).
ruff/ty clean; 301 passed, 5 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants