Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name = "GeneralizedPerturbedEquilibrium"
uuid = "462872dd-e066-4d2e-b993-6468b5239634"
license = "MIT"
authors = ["Nikolas Logan <ncl2128@columbia.edu>", "Jong-Kyu Park <jkpark@snu.ac.kr>", "Matthew Pharr <m.pharr@protonmail.com>", "Jacob Halpern <jmh2363@columbia.edu>", "Rithik Banerjee <rb3736@columbia.edu>", "Jaebeom Cho <aspire1019@snu.ac.kr>", "Daniel Burgess <dab2245@columbia.edu>", "Min-Gu Yoo <yoom@fusion.gat.com>"]
authors = ["Nikolas Logan <ncl2128@columbia.edu>", "Jong-Kyu Park <jkpark@snu.ac.kr>", "Matthew Pharr <m.pharr@protonmail.com>", "Jacob Halpern <jmh2363@columbia.edu>", "Rithik Banerjee <rb3736@columbia.edu>", "Jaebeom Cho <aspire1019@snu.ac.kr>", "Daniel Burgess <dab2245@columbia.edu>", "Min-Gu Yoo <yoom@fusion.gat.com>", "Evan Bursch <emb2333@columbia.edu>"]
version = "0.1.0"

[deps]
Expand Down
2 changes: 1 addition & 1 deletion docs/development/hdf5-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Top level (11 groups):
| `PerturbedEquilibrium/` | `ForcingModes/`, `Response/`, `ResponseMatrices/`, `SingularCoupling/`, `Energies/`, control-surface spectra |
| `KineticForces/` | `<method>/` (torque/energy profiles, `EnergyIntegrals/`, `KineticMatrices/`); multi-ion runs add `PerSpecies/<species>/<method>/` with the same per-method layout, summing to the top-level total |
| `ErrorFields/` | `CoilSensitivities/` (per-coil-set control-surface spectra and their rigid shift/tilt derivatives, `DominantMode/` full-window projection); `MonteCarlo/` (intrinsic and corrected `\|δ\|` histograms over the sampled tolerances, per batch and averaged); `Risk/` (threshold density, `P(lock\|δ)`, locking probabilities, `ToleranceScan/`); `NTV/` (correction-coil overlap and NTV torque couplings per kAt) |
| `Tearing/` | `PerSurface/` (+ `DpMatrix/`), `Roots/`, `LayerWidths/`, `Diagnostics/{ValidRoots,Poles,FilteredRoots}`, `Scan/Surface_<k>/` |
| `Tearing/` | `PerSurface/` (+ `DpMatrix/`), `Roots/`, `LayerWidths/`, `Diagnostics/{ValidRoots,Poles,FilteredRoots}`, `Scan/Surface_<k>/`, `CriticalResonantField/` (+ `Scan/Surface_<k>/`) |
| `SurfaceGeometries/` | `{Plasma,Wall}/{x,y,z}` point clouds |

Reserved (documented, not yet written): `ForceFreeStates/Solutions/RiccatiIntegration/` — the third integrator backend slot alongside `ForwardIntegration` and `GalerkinIntegration`.
Expand Down
Binary file added docs/src/assets/crf_fortran_benchmark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions docs/src/citations.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@ The basis for the presently implemented in Fortran SLAYER code which computes th

---

