Skip to content
Draft
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
55 changes: 55 additions & 0 deletions gui/web/src/components/tabs/RotatingTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@
// of phase_fit. 422s when the shot lacks the poloidal array; the panel hides then.
const { node: poloidalPhaseFitNode } = useNode(machine, "poloidal_phase_fit", { time: cursorMs });

// MAC-based poloidal m with a Monte-Carlo confidence: an independent, shape-based
// cross-check on the slope fit above, and the even/odd (1/1 vs 2/1) verdict. 422s
// when the shot lacks the poloidal array; the panel hides then.
const { node: mSpectrumNode } = useNode(machine, "poloidal_m_spectrum", { time: cursorMs });

// One Mirnov probe's raw dB/dt time series in a 4 ms window around the cursor.
const { node: rawTraceNode } = useNode(machine, "raw_trace", { time: cursorMs });

Expand Down Expand Up @@ -470,7 +475,7 @@
const liveN = hasStaticFiles && modeNumberNode?.kind === "heatmap" ? modeNumberNode : null;

const coh = liveCoh
? freqs.map((_, fIdx) => (liveCoh.z[fIdx] ? liveCoh.z[fIdx][tIdx] : 0))

Check warning on line 478 in gui/web/src/components/tabs/RotatingTab.tsx

View workflow job for this annotation

GitHub Actions / Web (gui)