> A. Cole and R. Fitzpatrick, "Drift-magnetohydrodynamical model of error-field penetration in tokamak plasmas,"
> *Physics of Plasmas* **13**, 032503 (2006).
> DOI: [10.1063/1.2178167](https://doi.org/10.1063/1.2178167)

The torque-balance error-field penetration threshold (Eqs. 61–62) behind the critical resonant field computed in `Tearing.CriticalResonantField`.

---

> A. Burgess et al., "Tearing Stability Prediction Combining Toroidal Calculations With a Two-Fluid Slab Layer,"
> Preprint (2026).

Expand Down
63 changes: 62 additions & 1 deletion docs/src/inner_layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
The `Tearing` module groups the resistive tearing-mode analysis stack:
`InnerLayer` (per-surface inner-layer matching data Δ(Q) for the GGJ and
SLAYER models), `Dispersion` (physics-agnostic complex-plane scan and
contour-intersection root extraction), and `Runner` (user-facing TOML
contour-intersection root extraction), `CriticalResonantField` (torque-balance
error-field penetration threshold), and `Runner` (user-facing TOML
configuration, profile loading, and HDF5 output).

## Layer Inputs
Expand Down Expand Up @@ -278,6 +279,60 @@ res = solve_ray(p, GGJ.inner_Q(p, γ))
res.Δ, res.resid, res.bc_cond
```

## Critical resonant field

With `[SLAYER.CriticalResonantField] enabled = true`, each SLAYER surface also gets the
critical resonant field for error-field penetration, from the steady-state torque balance of
Cole and Fitzpatrick, Phys. Plasmas **13**, 032503 (2006). The electromagnetic torque on the
layer balances the viscous restoring torque when (Cole Eq. 61)

```math
\frac{\operatorname{Im}\hat\Delta(Q)}{|\alpha + \hat\Delta(Q)|^2}
= \frac{2P\,(Q_0 - Q)}{S\hat\kappa\,(b_r/B_\phi)^2},
\qquad
\hat\kappa = \left[\frac{2}{s}\right]^2 \int_{r_s}^{a} \frac{\mu(r_s)}{\mu(r)}\,\frac{dr}{r},
```

and balance is lost above (Cole Eq. 62)

```math
\left(\frac{b_r}{B_\phi}\right)^2_\mathrm{crit}
= \max_Q \frac{2P\,(Q_0 - Q)}{S\hat\kappa\,\operatorname{Im}[-1/(\alpha + \hat\Delta(Q))]} .
```

- **Inputs.** P is each layer's `P_tor` (so χ_φ from the kinetic file, or the scalar
`chi_tor`), S its Lundquist number, and Q0 = τ_k·n·ω_E the natural E×B rotation of the
m/n mode (Cole's ω0; the diamagnetic drifts enter through Q_e and Q_i in Δ̂).
- **Approximations.** As in Fortran `gslayer.f`, the viscosity integral is taken as 1/2
(κ̂ = 2/s²) and α = 10⁻² stands in for S^(−1/3)(−r_s Δ'_s) ≪ 1 (b_crit moves about 4 %
over 10⁻⁴–10⁻¹).
- **Q axis.** Q is on Cole's axis, with the diamagnetic poles at Q_e and Q_i; `solve_inner`
mirrors the real axis, so the scan uses conj(Δ(−Q)).
- **Maximum.** The scan window follows `gslayer.f` (between Q0 and the Q_e pole), and the
largest positive interior local maximum is taken, skipping any within 0.02 of Q_e or Q_i
(Fortran SLAYER's pole-regularization radius).

Outputs go to `Tearing/CriticalResonantField/` (`br_crit` = b_r/B_φ, `q_peak`, `q0`, `p_phi`,
and with `store_scan` the per-surface `Scan/Surface_<k>/{Q, balance, Delta}`).

### Benchmark against Fortran

On the DIII-D-like example (2/1–6/1, n = 1, P = 1, κ̂ = 2/s², Q ∈ [−5, 5] with 20001
points), both codes were given Fortran's normalized inputs.

- **Torque-balance routine.** Fed Fortran's own Δ(Q) table with the pole exclusion narrowed to
one grid step, the Julia routine reproduces Fortran's b_crit and Q_peak to all printed digits
on every surface.
- **Pole handling.** Fortran's global maximum sits within 0.003 of the Q_e pole on 2/1, 3/1,
4/1 and 6/1. Skipping the pole region moves the threshold to the torque-balance maximum
between Q_e and Q0 (blue dots below), lowering the Fortran-table b_crit by 6–88 %.
- **Layer model.** Fortran solves Park's drift-MHD layer and Julia Fitzpatrick's, so Δ(Q)
differs by a median 7–78 % between Q_i and Q_e, and Julia's b_crit is 82–89 % of the
Fortran-table value on 2/1, 3/1, 4/1 and 6/1. The 5/1 edge surface is dominated by a
zero of the electromagnetic torque in both codes and is not a usable threshold.

![Fortran vs Julia inner-layer Δ(Q) and torque balance](assets/crf_fortran_benchmark.png)

## API Reference

### InnerLayer
Expand All @@ -304,6 +359,12 @@ Modules = [GeneralizedPerturbedEquilibrium.InnerLayer.SLAYER]
Modules = [GeneralizedPerturbedEquilibrium.Dispersion]
```

## CriticalResonantField

```@autodocs
Modules = [GeneralizedPerturbedEquilibrium.Tearing.CriticalResonantField]
```

## Runner

```@autodocs
Expand Down
8 changes: 8 additions & 0 deletions examples/DIIID-like_SLAYER_example/gpec.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,11 @@ Q_re_range = [-2.0, 2.0] # Scan box in the normalized Q plane, Re(Q) axis
Q_im_range = [-0.5, 3.0] # Scan box in the normalized Q plane, Im(Q) axis
nre = 41 # Grid resolution along the Re(Q) axis
nim = 31 # Grid resolution along the Im(Q) axis

[SLAYER.CriticalResonantField]
# Critical resonant field b_r/B_φ for error-field penetration at each SLAYER surface, from the
# Cole-Fitzpatrick 2006 torque balance with P = P_tor of each layer. Qmin/Qmax may be set to
# override the per-surface scan window derived from Q0, Q_e and Q_i.
enabled = true # run the analysis after SLAYER
n = 2000 # number of Q samples per surface
store_scan = false # write the per-surface Q, torque balance and Δ samples to gpec.h5
18 changes: 18 additions & 0 deletions regression-harness/cases/diiid_slayer_n1.toml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,24 @@ label = "SLAYER no_root flags [2/1,3/1,4/1]"
noise_threshold = 0
order = 34

# Critical resonant field (torque balance) for the inner three surfaces, matching
# the root pins above.
[quantities.crf_br_crit]
h5path = "Tearing/CriticalResonantField/br_crit"
type = "real_vector"
extract = "first_3"
label = "CRF b_r/B_φ crit [2/1,3/1,4/1]"
noise_threshold = 1e-9
order = 40

[quantities.crf_q_peak]
h5path = "Tearing/CriticalResonantField/q_peak"
type = "real_vector"
extract = "first_3"
label = "CRF Q_peak [2/1,3/1,4/1]"
noise_threshold = 1e-6
order = 41

# Settings (catches accidental config drift)
[quantities.slayer_enabled]
h5path = "Tearing/enabled"
Expand Down
1 change: 1 addition & 0 deletions src/GeneralizedPerturbedEquilibrium.jl
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export Tearing
# Backward-compat top-level aliases so callers can still reach these
# directly; the canonical nested path is `Tearing.{Dispersion,Runner}`.
import .Tearing.Dispersion as Dispersion
import .Tearing.CriticalResonantField as CriticalResonantField
import .Tearing.Runner as Runner
export Dispersion, Runner

Expand Down
1 change: 1 addition & 0 deletions src/InnerLayer/SLAYER/Riccati.jl
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ function solve_inner(::SLAYERModel{:fitzpatrick},
# growth-rate roots are identical to those of Δ̂_s(ĝ); it only reflects
# the off-root residual surface, fixing the integration-path orientation
# so |Δ| contours are consistent across the scan plane.
# Real-Q callers see a mirrored axis; the critical resonant field maps back via conj(Δ(−Q)).
Q_c = im * conj(ComplexF64(Q))

# Boundary condition at p_start
Expand Down
18 changes: 18 additions & 0 deletions src/Tearing/CriticalResonantField/CriticalResonantField.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# CriticalResonantField.jl
#
# Uses the inner-layer delta from InnerLayer.jl to find the critical resonant field
# required for mode penetration at each rational surface. This can then be compared
# to the actual resonant field from the perturbed equilibrium to determine which modes
# are predicted to penetrate.

module CriticalResonantField

using Printf

using ..InnerLayer: InnerLayerModel, solve_inner

include("TorqueBalance.jl")

export TorqueBalance, cole_delta, torque_balance_value, torque_balance_window, torque_balance_scan

end # module CriticalResonantField
113 changes: 113 additions & 0 deletions src/Tearing/CriticalResonantField/TorqueBalance.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# TorqueBalance.jl
#
# Error-field penetration threshold from the steady-state torque balance at a
# rational surface (Cole and Fitzpatrick, Phys. Plasmas 13, 032503 (2006), Sec. IV).
# The electromagnetic and viscous torques balance when (Cole Eq. 61)
#
# Im[Δ(Q)] / |α + Δ(Q)|² = 2·P·(Q0 − Q) / (S·κ̂·(b_r/B_φ)²),
#
# with κ̂ = [2/s(r_s)]² ∫_{r_s}^{a} [μ(r_s)/μ(r)] dr/r. Balance is lost when no Q
# satisfies this, so the critical field is (Cole Eq. 62)
#
# (b_r/B_φ)²_crit = max_Q 2·P·(Q0 − Q) / (S·κ̂·Im[−1/(α + Δ(Q))]).
#
# As in Fortran gslayer.f, the viscosity integral is taken as 1/2 (κ̂ = 2/s²) and α
# is kept finite at 1e-2. Q is on Cole's axis: the electron and ion diamagnetic
# frequencies sit at Q = Q_e and Q = Q_i (see `cole_delta`).

# Finite stand-in for α = S^(-1/3)·(−r_s Δ'_s) ≪ 1; b_crit moves ~4% over 1e-4 to 1e-1.
const TORQUE_BALANCE_ALPHA = 1e-2
# Half-width around Q_e, Q_i where the layer response is pole-dominated (Fortran SLAYER layfac).
const TORQUE_BALANCE_POLE_WIDTH = 0.02

"""
TorqueBalance{M<:InnerLayerModel,P}

Torque-balance inputs at one rational surface.

## Fields

- `model` -- inner-layer model passed to `solve_inner`
- `params` -- that model's layer parameters at the surface
- `Q0` -- normalized natural E×B rotation of the m/n mode, τ_k·n·ω_E
- `P` -- magnetic Prandtl number τ_R/τ_V (the layer's P_φ)
- `lu` -- Lundquist number S
- `kappa_hat` -- Cole's slab-to-tokamak factor κ̂ (Eq. 61)
"""
struct TorqueBalance{M<:InnerLayerModel,P}
model::M
params::P
Q0::Float64
P::Float64
lu::Float64
kappa_hat::Float64
end

"""
cole_delta(model, params, Q::Real) -> ComplexF64

Inner-layer Δ at real frequency `Q` on Cole's axis. `solve_inner` evaluates the layer
at `i·conj(Q)`, which mirrors the real axis, so Cole's Δ(Q) is `conj(Δ_solve_inner(−Q))`.
"""
cole_delta(model::InnerLayerModel, params, Q::Real) =
conj(solve_inner(model, params, ComplexF64(-Q)).tearing)

"""
torque_balance_value(tb::TorqueBalance, Q::Real) -> (bal, Δ)

Right-hand side of Cole Eq. 62 before the maximum, `bal = 2·P·(Q0 − Q) / Im[−1/(α + Δ)]`,
and the layer `Δ(Q)` it used.
"""
function torque_balance_value(tb::TorqueBalance, Q::Real)
Δ = cole_delta(tb.model, tb.params, Q)
jxb = -imag(1.0 / (Δ + TORQUE_BALANCE_ALPHA))
return 2.0 * tb.P * (tb.Q0 - Q) / jxb, Δ
end

"""
torque_balance_window(Q0, Q_e, Q_i) -> (Qmin, Qmax)

Q range bracketing the torque-balance branch between the natural rotation `Q0` and the
electron diamagnetic pole `Q_e` (the rule of Fortran gslayer.f).
"""
function torque_balance_window(Q0::Real, Q_e::Real, Q_i::Real)
Q0 > Q_e && return (1.05 * Q_e, 2.0 * Q0)
Qmin = Q0 > 0 ? 0.8 * Q_i : 1.5 * min(Q0, Q_i)
return (Qmin, 0.95 * Q_e)
end

"""
torque_balance_scan(tb::TorqueBalance; Qmin=nothing, Qmax=nothing, n=2000)
-> (Qs, bal, Qpeak, br_crit, idx_peak, Δs)

Sample `torque_balance_value` on `n` uniform real Q points and return the critical
normalized field `br_crit = b_r/B_φ` (Cole Eq. 62) at the largest positive interior local
maximum of `bal`, located at `Qpeak = Qs[idx_peak]`. A maximum within
`TORQUE_BALANCE_POLE_WIDTH` (or one grid step, if larger) of the diamagnetic poles `Q_e`,
`Q_i` is discarded as a pole artifact. `Qmin`/`Qmax`
default to `torque_balance_window`. Returns NaN for `Qpeak`, `br_crit` and 0 for
`idx_peak` when no valid maximum is found.
"""
function torque_balance_scan(tb::TorqueBalance; Qmin=nothing, Qmax=nothing, n::Integer=2000)
p = tb.params
wmin, wmax = torque_balance_window(tb.Q0, p.Q_e, p.Q_i)
Qs = range(something(Qmin, wmin), something(Qmax, wmax); length=n)
out = [torque_balance_value(tb, Q) for Q in Qs]
bal = first.(out)
Δs = last.(out)

w = max(step(Qs), TORQUE_BALANCE_POLE_WIDTH)
near_pole(Q) = abs(Q - p.Q_e) <= w || abs(Q - p.Q_i) <= w
peaks = [i for i in 2:(n-1) if isfinite(bal[i]) && bal[i] > 0 &&
bal[i-1] < bal[i] && bal[i] >= bal[i+1] && !near_pole(Qs[i])]
if isempty(peaks)
@warn "CriticalResonantField: no positive torque-balance maximum on " *
"Q ∈ [$(first(Qs)), $(last(Qs))] at the $(p.m)/$(p.n) surface."
return Qs, bal, NaN, NaN, 0, Δs
end
idx = peaks[argmax(bal[peaks])]
br_crit = sqrt(bal[idx] / (tb.lu * tb.kappa_hat))
@info @sprintf("CriticalResonantField: %d/%d surface b_r/B_φ crit = %.3e at Q = %.3f",
p.m, p.n, br_crit, Qs[idx])
return Qs, bal, Qs[idx], br_crit, idx, Δs
end
53 changes: 53 additions & 0 deletions src/Tearing/Runner/Control.jl
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,46 @@
# constructor or by parsing the `[SLAYER]` (and nested `[SLAYER.*]`)
# section(s) of a `gpec.toml`.

"""
CriticalResonantFieldControl

Configuration for the critical resonant field (torque-balance) analysis, read from the
`[SLAYER.CriticalResonantField]` TOML section via `critical_resonant_field_control_from_toml`
or built with the `@kwdef` keyword constructor. The magnetic Prandtl number is the per-surface
`P_tor` of the SLAYER layer parameters.

## Fields

- `enabled` -- run the analysis after SLAYER
- `Qmin`, `Qmax` -- override the real-Q scan window; `nothing` derives it per surface from Q0, Q_e and Q_i
- `n` -- number of Q samples per surface
- `store_scan` -- write the per-surface Q, torque balance and Δ samples to `gpec.h5`
"""
@kwdef struct CriticalResonantFieldControl
enabled::Bool = false
Qmin::Union{Nothing,Float64} = nothing
Qmax::Union{Nothing,Float64} = nothing
n::Int = 2000
store_scan::Bool = false
end

"""
critical_resonant_field_control_from_toml(section::AbstractDict) -> CriticalResonantFieldControl

Parse a `[SLAYER.CriticalResonantField]` TOML section. Unknown keys raise an error.
"""
function critical_resonant_field_control_from_toml(section::AbstractDict)
field_names = Set(String.(fieldnames(CriticalResonantFieldControl)))
unknown = [k for k in keys(section) if !(k in field_names)]
isempty(unknown) ||
throw(
ArgumentError("critical_resonant_field_control_from_toml: unknown keys " *
"$(unknown) in [SLAYER.CriticalResonantField]. Known: " *
"$(sort(collect(field_names))).")
)
return CriticalResonantFieldControl(; (Symbol(k) => v for (k, v) in section)...)
end

"""
SLAYERControl

Expand Down Expand Up @@ -100,6 +140,11 @@ there is one consistent interface for resistive and kinetic profiles.

- `store_scan` -- write the full Q/Δ scan grid to HDF5. `false` by
default to keep the output file small.

# Critical resonant field

- `critical_resonant_field` -- `CriticalResonantFieldControl` from the
`[SLAYER.CriticalResonantField]` subsection; requires `inner_model = :slayer_fitzpatrick`
"""
@kwdef struct SLAYERControl
enabled::Bool = false
Expand Down Expand Up @@ -162,6 +207,8 @@ there is one consistent interface for resistive and kinetic profiles.
profile_group::String = "/"

store_scan::Bool = false

critical_resonant_field::CriticalResonantFieldControl = CriticalResonantFieldControl()
end

const _VALID_INNER_MODELS = (:slayer_fitzpatrick, :ggj_shooting, :ggj_galerkin)
Expand Down Expand Up @@ -198,6 +245,10 @@ function validate(ctrl::SLAYERControl)
throw(ArgumentError("SLAYERControl: nre and nim must both be ≥ 2"))
ctrl.amr_passes >= 0 ||
throw(ArgumentError("SLAYERControl: amr_passes must be ≥ 0"))
!ctrl.critical_resonant_field.enabled || ctrl.inner_model === :slayer_fitzpatrick ||
throw(ArgumentError("SLAYERControl: CriticalResonantField requires inner_model=:slayer_fitzpatrick"))
ctrl.critical_resonant_field.n >= 3 ||
throw(ArgumentError("SLAYERControl: CriticalResonantField n must be ≥ 3"))
return ctrl
end

Expand Down Expand Up @@ -233,6 +284,8 @@ function slayer_control_from_toml(section::AbstractDict)
haskey(v, "pole_threshold") && (flat["pole_threshold"] = v["pole_threshold"])
haskey(v, "filter_above_poles") && (flat["filter_above_poles"] = v["filter_above_poles"])
haskey(v, "filter_outside_re") && (flat["filter_outside_re"] = v["filter_outside_re"])
elseif k == "CriticalResonantField" && v isa AbstractDict
flat["critical_resonant_field"] = critical_resonant_field_control_from_toml(v)
else
flat[k] = v
end
Expand Down
Loading
Loading