React Hook useMemo has an unnecessary dependency: 'powerGate'. Either exclude it or remove the dependency array
// Live but coherence node not yet loaded → zeros, never a synthesized stand-in.
: live
? freqs.map(() => 0)
Expand Down Expand Up @@ -1474,6 +1479,56 @@
</div>
</div>
)}
{mSpectrumNode && mSpectrumNode.kind === "bar" && (() => {
const m = shapeMeta(mSpectrumNode) as Record<string, number | string | boolean> | undefined;
const pEven = Number(m?.p_even ?? 0), pOdd = Number(m?.p_odd ?? 0);
const parityHi = pEven >= pOdd ? pEven : pOdd;
const parityName = pEven >= pOdd ? "even-m" : "odd-m";
const quality = String(m?.quality ?? "");
// colour the verdict by trust: q-anchored (physical rational surface) is the
// strongest; weak/ambiguous are cautions (the printed m may be a sampling alias)
// so they must not read as a firm answer.
const verdictColor = quality === "q-anchored" ? "var(--good, #54e08a)"
: quality === "weak" || quality === "ambiguous" ? "var(--warn, #ffb454)"
: quality === "marginal" ? "var(--text)" : "var(--accent)";
const qAnchored = m?.q_anchored === true;
const aliasMs = Array.isArray(m?.alias_ms) ? (m!.alias_ms as number[]) : [];
return (
<div className="card" style={{ flexShrink: 0, display: "flex", flexDirection: "column", gap: "8px", margin: "7px 0 0 0", minHeight: 0 }}>
<h4 style={{ margin: 0, fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--accent)" }}>
Poloidal m — MAC + Confidence
<span style={{ color: "var(--text-dim)", fontWeight: 400, textTransform: "none" }}>
{" · shape-based m (eigspec eq 9), P(m) from a Monte-Carlo over per-probe σ"}
</span>
</h4>
{/* verdict banner — the plain-language m/n answer */}
<div style={{ display: "flex", alignItems: "baseline", gap: "12px", flexWrap: "wrap", fontSize: "12px" }}>
<span style={{ fontSize: "15px", fontWeight: 700, color: verdictColor }}>{String(m?.verdict ?? "")}</span>
{qAnchored ? (
<span style={{ color: "var(--text-dim)" }}>
EFIT02 q-anchored: q={Number(m?.q_surface ?? 0).toFixed(2)}
{m?.psi_n != null ? ` @ ψ_N=${Number(m.psi_n).toFixed(2)}` : ""}
{m?.q_corrected ? ` · raw MAC m=${Number(m?.raw_m ?? 0)} was an alias` : ""}
</span>
) : (
<span style={{ color: "var(--text-dim)" }}>
P({parityName}) = <strong style={{ color: "var(--text)" }}>{parityHi.toFixed(2)}</strong>
{aliasMs.length ? ` · aliases m∈{${[Number(m?.raw_m ?? m?.best_m), ...aliasMs].join(", ")}}` : ""}
{` · margin ${Number(m?.margin ?? 0).toFixed(2)}`}
</span>
)}
<span style={{ color: "var(--text-dim)" }}>
MAC <strong style={{ color: "var(--text)" }}>{Number(m?.mac_best ?? 0).toFixed(2)}</strong>
{" · "}{Number(m?.n_used ?? m?.n_probes ?? 0)}/{Number(m?.n_probes ?? 0)} probes (SNR-gated)
{m?.used_theta_star ? ` · θ* (κ=${m?.kappa})` : " · geometric θ (no κ)"}
</span>
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<NodeView node={mSpectrumNode} height={200} />
</div>
</div>
);
})()}
{analysisCard("Mode Persistence", modeTrackNode, "line", 200,
shapeMeta(modeTrackNode)?.dominant_n != null
? `shape similarity to the dominant mode vs time (1 = persists) · n≈${shapeMeta(modeTrackNode)!.dominant_n}`
Expand Down
36 changes: 36 additions & 0 deletions gui/web/src/lib/NodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,42 @@ export default function NodeView({
);
}

case "bar": {
// Categorical spectrum (e.g. P(m) over poloidal mode number). The highlighted
// category (best-fit mode) is drawn in the accent colour, the rest muted; an
// optional secondary series (nominal MAC) rides on top as a marker line.
const xs = node.x.map((v) => String(v));
const colors = node.x.map((v) =>
node.highlight != null && v === node.highlight ? "#ff5cad" : "#4aa3ff",
);
const traces: Partial<Plotly.PlotData>[] = [
{
type: "bar", x: xs, y: node.y, marker: { color: colors },
name: node.axes.y, hovertemplate: `%{x}: %{y:.3f}<extra></extra>`,
} as Partial<Plotly.PlotData>,
];
if (node.secondary) {
traces.push({
type: "scatter", mode: "lines+markers", x: xs, y: node.secondary.y,
name: node.secondary.name, yaxis: "y2",
marker: { color: "#ffb454", size: 6 }, line: { color: "#ffb454", width: 1, dash: "dot" },
hovertemplate: `%{x}: %{y:.3f}<extra></extra>`,
} as Partial<Plotly.PlotData>);
}
const layout: Partial<Plotly.Layout> = {
...axisLayout(node.axes),
showlegend: false,
bargap: 0.35,
};
if (node.secondary) {
layout.yaxis2 = {
overlaying: "y", side: "right", range: [0, 1], showgrid: false,
title: { text: node.secondary.name, font: { size: 9 } }, tickfont: { size: 8 },
} as Partial<Plotly.LayoutAxis>;
}
return <Plot data={traces} height={height} layout={layout} />;
}

default: {
// A kind outside the known union (backend shipped a new node before the
// contract was updated, or a malformed payload). Render a placeholder —
Expand Down
14 changes: 14 additions & 0 deletions gui/web/src/lib/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ export interface LineNode {
meta?: Record<string, unknown>;
}

/** A categorical bar chart — e.g. the MAC / probability spectrum over mode number.
* `x` are the categories (mode numbers), `y` the bar heights; `highlight` marks the
* best-fit category and `secondary` is an optional parallel series (nominal MAC). */
export interface BarNode {
kind: "bar";
x: (number | string)[];
y: number[];
axes: Axes;
highlight?: number | string | null;
secondary?: { name: string; y: number[] } | null;
meta?: Record<string, unknown>;
}

/** A plasma equilibrium slice — normalized poloidal flux ψ_N(R,Z) + boundary.
* Mirrors an EFIT gEQDSK slice: `psi_n` is row-major [z][r], 0 at the magnetic
* axis and 1 at the last closed flux surface. Time-parametrized (one slice). */
Expand Down Expand Up @@ -107,6 +120,7 @@ export type Node =
| HeatmapNode
| Scatter2DNode
| LineNode
| BarNode
| EquilibriumNode
| MetricsNode;

Expand Down
20 changes: 20 additions & 0 deletions src/magnetics/core/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,26 @@ def line(series, axes, *, meta=None) -> dict:
return _clean({"kind": "line", "series": series, "axes": axes, "meta": meta})


def bar(x, y, axes, *, highlight=None, secondary=None, meta=None) -> dict:
"""A categorical bar chart — e.g. the MAC / probability spectrum over mode number.

``x`` are the categories (mode numbers), ``y`` the bar heights. ``highlight`` marks
one category (the best-fit mode) for emphasis; ``secondary`` is an optional parallel
series shown as an overlaid marker (e.g. the nominal MAC behind the probability).
"""
return _clean(
{
"kind": "bar",
"x": x,
"y": y,
"axes": axes,
"highlight": highlight,
"secondary": secondary,
"meta": meta,
}
)


def equilibrium(
r, z, psi_n, boundary, axis, *, levels=None, time_ms=0.0, axes=None, meta=None
) -> dict:
Expand Down
149 changes: 149 additions & 0 deletions src/magnetics/core/equilibrium.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""q-profile physics: rational surfaces and mode-number anchoring.

A magnetic island / tearing mode lives on a *rational* flux surface where the
safety factor q = m/n is a ratio of the poloidal (m) and toroidal (n) mode numbers.
The toroidal n from a well-sampled toroidal array is reliable; the poloidal m from a
sparse, unevenly-sampled poloidal array is easily *aliased* (a physical m folds down
to a spurious small one). The q-profile breaks that degeneracy: for the measured n,
only integer m with a real q = m/n surface in the plasma (q_min ≤ m/n ≤ q_max) are
physically possible, so an aliased m/n below q_min (e.g. m/n = 1/3, q ≈ 0.33, inside
the q ≈ 1 axis) can be rejected and replaced by the physical alias.

Pure numpy over arrays: no device specifics, no I/O.
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
from numpy.typing import NDArray


@dataclass(slots=True)
class RationalSurface:
m: int # poloidal mode number of the resonance
n: int # toroidal mode number (the anchor)
q: float # q = m/n at the surface
psi_n: float # normalized flux ψ_N of the (outermost) crossing, in [0, 1]


@dataclass(slots=True)
class MAnchorResult:
chosen_m: int # physically-anchored poloidal mode number (signed, MAC's sign kept)
q_surface: float | None # q = |chosen_m|/|n| if it resonates, else None
psi_n: float | None # ψ_N of the resonant surface, if any
corrected: bool # True if the raw MAC winner was unphysical and got replaced
raw_m: int # the raw MAC-argmax m, before the q-anchor
allowed_ms: list[int] # |m| that have a q = m/n surface (within the MAC alias set)
q_min: float
q_max: float


def _crossings(psi_n: NDArray[np.floating], q: NDArray[np.floating], target: float) -> list[float]:
"""ψ_N where the (possibly non-monotonic) q-profile crosses ``target`` q, by linear
interpolation across each sign change of q − target. Empty if never reached."""
d = np.asarray(q, dtype=np.float64) - float(target)
psi = np.asarray(psi_n, dtype=np.float64)
out: list[float] = []
sign_change = np.flatnonzero(np.diff(np.sign(d)) != 0)
for i in sign_change:
d0, d1 = d[i], d[i + 1]
if d1 != d0:
out.append(float(psi[i] - d0 * (psi[i + 1] - psi[i]) / (d1 - d0)))
return out


def q_range(q_psi: NDArray[np.floating]) -> tuple[float, float]:
"""(q_min, q_max) over the profile, ignoring NaNs."""
q = np.asarray(q_psi, dtype=np.float64)
return float(np.nanmin(q)), float(np.nanmax(q))


def resonant_surfaces(
q_psi: NDArray[np.floating],
psi_n: NDArray[np.floating],
n: int,
*,
m_max: int = 15,
) -> list[RationalSurface]:
"""All q = m/n rational surfaces present in the profile for toroidal number ``n``.

Scans |m| = 1…``m_max``; a surface exists when q = m/|n| lies within [q_min, q_max].
The ψ_N reported is the *outermost* crossing (largest ψ_N) — the mode's resonance is
conventionally the outer one for the higher-order rationals. Returns them sorted by m.
"""
na = abs(int(n))
if na == 0:
return []
qmin, qmax = q_range(q_psi)
out: list[RationalSurface] = []
for m in range(1, m_max + 1):
qt = m / na
if qmin <= qt <= qmax:
xs = _crossings(psi_n, q_psi, qt)
psi_loc = max(xs) if xs else float("nan")
out.append(RationalSurface(m=m, n=na, q=qt, psi_n=psi_loc))
return out


def anchor_poloidal_m(
m_values: NDArray[np.integer],
mac_values: NDArray[np.floating],
n: int,
q_psi: NDArray[np.floating],
psi_n: NDArray[np.floating],
*,
alias_margin: float = 0.1,
m_max: int = 15,
) -> MAnchorResult:
"""Pick the physical poloidal m from the MAC spectrum using the q-profile.

The raw MAC argmax is easily an alias on a sparse poloidal array. Among the MAC
*alias set* — the candidates within ``alias_margin`` MAC of the best — keep only those
whose |m|/|n| has a real q surface, and choose the highest-MAC survivor. If the raw
winner is itself physical, nothing changes; if it is not (e.g. m/n below q_min) it is
replaced and ``corrected`` is set. Sign of m follows the MAC (helicity), the q test
uses |m|.
"""
ms = np.asarray(m_values)
macs = np.asarray(mac_values, dtype=np.float64)
na = abs(int(n))
qmin, qmax = q_range(q_psi)

best_idx = int(np.argmax(macs))
raw_m = int(ms[best_idx])

def resonates(m_abs: int) -> bool:
return na > 0 and 1 <= m_abs <= m_max and qmin <= m_abs / na <= qmax

# alias set: candidates within the MAC margin of the best (excluding q=0/trivial)
alias_mask = macs >= macs[best_idx] - alias_margin
allowed_ms = sorted({abs(int(m)) for m in ms[alias_mask] if resonates(abs(int(m)))})

chosen_idx = best_idx
corrected = False
if not resonates(abs(raw_m)):
# raw winner is unphysical → take the best-MAC alias that does resonate
candidates = [i for i in np.argsort(macs)[::-1] if resonates(abs(int(ms[i])))]
if candidates:
chosen_idx = int(candidates[0])
corrected = True

chosen_m = int(ms[chosen_idx])
q_surface = abs(chosen_m) / na if resonates(abs(chosen_m)) else None
psi_loc: float | None = None
if q_surface is not None:
xs = _crossings(psi_n, q_psi, q_surface)
psi_loc = max(xs) if xs else None

return MAnchorResult(
chosen_m=chosen_m,
q_surface=q_surface,
psi_n=psi_loc,
corrected=corrected,
raw_m=raw_m,
allowed_ms=allowed_ms,
q_min=qmin,
q_max=qmax,
)
Loading
Loading