Ideal MHD Stability (ForceFreeStates)

The ForceFreeStates module implements ideal MHD stability analysis for axisymmetric toroidal plasmas following the direct Newcomb criterion described in [Glasser 2016]. It solves the Euler-Lagrange (EL) system derived from the potential energy functional, identifies singular (rational) surfaces where resonant coupling occurs, and returns eigenmode energies, the tearing stability parameters Δ', and the full inter-surface Δ' matrix.

Physical background

Ideal MHD stability is determined by the sign of the perturbed potential energy

\[\delta W[\xi] = \int_0^{\psi_\mathrm{lim}} \mathcal{F}(\xi, \xi') \, d\psi,\]

where $\xi(\psi)$ is the poloidal displacement vector. The extremum of $\delta W$ over all admissible $\xi$ satisfies the Euler-Lagrange system [Glasser 2016, Eq. 24]:

\[\frac{d}{d\psi} \begin{pmatrix} U_1 \\ U_2 \end{pmatrix} = \begin{pmatrix} A & B \\ C & D \end{pmatrix} \begin{pmatrix} U_1 \\ U_2 \end{pmatrix}, \quad A = -Q\bar{F}^{-1}\bar{K}, \; B = Q\bar{F}^{-1}Q, \; C = \bar{G} - \bar{K}^\dagger\bar{F}^{-1}\bar{K}, \; D = \bar{K}^\dagger\bar{F}^{-1}Q,\]

where $\bar{F}$, $\bar{K}$, $\bar{G}$ are the MHD metric matrices in Fourier-mode space and $Q = \mathrm{diag}(1/(m - nq))$ is the singular factor. The Newcomb criterion states that the plasma is stable if and only if this system admits a regular solution that remains finite across every rational surface.

Key references

PaperContent
[Glasser 2016] Phys. Plasmas 23, 112506Newcomb criterion, EL system, standard DCON integration
[Glasser 2018a] Phys. Plasmas 25, 032507Riccati reformulation, reduced stiffness near singular surfaces
[Glasser 2018b] Phys. Plasmas 25, 032501STRIDE code: parallel FM integration, inter-surface Δ' matrix

Integration methods

Three integration drivers are available, all solving the same EL system but with different numerical strategies.

Standard integration

eulerlagrange_integration is the baseline driver. It integrates the EL ODE directly in $(U_1, U_2)$ using Tsit5 with adaptive step control. Near each rational surface the columns of $U_2$ that correspond to resonant modes are zeroed via Gaussian reduction (GR), keeping the solution bounded. This is the reference path for correctness comparisons.

Enable with (default):

[ForceFreeStates]
use_riccati  = false
use_parallel = false

Riccati integration

riccati_eulerlagrange_integration reformulates the problem in terms of the dual Riccati matrix $S = U_1 \cdot U_2^{-1}$ [Glasser 2018a, Eq. 19]:

\[\frac{dS}{d\psi} = w^\dagger \bar{F}^{-1} w - S\bar{G}S, \qquad w = Q - \bar{K}S.\]

$S$ remains bounded near rational surfaces (where $U_1, U_2$ grow exponentially), so the solver takes fewer steps. Rather than integrating the quadratic Riccati ODE directly (which blows up when $|S|$ is large), the code integrates the linear EL system with sing_der! as the RHS and recovers $S = U_1 U_2^{-1}$ via periodic renormalization — an approach that is mathematically equivalent to O(Δψ) but uses the ODE solver's full 5th-order accuracy.

Renormalization is triggered whenever $\max(|U_1|)$ or $\max(|U_2|)$ exceeds the threshold ucrit (default 1e6), and is forced at the end of each chunk. At singular surface crossings, riccati_cross_ideal_singular_surf! applies the small-asymptotic matching directly in column ipert_res — without Gaussian reduction — and renormalizes to $(S, I)$.

Enable with:

[ForceFreeStates]
use_riccati  = true
use_parallel = false

Speedup (benchmarked on reference examples):

ExampleN modesSpeedup vs standard
Solovev8~1.6× (1 thread), ~2.8× (4 threads)
DIIID26~2.0× (1 thread), ~1.3× (4 threads)

Parallel fundamental-matrix (FM) integration

parallel_eulerlagrange_integration decomposes the radial domain into independent chunks and integrates each chunk in parallel using Threads.@threads. Each chunk produces a fundamental-matrix (FM) propagator. Serial post-processing multiplies the propagators in order and applies each singular-surface crossing, recovering the same EL trajectory as the Riccati path.

Bidirectional integration for large N

For large mode counts the FM propagator for a chunk ending near a rational surface is ill-conditioned: the EL solutions grow exponentially toward the rational surface, so the forward FM amplifies numerical errors. GPEC follows the STRIDE approach [Glasser 2018b, Sec. III.A]: the crossing chunk (the last sub-chunk before each rational surface) is integrated backward — from the rational surface toward the interior — producing a well-conditioned backward FM $\Phi_L$. The forward propagation is recovered as $\Phi_L^{-1}$ via an LU solve in serial assembly, which is accurate precisely because $\Phi_L$ is well-conditioned.

The implementation uses a direction field on IntegrationChunk:

  • direction = +1: standard forward integration, tspan = (ψ_start, ψ_end).
  • direction = -1: backward integration, tspan = (ψ_end, ψ_start) (reversed).

chunk_el_integration_bounds(...; bidirectional=true) assigns direction = -1 to every crossing chunk. balance_integration_chunks preserves this: the sub-chunk closest to the rational surface inherits direction, while the earlier sub-chunk always gets direction=+1.

Enable with:

[ForceFreeStates]
use_parallel = true

Accuracy (N=26, DIIID-like example): energy eigenvalue within 2% of standard path. The residual ~2% gap comes from the different crossing convention (Riccati-style direct zeroing vs GR), not from ODE tolerance; it is present in both 1-thread and 4-thread runs.

Local stability: Mercier and ballooning (s–α)

Setting local_stability_flag = true in [ForceFreeStates] runs a local high-$n$ stability scan over every flux surface, in addition to the global ideal analysis above. For the derivation and implementation details behind these diagnostics, see Ballooning and Mercier Local Stability. Three diagnostics are produced and stored under the locstab/ HDF5 group, each a profile in normalized poloidal flux $\psi$:

  • Mercier criterion $D_I$ (locstab/di) — the ideal interchange criterion. A surface is Mercier-unstable where $D_I > 0$. It is evaluated from the $\det(\bar{d}_0)$ of the integrated local-mode matrix.
  • Resistive interchange $D_R$ (locstab/dr) — the Glasser–Greene–Johnson resistive interchange criterion $D_R = D_I + (H - 1/2)^2$. The $D_I$ term is the same $\det(\bar{d}_0)$ value reported in locstab/di; $H$ is computed from the legacy Mercier/GGJ flux-surface averages of the field and metric quantities. $D_R > 0$ indicates resistive interchange instability.
  • Ballooning $\Delta'$ (locstab/ballooning_Delta_prime) — the high-$n$ ballooning stability index, obtained by integrating the ballooning equation along the field line and taking the jump in the logarithmic derivative of the solution between the two asymptotic ends.
Two different Δ' quantities

locstab/ballooning_Delta_prime is the local high-$n$ ballooning index and is distinct from the resistive tearing $\Delta'$ described in the next section, which is written under singular/ and perturbed_equilibrium/singular_coupling/delta_prime. They measure different instabilities; do not confuse them.

s–α diagram

The local criteria can be mapped over a two-parameter $(p', q')$ scan at a single flux surface — the classic s–α (shear–pressure) stability diagram. For a chosen surface the pressure gradient $p'$ and shear $q'$ are scaled away from their equilibrium values, and $\Delta'$ and $D_I$ are recomputed on the resulting grid. The $\Delta' = 0$ and $D_I = 0$ contours bound the ballooning- and Mercier-stable regions, with the equilibrium operating point marked inside.

The example script examples/DIIID-like_ideal_example/analyze_example.jl demonstrates the full workflow: it plots the $s$ and $\alpha$ profiles, the $D_I$ and ballooning $\Delta'$ profiles, and the 2-D s–α maps with their zero contours, using salpha_reference, compute_ballooning_stability!, and scan_delta_prime_map.

Δ' tearing stability parameter

Per-surface Δ' (delta_prime)

At each rational surface the asymptotic matching condition gives the tearing stability parameter [Glasser 2016]:

\[\Delta'_s = \frac{c_{a,r}[i_s,i_s,2] - c_{a,l}[i_s,i_s,2]}{4\pi^2 \psi_0},\]

where $c_{a,l}$ and $c_{a,r}$ are the left and right asymptotic coefficients at surface $s$, and $i_s$ is the column index of the resonant mode. Positive $\Delta' > 0$ indicates a tearing-unstable surface.

The Riccati and parallel FM paths populate intr.sing[s].delta_prime (a length-$n_\mathrm{res}$ vector) inline during each crossing. A companion vector delta_prime_col (length N) stores the coupling of all poloidal modes to the resonant mode at surface $s$:

\[(\Delta'_\mathrm{col})_{j,i} = \frac{c_{a,r}[j,i_s,2] - c_{a,l}[j,i_s,2]}{4\pi^2 \psi_0}.\]

The diagonal element $(\Delta'_\mathrm{col})_{i_s,i}$ equals delta_prime[i] exactly by construction.

Inter-surface Δ' matrix (delta_prime_matrix)

compute_delta_prime_matrix! assembles an $m_\mathrm{sing} \times m_\mathrm{sing}$ inter-surface tearing matrix following the STRIDE global BVP [Glasser 2018b, Sec. III.B]. Internally, the solver builds a raw $2 m_\mathrm{sing} \times 2 m_\mathrm{sing}$ matrix whose rows/columns index the left and right inner-layer boundaries of every rational surface; the stored PEST3-convention $\Delta'$ is the four-term combination $\text{dp\_raw}[2i, 2j] - \text{dp\_raw}[2i, 2j{-}1] - \text{dp\_raw}[2i{-}1, 2j] + \text{dp\_raw}[2i{-}1, 2j{-}1]$ that folds the raw block into a per-surface response. The BVP unknowns are the plasma state at the left and right inner-layer boundaries of every rational surface; the driving terms are unit-amplitude asymptotic solutions at each boundary. The resulting matrix encodes the full plasma response between all pairs of surfaces and is required for resistive stability analysis of multi-surface configurations.

The BVP is well-conditioned because it is formulated using the split $(\Phi_R, \Phi_L)$ propagator blocks from bidirectional integration rather than the monolithic forward product $\Phi_L^{-1} \Phi_R$ (which is ill-conditioned for large N):

\[\Phi_R[j] \cdot x_R[j-1] - \Phi_L[j] \cdot x_L[j] = 0 \quad \text{(junction at } \psi_m[j]\text{)},\]

where $\Phi_R[j]$ is the forward FM product from $\psi_{R,j-1}$ to the junction, and $\Phi_L[j]$ is the backward crossing FM from $\psi_{L,j}$ to the junction.

The matrix is only populated by the parallel FM path and is written to the HDF5 output under singular/delta_prime_matrix.

Configuration reference

All ForceFreeStates options are set in the [ForceFreeStates] section of gpec.toml.

[ForceFreeStates]
# Integration driver
use_riccati  = false   # true: Riccati path (faster, same accuracy)
use_parallel = false   # true: parallel FM path (multi-thread, large N)

# Mode space
nn_low       = 1       # lowest toroidal mode number
nn_high      = 1       # highest toroidal mode number
delta_mlow   = 0       # extra low poloidal modes (m < mlow)
delta_mhigh  = 0       # extra high poloidal modes (m > mhigh)

# ODE solver
numsteps_init     = 200    # initial step budget per chunk
numunorms_init    = 50     # renorm checkpoint budget
reltol            = 1e-6   # ODE relative tolerance

# Local stability
local_stability_flag = false  # scan Mercier D_I, resistive D_R, and ballooning Δ' over ψ

# Output
verbose              = true
write_outputs_to_HDF5 = true

The number of Julia threads is controlled at startup via -t N or the JULIA_NUM_THREADS environment variable; it is not a runtime parameter.

API Reference

The Galerkin Δ′ solver (src/ForceFreeStates/Galerkin/) is documented separately in docs/src/galerkin.md.

GeneralizedPerturbedEquilibrium.ForceFreeStates.ChunkPropagatorType
ChunkPropagator

Fundamental matrix for one integration chunk, stored as two N×N×2 solution blocks. Represents the propagator Φ(ψ₂,ψ₁) computed by integrating the EL ODE from two identity-block initial conditions:

  • block_upper_ic: result of integrating with IC = (IN, 0N) (U₁ = I, U₂ = 0)
  • block_lower_ic: result of integrating with IC = (0N, IN) (U₁ = 0, U₂ = I)

Applying the propagator to the current state u_prev:

u₁new = blockupperic[:,:,1] · u₁prev + blockloweric[:,:,1] · u₂prev u₂new = blockupperic[:,:,2] · u₁prev + blockloweric[:,:,2] · u₂prev

Since each chunk starts from a bounded identity IC (rather than the accumulated state), exponential growth within a chunk does not affect the conditioning of the overall assembly. This enables Threads.@threads parallel integration across all chunks.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.EdgeScanStateType

EdgeScanState

Holds the state and results for the edge dW stability scan over ψ ∈ [psiedge, psilim]. Initialized and populated by findmax_dW_edge!; results written to HDF5 under EdgeScan/ (root-area-weighted values) and EdgeScan/XiNorm/ (ξ-space values, for benchmarking).

Fields

  • wvmat - Precomputed wv matrix spline (raw, no singfac); singfac applied analytically in free_compute_total.
  • wv_hint::Base.RefValue{Int} - Search hint for wvmat spline (different grid from equilibrium profiles).
  • sqrtamat_spline - Precomputed √A convolution matrix + surface area spline for root-area-weighted eigenvalues.
  • sqrtamat_hint::Base.RefValue{Int} - Search hint for sqrtamat spline.
  • psi, q - ψ and q values at each edge scan step.
  • total_eigenvalue, plasma_energy, vacuum_energy, vacuum_eigenvalue - ξ-space energy components at each step (NaN for failed steps). Kept for Fortran benchmarking.
  • rootA_total_eigenvalue, rootA_plasma_energy, rootA_vacuum_energy, rootA_vacuum_eigenvalue - Root-area-weighted flux (Φ-space) energies (Jacobian-invariant; NaN for failed/singular steps). These are the values written to EdgeScan/ by default.
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.ForceFreeStatesControlType
ForceFreeStatesControl

A mutable struct containing control parameters for stability analysis, set by the user in gpec.toml.

Fields

  • verbose::Bool - Enable verbose output
  • local_stability_flag::Bool - Enable local stability analysis (D_I and ballooning)
  • mat_flag::Bool - Enable matrix output
  • ode_flag::Bool - Enable ODE integration diagnostics
  • vac_flag::Bool - Enable vacuum region calculation
  • mthvac::Int - Number of vacuum poloidal grid points (corresponds to mtheta in VacuumInput)
  • nzvac::Int - Number of vacuum toroidal grid points (corresponds to nzeta in VacuumInput3D)
  • sing_start::Int - Start integration at the sing_start-th singular surface
  • nn_low::Int - Lower bound for toroidal modes
  • nn_high::Int - Upper bound for toroidal modes
  • delta_mlow::Int - Expands lower bound of Fourier harmonics by delta_mlow
  • delta_mhigh::Int - Expands upper bound of Fourier harmonics by delta_mhigh
  • nstep::Int - Maximum number of integration steps (not yet implemented)
  • ksing::Int - Singular surface handling parameter
  • eulerlagrange_tolerance::Float64 - Relative tolerance for ODE integration of Euler-Lagrange equations
  • ucrit::Float64 - Critical value of unorm ratio to trigger solution normalization. In the standard path it triggers Gaussian reduction; in the Riccati path it triggers renormalize_riccati_inplace!. Default 1e4 empirically keeps max(|U₁|, |U₂|) in O(1)–O(10⁴) over the integration domain on DIII-D / Solovev sweeps; lower triggers excess renorms without accuracy gain, higher risks overflow before the next renorm.
  • numsteps_init::Int - Initial array size for ODE data storage
  • numunorms_init::Int - Initial array size for solution normalization data
  • singfac_min::Float64 - Fractional distance from rational q at which ideal jump condition is enforced
  • cyl_flag::Bool - Make deltamlow and deltamhigh set the actual m truncation bounds. Default is to expand (nqmin-4, nqmax).
  • set_psilim_via_dmlim::Bool - Truncate the integration domain at (last_rational_q + dmlim) / n rather than at qhigh / psihigh. Fortran STRIDE found that truncating ~20 % above the outermost rational (dmlim = 0.2) avoids a numerical kink instability in δW that appears when the integration ends too close to or just below a rational surface. For diverted equilibria where q → ∞ at the separatrix (e.g. DIII-D geqdsks, the bulk of production use) this costs negligible physical domain because rationals get arbitrarily dense near the LCFS — set_psilim_via_dmlim = true is the safe and recommended default. For limited circular / analytical equilibria with finite q at the edge (Solovev, LAR scans), rationals are sparse and 20 % above the last rational chops off too much edge, so set set_psilim_via_dmlim = false and let qhigh / psihigh control the truncation. Multi-n runs are not supported by this truncation (the "outermost rational + dmlim / n" depends on which n); when set_psilim_via_dmlim = true with nn_low != nn_high, sing_lim! warns and falls back to qhigh / psihigh. Default true.
  • dmlim::Float64 - Distance beyond last rational surface (normalised ∈ [0,1) in units of 1/n). Only used when set_psilim_via_dmlim is true. Fortran STRIDE convention is 0.2 (truncate 20 % of one rational-surface spacing above the last surface), retained here.
  • sing_order::Int - Order of singular layer (Frobenius) expansion at rational surfaces. Default 6 (Fortran STRIDE convention for Δ' calculations; lower values trade accuracy for speed).
  • qhigh::Float64 - Integration terminated at q limit determined by minimum of qhigh and qa from equil
  • kinetic_source::String - Kinetic matrix source: "fixed" (X-shaped test matrices scaled by kinetic_factor relative to ideal matrix Frobenius norms; Ak, Dk, Hk Hermitian, Bk, Ck, Ek non-Hermitian), "calculated" (PENTRC — not yet implemented)
  • kinetic_factor::Float64 - Dimensionless scaling factor for kinetic matrices. Zero (the default) disables the kinetic path; any positive value enables it and scales the kinetic matrices: when kineticsource="fixed", scales X-shaped test matrices relative to ideal matrix norms; when kineticsource="calculated", applied as uniform post-hoc multiplier to W and T components.
  • qlow::Float64 - Integration terminated at q limit determined by minimum of qlow and q0 from equil
  • reform_eq_with_psilim::Bool - Reform equilibrium with computed psilim (not yet implemented)
  • psiedge::Float64 - If less than psilim, records a dW(ψ) diagnostic scan over [psiedge, psilim] on odet.edgescan. The integration domain (psilim) is always controlled by qhigh / psihigh and is not modified by this scan (unless `truncateatdWpeak=true`, see caveats below).
  • truncate_at_dW_peak::Bool - When true and psiedge < psilim, the edge-dW scan's peak location is adopted as the new physical plasma edge — intr.psilim/intr.qlim/odet.u are pulled back to the peak, AND the FM Δ' chunks/propagators are made self-consistent with the new boundary (the chunk that straddles the peak is rebuilt + re-integrated; any chunks past the peak are dropped). This reproduces the spirit of the original oderecordedge heuristic from Fortran STRIDE while keeping Δ' and δW well-defined at the new boundary. The Δ' metric is still physically dependent on where the peak falls in the edge band, so use this flag deliberately when you mean to scan against the peak-defined edge (e.g. for studying edge-mode regimes); leave at false (default) for the full-domain Δ' at qhigh / psihigh / dmlim.
  • diagnose::Bool - Enable diagnostic output (not yet implemented)
  • diagnose_ca::Bool - Enable asymptotic coefficient diagnostics (not yet implemented)
  • write_outputs_to_HDF5::Bool - Write results to HDF5 format
  • HDF5_filename::String - Name of HDF5 output file
  • force_wv_symmetry::Bool - Boolean flag to enforce symmetry in the vacuum response matrix
  • save_interval::Int - Save every Nth ODE step (1=all, 10=every 10th). Always saves near rational surfaces. (Same as euler_step in the Fortran)
  • force_termination::Bool - Terminate after force-free states (skip perturbed equilibrium calculations)
  • use_riccati::Bool - Use the dual Riccati reformulation S = U₁·U₂⁻¹ instead of the standard U₁/U₂ ODE. Reduces stiffness for faster integration. See Glasser (2018) Phys. Plasmas 25, 032507.
  • use_parallel::Bool - Parallel fundamental matrix (propagator) integration using Threads.@threads. Each chunk is integrated independently from identity IC and assembled serially. Requires singfac_min != 0. Uses the same chunk bounds as the standard path but sub-divides chunks for load balancing. Crossings use the Riccati-style algorithm (no Gaussian reduction).
  • parallel_threads::Int - Cap on the number of threads the parallel BVP uses. Default 2 parallelises the FM chunks across two threads (the BVP has ~10 chunks; 2 threads is enough to amortize them — speedup saturates here, raising to 4 adds scheduling overhead). Set parallel_threads = 1 to run the FM chunks SERIALLY (no Threads.@threads), which is bit-deterministic and immune to the thread-schedule sensitivity that historically caused intermittent BVP divergences on numerically delicate equilibria like DIII-D 147131 (see CONVENTIONS.md §7). Empirical reliability sweep (5 trials × {1,2,4} on DIII-D 147131 βₚ≈0.07): 15/15 bit-identical Δ′ at every setting; pt=2 ≈ pt=4 ≈ 20 % faster than serial. If a parallel run diverges, drop to parallel_threads = 1 rather than switching use_parallel = false — the latter is silently wrong. Capped at Threads.nthreads().
  • populate_dense_xi::Bool - When use_parallel = true, append a serial Euler-Lagrange pass at the end of the propagator BVP and let it replace the odet returned to the main pipeline. This populates u_store / ud_store densely in the axis (EL) basis — the only convention the PerturbedEquilibrium / FieldReconstruction downstream code consumes correctly. Without it the parallel path stores only chunk-endpoint Riccati S matrices and zeros for ud_store (see Riccati.jl docstring caveats), and HDF5 integration/xi_psi/dxi_psi/xi_s are unusable. Δ' (singular/delta_prime_matrix) is computed from the parallel BVP and is bit-identical between populate_dense_xi=true and false. Energies (vacuum/ep/ev/et) are computed by free_run! from odet, so with populate_dense_xi=true they match what a pure serial run (use_parallel=false) would produce; with populate_dense_xi=false they use the parallel-pass Riccati odet.u instead (differs by the ~0.12 % Riccati-vs-axis algorithmic gap on DIIID-class cases). Default false to avoid paying the dense-pass cost on Δ'/vacuum/ideal-stability-only runs; PerturbedEquilibrium-using configs must set populate_dense_xi = true explicitly when use_parallel = true (otherwise PE silently reads Riccati-basis garbage). Auto-disabled when force_termination = true regardless of the user setting, since the dense pass has no downstream consumer in that case. Approximate cost when enabled: one extra serial EL integration (~1× the parallel BVP wall-clock for typical N).
  • extended_precision_bvp::Bool - When true (default), promote the Δ' BVP linear system to Complex{Double64} (~31 digits) for the LU solve and PEST3 combination. Guards against catastrophic cancellation in the PEST3 four-term combination (dp_raw entries can be 10⁴–10⁵× larger than the result; the imaginary part of off-diagonal Δ' is particularly sensitive). Disabling (false) saves ~1.5–2× the BVP solve time but on DIIID-class equilibria the imaginary Δ' components can drift by factors of 2–5×; only disable for performance experiments on cases where Float64 has been validated against Double64.
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.ForceFreeStatesInternalType
ForceFreeStatesInternal

A mutable struct holding internal state variables for stability calculations.

Fields

  • dir_path::String - Directory path for input/output files
  • mlow::Int - Lowest poloidal mode number
  • mhigh::Int - Highest poloidal mode number
  • mpert::Int - Number of poloidal modes (mhigh - mlow + 1)
  • nlow::Int - Lowest toroidal mode number
  • nhigh::Int - Highest toroidal mode number
  • npert::Int - Number of toroidal modes (nhigh - nlow + 1)
  • numpert_total::Int - Total number of modes (mpert × npert)
  • keq_out::Bool - Flag to output equilibrium quantities (not yet implemented)
  • theta_out::Bool - Flag to output theta coordinate data (not yet implemented)
  • xlmda_out::Bool - Flag to output eigenvalue data (not yet implemented)
  • sol_base::Int - Base index for solution vectors (not yet implemented)
  • msing::Int - Number of ideal singular surfaces
  • kmsing::Int - Number of kinetic singular surfaces (det(F̄) near-zeros)
  • sing::Vector{SingType} - Vector of ideal singular surface data
  • kinsing::Vector{SingType} - Vector of kinetic singular surface data
  • kinsing_scan_psi::Vector{Float64} - ψ grid used by find_kinetic_singular_surfaces! for the cond(F̄) scan (empty unless the finder has run)
  • kinsing_scan_cond::Vector{Float64} - cond(F̄) values on that grid; the finder locates peaks that exceed kinsing_scan_threshold
  • kinsing_scan_threshold::Float64 - Threshold on cond(F̄) used to accept a peak as a kinetic singular surface
  • psilim::Float64 - Flux limit for integration
  • qlim::Float64 - Safety factor at psilim
  • q1lim::Float64 - Safety factor derivative at psilim
  • locstab::CubicSeriesInterpolant - Spline for local stability analysis
  • wall_settings::Vacuum.WallShapeSettings - Wall shape settings for vacuum calculations
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.IntegrationChunkType
IntegrationChunk

A struct representing a region of integration in the Euler-Lagrange solver.

Fields

  • psi_start::Float64 - Starting ψ coordinate for this integration region
  • psi_end::Float64 - Ending ψ coordinate for this integration region
  • needs_crossing::Bool - Whether a rational surface crossing is needed after this chunk
  • ising::Int - Index of the singular surface associated with this chunk (0 if none)
  • direction::Int - Integration direction: +1 forward (axis→edge), -1 backward (edge→axis). For direction=-1 chunks, psi_start < psi_end but integration proceeds from psi_end toward psi_start. The resulting propagator maps state at psi_end → state at psi_start. Used in bidirectional parallel FM to produce well-conditioned crossing-chunk propagators: solutions that grow exponentially forward (toward a singularity) decay when integrated backward, so the backward propagator is well-conditioned.
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.OdeStateType

OdeState

A mutable struct to hold the state of the ODE solver used by the ForceFreeStates integration routines. This struct stores configuration parameters used to allocate arrays, the evolving stored solution during integration, diagnostic arrays used for normalization / Gaussian reduction, and a small set of temporary matrices and factors used to compute singular-layer corrections.

Fields

  • numpert_total::Int - Total number of Fourier mode combinations (m × n) used in the calculation.

  • numunorms_init::Int - Initial allocation size for the number of normalization operations recorded.

  • msing::Int - Number of singular surfaces in the equilibrium (used to size asymptotic coefficient arrays).

  • numsteps_init::Int - Initial allocation size for the number of integration steps to store.

  • step::Int - Current integration step index (1-based, like istep in the original Fortran).

  • psi_store::Vector{Float64} - Stored psi values at each saved integration step (length numsteps_init).

  • q_store::Vector{Float64} - Stored q values at each saved integration step (length numsteps_init).

  • u_store::Array{ComplexF64,4} - Stored solution arrays at each saved step with shape (numpert_total, numpert_total, 2, numsteps_init) (complex solution state used by the solver).

  • ud_store::Array{ComplexF64,4} - Stored derivatives of the solution at each saved step with same shape as u_store.

  • crit_store::Vector{Float64} - Stored crit parameter values (smallest eigenvalue of W⁻ꜝ) (length numsteps_init).

  • ca_r::Array{ComplexF64,4} - Asymptotic coefficients just to the right of each singular surface with shape (numpert_total, numpert_total, 2, msing).

  • ca_l::Array{ComplexF64,4} - Asymptotic coefficients just to the left of each singular surface with shape (numpert_total, numpert_total, 2, msing).

  • edge_scan::EdgeScanState - Edge dW scan state and results. Initialized as a disabled sentinel (Nedge=0) and replaced by `findmaxdW_edge!` when a scan runs.

  • psifac::Float64 - Current normalized flux coordinate for the integrator.

  • q::Float64 - Safety factor value at psifac (current q during integration).

  • u::Array{ComplexF64,3} - Current working solution arrays with shape (numpert_total, numpert_total, 2).

  • ud::Array{ComplexF64,3} - Current working solution derivative (different than du) arrays with shape (numpert_total, numpert_total, 2).

  • ising_start::Int - Index of the starting singular surface to be crossed during integration.

    Initialization parameters

  • psimax::Float64 - Maximum psi value for which the integrator is allowed to run in next integration region.

  • needs_crossing::Bool - Flag indicating whether a rational surface needs to be crossed after the current integration region.

  • nzero::Int - Count of detected zero crossings (used for diagnostics).

    Saved data throughout integration

  • new::Bool - Flag indicating whether a new unorm0 should be computed after a fixup.

Total ODE solver steps taken (all steps, not just saved ones)

  • unorm::Vector{Float64} - Current norms of the solution vectors (length numpert_total).

  • unorm0::Vector{Float64} - Reference/initial norms of the solution vectors (length numpert_total).

  • ifix::Int - Number of normalization operations performed (index into normalization arrays).

  • index::Array{Int,2} - Index matrix used for sorting solution norms with shape (numpert_total, numunorms_init).

  • sing_flag::Vector{Bool} - Boolean flags indicating which stored normalizations correspond to singular solutions # Edge dW scan state and results (disabled sentinel when psiedge >= psilim, i.e. no edge scan) (length numunorms_init).

  • zeroed_idx::Vector{Vector{Int}} - For each ideal rational surface jump, a vector of indices of solutions that were zeroed. # Data for integrator

  • fixfac::Array{ComplexF64,3} - Fix-up factors for Gaussian reduction with shape # Initialization parameters (numpert_total, numpert_total, numunorms_init).

  • fixstep::Vector{Int64} - Step indices (psi step positions) at which normalization/fixups were performed (length numunorms_init).

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.SingAsymptoticsType
SingAsymptotics

A struct containing asymptotic expansion data for ideal ForceFreeStates calculations at a singular surface. This data is computed on-demand during singular surface crossings in cross_ideal_singular_surf!.

Fields

  • alpha::Vector{ComplexF64} - Resonant matrix eigenvalues
  • r1::Vector{Int} - Resonant indices along first index
  • r2::Vector{Int} - Resonant indices along second index
  • n1::Vector{Int} - Nonresonant indices along first index
  • n2::Vector{Int} - Nonresonant indices along second index
  • power::Vector{ComplexF64} - Power series coefficients
  • vmat::Array{ComplexF64,4} - Power series of V matrix for asymptotic analysis
  • mmat::Array{ComplexF64,4} - Power series of M matrix for asymptotic analysis
  • m0mat::Matrix{ComplexF64} - Zeroth order M matrix projected onto resonant subspace
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.SingTypeType
SingType

A mutable struct holding data related to the singular surfaces in the equilibrium.

Fields

  • psifac::Float64 - Normalized flux coordinate at the singular surface
  • rho::Float64 - Radial coordinate (√ψ)
  • m::Vector{Int} - Poloidal mode number(s)
  • n::Vector{Int} - Toroidal mode number(s)
  • q::Float64 - Safety factor (= m/n)
  • q1::Float64 - Derivative of safety factor with respect to ψ
  • grri::Array{Float64,2} - Interior Green's function at this surface [2mthvac, 2mpert]
  • grre::Array{Float64,2} - Exterior Green's function at this surface [2mthvac, 2mpert]
  • delta_prime::Vector{ComplexF64} - STUB (not physically valid). Per-surface ca-based Δ' estimate retained for future work / debugging only. The physically valid Δ' is ForceFreeStatesInternal.delta_prime_matrix, computed via the STRIDE global BVP (Glasser 2018 PoP 25, 032501). Do not use this field for tearing-stability analysis; do not expect agreement with delta_prime_matrix.
  • delta_prime_col::Matrix{ComplexF64} - STUB (not physically valid). Per-surface ca-based Δ' column retained for future work / debugging only. Shape (numperttotal × nresmodes); `deltaprimecol[j, i] = (car[j,ipertresi,2] - cal[j,ipertresi,2]) / (4π²·psio). The diagonal element matches the (also stubbed)deltaprime[i]. Only populated for the Riccati/parallel FM paths. The physically valid Δ' isForceFreeStatesInternal.deltaprimematrix`; this field exists for future development on intra-surface coupling diagnostics, not for production use.
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.VacuumDataType
VacuumData

A struct containing relevant data from the vacuum calculation. Populated in Free.jl.

Fields

  • numpoints::Int - Total number of points in the vacuum calculation (mthvac * nzvac)
  • numpert_total::Int - Total number of modes (mpert × npert)
  • mthvac::Int - Number of vacuum poloidal grid points (corresponds to mtheta in VacuumInput) - only needed for GPEC functionality currently
  • wt::Array{ComplexF64, 2} - Free-boundary eigenvector matrix after diagonalising W (numperttotal × numperttotal). Columns are the eigenmodes sorted most-unstable first. ξ-space.
  • wt0::Array{ComplexF64, 2} - Free-boundary total-energy matrix W = wp + wv before diagonalisation (numperttotal × numperttotal). ξ-space.
  • wp::Array{ComplexF64, 2} - Plasma energy matrix (numperttotal × numperttotal). ξ-space.
  • wv::Array{ComplexF64, 2} - Vacuum energy matrix (numperttotal × numperttotal). ξ-space.
  • rootA_wt0, rootA_wp, rootA_wv::Array{ComplexF64,2} - Root-area-weighted flux (Φ-space) counterparts of wt0, wp, wv at the plasma edge (see RootAreaWeighted.jl). Jacobian-invariant up to the M†·W·M transform.
  • rootA_wt::Array{ComplexF64,2} - Φ-space eigenvector matrix of rootA_wt0 (columns sorted most-unstable first, phase-normalized so each column's largest-magnitude entry is real-positive).
  • ep::Vector{ComplexF64} - Plasma eigenvalues
  • ev::Vector{ComplexF64} - Vacuum eigenvalues
  • et::Vector{ComplexF64} - Total eigenvalues of plasma + vacuum
  • n_tor_idx::Vector{Int} - 0-based toroidal mode number index of each sorted eigenvalue (numperttotal). Needed in `writeimas`
  • vacuum_eigenvalue::Float64 - Least stable (minimum) eigenvalue of the vacuum matrix wv, clamped to zero
  • grri::Array{Float64, 2} - Interior Green's function matrices (2 * mthvac * nzvac × 2 * numpert_total)
  • grre::Array{Float64, 2} - Exterior Green's function matrices (2 * mthvac * nzvac × 2 * numpert_total)
  • plasma_pts::Array{Float64, 3} - Cartesian coordinates of plasma points, shape (mthvac * nzvac) × 3 for (x, y, z)
  • wall_pts::Array{Float64, 3} - Cartesian coordinates of wall points, shape (mthvac * nzvac) × 3 for (x, y, z)
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_alpha_boundariesMethod
ballooning_alpha_boundaries(ctrl, plasma_eq; theta_k=0.0, max_alpha_scale=8.0, n_scan=24)

Profile driver returning the experimental pressure gradient alpha, the first stability boundary alpha_critical1 (lowest Δ' zero), and the second stability boundary alpha_critical2 (next zero above it) versus normalized flux psi, from ballooning_alpha_crossings at each surface. Arrays contain NaN where no boundary exists.

n_scan sets the scan resolution of the crossing search; crossings closer together than the scan step can be missed, which shows up as isolated outliers on otherwise smooth boundary curves.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_alpha_boundaryMethod
ballooning_alpha_boundary(ctrl, plasma_eq; theta_k=0.0, n_scan=24)

Profile driver for the BALOO-style ballooning stability diagram. Loops over flux surfaces returning the experimental pressure gradient alpha (from salpha_reference) and the first stability boundary alpha_critical (from critical_ballooning_alpha) versus normalized flux psi. Surfaces whose experimental alpha exceeds alpha_critical are ballooning-unstable.

This is the fast first-boundary-only driver: critical_ballooning_alpha stops at the first crossing, so cost scales with the boundary location rather than the full α range. Use this (not ballooning_alpha_boundaries) when only the 1st boundary is needed, e.g. a pedestal-stability constraint. n_scan sets the scan resolution; raise it (e.g. 64) on edge surfaces where Δ' poles can shift a coarse first crossing.

Per-surface failures and surfaces with no boundary within range are returned as NaN.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_alpha_crossingsMethod
ballooning_alpha_crossings(psi_idx, plasma_eq; theta_k=0.0, max_alpha_scale=8.0, n_scan=24, tol=1e-3)

Locate the marginal-stability crossings of the ballooning Δ' along the pressure-gradient scaling α/α_exp ∈ [0, max_alpha_scale] at fixed magnetic shear. Marching up from the ballooning-stable α = 0 anchor, every sign change between scan samples is bisected to tol; sign changes with endpoint |Δ'| far above the anchor magnitude are Δ' pole crossings (not marginal points) and are skipped. The first crossing is the first stability boundary, the second the second-stability boundary, and so on.

Returns (scales, alphas, reference) with alphas = alpha_ref * scales, ordered in increasing α. Surfaces that never cross return empty vectors. With max_crossings > 0 the scan stops after that many crossings (e.g. 1 for a first-boundary-only search, which avoids scanning the full α range above the boundary).

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_delta_primeMethod
ballooning_delta_prime(psi_idx, plasma_eq; corr_qprime=0.0, corr_pprime=0.0, theta_k=0.0)

Evaluate one corrected local ballooning point. Corrections use the same normalized units as prepare_ballooning_coefficients: add corr_qprime to dq/dpsi_norm and corr_pprime to d(mu0*p)/dpsi_norm. di is the det(d0bar) diagnostic assembled from the same corrected coefficients.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_delta_prime_mapMethod
ballooning_delta_prime_map(ctrl, plasma_eq; theta_k=0.0, max_alpha_scale=8.0, n_alpha=61, max_surfaces=40)

Map the ballooning Δ' over the (ψN, α) plane at fixed magnetic shear: at each flux surface, scan the pressure-gradient scaling from 0 to `maxalphascaletimes the experimental α. The flux grid is thinned to at mostmaxsurfaces` surfaces to bound the cost (each point is one ballooning ODE solve).

Returns a NamedTuple (psi, alpha_scales, alpha_ref, delta_prime) where delta_prime is [n_surf × n_alpha] with NaN on failed surfaces; physical α = alpha_ref * scale.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_qprime_boundariesMethod
ballooning_qprime_boundaries(ctrl, plasma_eq; theta_k=0.0, min_qprime_scale=-2.0, max_qprime_scale=4.0, n_scan=24)

Profile driver returning the experimental shear profile qprime (dq/dpsi_norm), the critical shear qprime_critical1 below which the surface is ballooning unstable at the experimental pressure gradient (first crossing marching down from max_qprime_scale), and qprime_critical2, the next crossing at lower (or reversed) shear, versus normalized flux psi, from ballooning_qprime_crossings at each surface. Arrays contain NaN where no boundary exists.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_qprime_crossingsMethod
ballooning_qprime_crossings(psi_idx, plasma_eq; theta_k=0.0, min_qprime_scale=-2.0, max_qprime_scale=4.0, n_scan=24, tol=1e-3)

Locate the marginal-stability crossings of the ballooning Δ' along the magnetic shear scaling q'/q'_exp ∈ [min_qprime_scale, max_qprime_scale] at the fixed experimental pressure gradient — the q'-channel counterpart of ballooning_alpha_crossings. The march starts from the first-regime-stable high shear end (max_qprime_scale) and proceeds downward; sign changes are bisected and Δ' pole crossings skipped exactly as in the α scan, so the first crossing is the critical q' and the second the re-entry to stability at lower (or reversed) shear.

Returns (scales, qprimes, reference) where qprimes = qprime_norm_ref * scales is in dq/dpsi_norm units, ordered in decreasing q'.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_qprime_delta_prime_mapMethod
ballooning_qprime_delta_prime_map(ctrl, plasma_eq; theta_k=0.0, min_qprime_scale=-2.0, max_qprime_scale=4.0, n_qprime=61, max_surfaces=40)

Map the ballooning Δ' over the (ψN, q') plane at the fixed experimental pressure gradient: at each flux surface, scan the magnetic shear scaling from `minqprimescaletomaxqprimescaletimes the experimental q' — the q'-channel counterpart of [ballooningdeltaprimemap](@ref). The flux grid is thinned to at mostmax_surfaces` surfaces to bound the cost.

Returns a NamedTuple (psi, qprime_scales, qprime_ref, delta_prime) where delta_prime is [n_surf × n_qprime] with NaN on failed surfaces; physical q' = qprime_ref * scale in dq/dpsi_norm units.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_ballooning_odeMethod
compute_ballooning_ode(solution, parameters, poloidal_angle) -> SVector{2,Float64}

Defines the RHS of the first-order ODE system for ideal marginal ballooning modes. Used by the DifferentialEquations.jl ODE solver.

The second-order equation d/dθ(f·dy/dθ) + g·y = 0 is rewritten as a 2x1 system:

  • dy₁/dθ = y₂ / f
  • dy₂/dθ = -g · y₁

where y₁ is the solution and y₂ = f·dy/dθ.

Arguments

  • solution: Current state vector [y₁, y₂] (an SVector{2}). Returns the derivative [dy₁/dθ, dy₂/dθ] as an SVector{2}.
  • parameters::Tuple: (odecoefficientspline, reference_angle).
  • poloidal_angle::Float64: Current poloidal angle θ (independent variable).
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_ballooning_stability!Method
compute_ballooning_stability!(ctrl, locstab_fs, plasma_eq)

Main driver routine for local high-n stability analysis. Iterates over all magnetic flux surfaces, prepares ballooning coefficients, stores det(d0bar) as the local Di diagnostic, and optionally integrates the ballooning equation to compute Delta Prime.

Arguments

  • ctrl::ForceFreeStatesControl: Control parameters for the analysis.
  • locstab_fs::Matrix{Float64}: Local stability matrix to store results (modified in place).
  • plasma_eq::Equilibrium.PlasmaEquilibrium: Plasma equilibrium data.

This function modifies locstab_fs in place with:

  • Column 1: det(d0bar) * ψ (Mercier interchange D_I)
  • Column 2: resistive interchange D_R * ψ, using det(d0bar) for D_I and the surface-average route for H (see resistive_interchange_h)
  • Column 4: Delta Prime (Δ')
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.critical_ballooning_alphaMethod
critical_ballooning_alpha(psi_idx, plasma_eq; theta_k=0.0, max_alpha_scale=8.0, n_scan=24, tol=1e-3)

First infinite-n ballooning stability boundary at one flux surface: the lowest Δ' zero from ballooning_alpha_crossings. α is linear in dp/dψ, so the boundary maps directly to α_crit = α_ref * scale_crit.

Returns (alpha_crit, alpha_scale_crit, found). When no crossing exists within max_alpha_scale (always stable, or second-stability access) alpha_crit = NaN and found = false.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.resistive_interchange_hMethod
resistive_interchange_h(flux_surface_index, plasma_eq)

Resistive interchange helper for D_R = D_I + (H - 1/2)² at a single flux surface [Glasser-Greene-Johnson; Glasser Phys. Plasmas 23, 112506 (2016)]. The intermediate H is formed from flux-surface averages of the field and metric quantities.

The main local-stability scan takes D_I from the det(d0bar) calculation reported as locstab/di, then combines it with this surface-average H to form locstab/dr. This avoids recomputing a separate surface-average D_I inside the D_R path.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.scan_delta_prime_mapMethod
scan_delta_prime_map(psi_idx, plasma_eq; s_scales, alpha_scales, ...)

Run a local s-alpha style scan at one flux surface using the Ballooning.jl makefromZero ballooning path. The scan perturbs physical dq/dpsi and dp/dpsi, converts them to the normalized units expected by the local ballooning evaluator, and returns delta-prime and det(d0bar) Di maps.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.resist_evalMethod
resist_eval(sing, equil, intr; eta, rho, gamma, ising=0) -> InnerLayer.GGJParameters

Compute the GGJ resistive inner-layer parameters at the rational surface sing. Port of RDCON resist_eval (resist.f), but with η and ρ supplied by the caller (no built-in Spitzer/density model). Returns an InnerLayer.GGJParameters carrying the curvature/mass coefficients E, F, G, H, K, M, the local time scales τA (taua), τR (taur), v1 (V′ normalized by total volume), and the surface index ising.

Required keyword arguments

  • eta::Real — plasma resistivity η at this surface [Ω·m]
  • rho::Real — mass density ρ at this surface [kg/m³]
  • gamma::Real — ratio of specific heats (enters G; physical thermodynamic value, e.g. 5/3)

Physics

The six flux-surface averages (Fortran avg(1:6)) are ⟨B²/|∇ψ|²⟩, ⟨1/|∇ψ|²⟩, ⟨1/B²⟩, ⟨1/(B²|∇ψ|²)⟩, ⟨B²⟩, ⟨|∇ψ|²/B²⟩, each weighted by J/V′ and integrated over θ ∈ [0,1) — exactly the resist.f integrands. The timescales are τ_A = √(ρ·M·μ₀) / |2π n q₁ χ₁ / V′| and τ_R = (⟨B²/|∇ψ|²⟩/⟨B²⟩)·μ₀/η, with η and ρ entering once, explicitly.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.apply_gaussian_reduction!Method
apply_gaussian_reduction!(u::Array{ComplexF64,3}, odet::OdeState, intr::ForceFreeStatesInternal, sing_flag::Bool)

Applies Gaussian reduction to orthogonalize solution vectors in u. Formerly ode_fixup!. Performs the same function as ode_fixup in the Fortran code, except now relevant fixfac data are stored in memory instead of dumped to euler.bin. Used when the spread in norms exceeds a threshold or when a rational surface is reached. This will update both u and relevant fields in odet in-place. See the description of compute_solution_norms! for more details on the benefits of in-place u updates.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.balance_integration_chunksMethod
balance_integration_chunks(chunks, ctrl, intr) -> Vector{IntegrationChunk}

Sub-divide integration chunks to produce a load-balanced set for parallel execution. Starts from the output of chunk_el_integration_bounds and iteratively splits the highest-cost chunk (by ode_itime_cost) until the total chunk count reaches max(2*msing + 3, 4 * Threads.nthreads()).

Each split finds the equal-cost midpoint ψmid via bisection: odeitimecost(psistart, psimid) ≈ odeitimecost(psistart, psi_end) / 2

Sub-chunks inherit needs_crossing=false and ising=0. Only the LAST sub-chunk of each original chunk retains needs_crossing=true and the original ising, so the rational surface crossing still fires at the correct ψ in the serial assembly phase.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.chunk_el_integration_boundsMethod
chunk_el_integration_bounds(odet::OdeState, ctrl::ForceFreeStatesControl, intr::ForceFreeStatesInternal)

Pre-compute all integration chunks from the current position to the edge. Returns a vector of IntegrationChunk objects, each representing a region to integrate and whether it needs a rational surface crossing beforehand.

This function replaces the iterative while-loop logic with a single upfront computation, making the integration flow more predictable and easier to parallelize (e.g., for STRIDE).

Arguments

  • odet::OdeState - ODE state struct (starting position and singular surface index)
  • ctrl::ForceFreeStatesControl - Control parameters
  • intr::ForceFreeStatesInternal - Internal data (singular surfaces, limits)

Returns

  • Vector{IntegrationChunk} - Array of integration chunks to process
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_axis_initMethod
compute_axis_init(ffit, profiles, intr, psi_low) -> (U1_init, U2_init)

Compute axis initial conditions for the Euler-Lagrange ODE via the Frobenius leading-coefficient eigenvalue problem [Glasser Phys. Plasmas 2016 112506 Eq. 51]:

lim_{ψ→0} [ψ M(ψ) − a I] v = 0

For each mode j, solves the 2×2 Frobenius eigenvalue problem for the diagonal block of A₀ = ψlow · M(ψlow). The eigenvector with Re(a) ≥ 0 is the regular (non-singular) Frobenius solution. Returns U₁init and U₂init normalized so that U₂_init = I (consistent with the N independent solutions convention).

For m≠0 the regular eigenvector has a negligible U₁ component (~ψlow^(|m|/2)), recovering the Glasser [0, I] limit as ψlow → 0. For m=0 (degenerate a≈0), the regular eigenvector is identified by dominant |U₁| component, giving the physically correct constant-displacement Frobenius solution and avoiding the spurious logarithmic irregularity.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_delta_prime_from_ca!Method
compute_delta_prime_from_ca!(odet, intr, equil)

STUB — not physically valid. Compute a per-surface Δ' estimate from the asymptotic coefficients ca_l/ca_r using Δ'[i] = (ca_r[i,i,2,s] - ca_l[i,i,2,s]) / (4π²·psio).

The physically valid tearing-stability Δ' is ForceFreeStatesInternal.delta_prime_matrix, computed via the STRIDE global BVP in compute_delta_prime_matrix!. The per-surface ca-based formula here ignores inter-surface coupling and the vacuum BC, and should not be expected to agree with delta_prime_matrix. Retained for reference / future work on intra-surface coupling diagnostics.

Not called from any integration driver. Used only by tests / benchmarks that exercise the stub formula directly.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_solution_norms!Method
compute_solution_norms!(u::Array{ComplexF64,3}, odet::OdeState, ctrl::ForceFreeStatesControl, intr::ForceFreeStatesInternal, sing_flag::Bool)

Computes norms of the solution vectors of the array u and normalizes them if this is not the first call after a fixup. Formerly ode_unorm!. Throws an error if any vector norm is zero. It then compares the variation in norms relative to initial values after a fixup, and applies the Gaussian reduction via apply_gaussian_reduction! if the variation exceeds ctrl.ucrit or if sing_flag is true. Performs the same function as ode_unorm in the Fortran code, with minor differences in indexing and array handling.

Note that we pass u as an argument to reduce the number of copies that are necessary in the integration callback, since otherwise we'd need to copy from the integrator state to odet.u and then back again. This way, we can just operate on u directly without extra copies.

Arguments

  • u: Current solution vector array, updated in-place if fixfac is called
  • sing_flag: Indicates if normalization is occuring at a singular surface or not

TODOs

Add resizing logic for unorm arrays when ifix exceeds allocated size

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.cross_ideal_singular_surf!Method
cross_ideal_singular_surf!(odet::OdeState, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal)

Handle the crossing of a rational surface during integration if kinetic mode is disabled. Formerly ode_ideal_cross!. Performs the same function as ode_ideal_cross in the Fortran code. Differences mainly in integration data storage logic, but otherwise identical. It normalizes and reinitializes the solution vector at the singularity, and updates relevant state variables.

Asymptotics are now computed on-demand here instead of being pre-computed, making it clear that asymptotic calculations are specific to ideal ForceFreeStates and not inherent to the singular surface.

Arguments

  • ising::Int - Index of the singular surface being crossed
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.cross_kinetic_singular_surf!Method
cross_kinetic_singular_surf!(odet, ctrl, equil, ffit, intr, ising)

Cross a kinetically-displaced singular surface using a simple trapezoidal step. Matches Fortran ode_kin_cross with con_flag=true (ode.f:615-619): evaluate the ODE RHS on both sides of the singularity and take a trapezoidal Euler step across. No asymptotic analysis, no Gaussian elimination — the kinetic FKG formulation absorbs the ideal singularity and the trapezoidal step handles the residual near-singularity.

Much simpler than cross_ideal_singular_surf! which requires asymptotic power series and solution vector surgery.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.eulerlagrange_integrationMethod
eulerlagrange_integration(ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal)

Main driver for integrating the Euler-Lagrange equations across the plasma and detecting singular surfaces. Formerly ode_run. Has the same functionality as ode_run in the Fortran code, with the addition of a single dump to the euler.h5 file at the end of integration instead of multiple dumps to euler.bin throughout the integration. We have made the control logic more clear by pre-computing all integration chunks upfront and using a for loop to iterate through them, eliminating the while-loop logic and making integration bounds explicit at each step. We now perform significant post-processing after integration including finding the peak dW in the edge region and evaluating the stability criterion over the entire integration, which were previously done during integration in the Fortran code.

TODOs

restype functionality if we decide to do this

Returns

An OdeState struct containing the final state of the ODE solver after integration is complete.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.findmax_dW_edge!Method
findmax_dW_edge!(odet::OdeState, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal)

Records the total dW in the integration region between ctrl.psiedge and ctrl.psilim. This performs the same function as ode_record_edge in the Fortran, but everything is now done post-integration which cleans up the logic, i.e. no "_edge" arrays.

The dW is stored at that step index in odet.dW_edge; because we initialize dW_edge to -Inf, we can just take the max value after integration to get the total dW at the edge and avoid unphysical kink modes that might occur just inside rational surfaces.

We have also separated the computation of the wv matrix spline and the total dW calculation into free_compute_wv_spline and free_compute_total respectively for clarity. We create the wv matrix spline once prior to the loop.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.initialize_el_at_axis!Method
initialize_el_at_axis!(odet::OdeState, ctrl::ForceFreeStatesControl, ffit::FourFitVars, profiles::Equilibrium.ProfileSplines, intr::ForceFreeStatesInternal)

Initialize the OdeState struct for the case of singstart = 0 (axis initialization). Formerly `odeaxisinit!. This now only initializespsifac,isingstart, andu`.

TODOs

Move isingstart logic to chunkelintegrationbounds?

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.integrate_el_region!Method
integrate_el_region!(odet::OdeState, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal, chunk::IntegrationChunk)

Integrate the Euler-Lagrange equations from psi_start to psi_end. Formerly ode_step!. Performs the same function as ode_step in the Fortran code, with the addition of a callback function to handle tolerances, normalization, and storage at each step of the integration. In Fortran, this was performed by running LSODE in one-step mode (so ode_step was called hundreds of times) and calling the relevant functions in a DO loop. Here, we use the DifferentialEquations.jl interface to achieve the same functionality in a more Julian way. The integration bounds are now explicit arguments, making it clear what region is being integrated.

Arguments

  • odet::OdeState - ODE state struct (modified in-place)
  • ctrl::ForceFreeStatesControl - Control parameters
  • equil::Equilibrium.PlasmaEquilibrium - Plasma equilibrium
  • ffit::FourFitVars - Fourier fit variables
  • intr::ForceFreeStatesInternal - Internal data
  • chunk::IntegrationChunk - Integration chunk containing start and end ψ for integration

TODOs

Check sensitivity of results to tolerances, currently using same logic as Fortran Check absolute tolerances, currently only relative tolerances are updated

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.ode_itime_costMethod
ode_itime_cost(psi1, psi2, intr) -> Float64

Estimate the relative ODE integration cost for the interval [ψ₁, ψ₂] using the empirical log-divergent cost model from STRIDE (Glasser 2018). Coefficients are the module constants ODE_COST_AXIS, ODE_COST_RAT, ODE_COST_EDGE. The cost is additive for sub-intervals not containing rational surfaces, which makes it suitable for equal-cost splitting via bisection in balance_integration_chunks.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.transform_u!Method
transform_u!(odet::OdeState, intr::ForceFreeStatesInternal)

Constructs the transformation matrices to form the true solution vectors. Effectively "undoes" the Gaussian reduction applied during fixups throughout the integration, such that we have the true solution vectors for use in GPEC. Modifies the store arrays in odet in-place. Performs a similar function as idcon_transform + idcon_build in the Fortran code, except we separate the building of the transformation matrices and determining the coefficients for a chosen force-free solution, which can be done in postprocessing.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_sing_asymptoticsMethod
compute_sing_asymptotics(singp::SingType, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal)

Calculate asymptotic vmat and mmat matrices for a singular surface. Formerly sing_vmat!. Returns a SingAsymptotics struct with the computed data instead of mutating the SingType struct. This makes it clear that asymptotics are computed on-demand for ideal ForceFreeStates and are not inherent properties of the singular surface.

See equations 41-48 in the Glasser Phys. Plasmas 2016 112506 for the mathematical details.

Arguments

  • singp::SingType: Singular surface parameters

Returns

  • SingAsymptotics: Struct containing all asymptotic expansion data
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_sing_mmat!Method
compute_sing_mmat!(mmat::Array{ComplexF64,4}, singp::SingType, ctrl::ForceFreeStatesControl, profiles::Equilibrium.ProfileSplines, ffit::FourFitVars, intr::ForceFreeStatesInternal)

Calculate asymptotic mmat matrix for a singular surface. Formerly sing_mmat!. Performs the same function as sing_mmat in the Fortran code. Main differences are 1-indexing for the expansion orders and using dense matrices instead of banded. We keep the Fortran convention of only filling in the lower half of the Hermitian matrices, and wrap the subsequent multiplications in Hermitian() calls to take advantage of the symmetry. We have tried to be explicit in which matrices have only their lower triangle stored to avoid confusion.

More specifically, in this function we construct the Taylor series M_k of the matrix M (eq. 43-44 in Glasser 2016). This involves: evaluating the cubic splines and their derivatives at the singular surface, constructing the Taylor series coefficients for each composite matrix (simple for G, more complicated for F and K since they are stored as their nonsingular forms and need to be multiplied by Q, which is itself a Taylor series so the coefficients get complicated), solving x = L v for each order in the Taylor series, and then reconstructing mmat from x.

Arguments

  • mmat::Array{ComplexF64,4}: Output array to store the computed mmat values
  • singp::SingType: Singular surface parameters

TODOs

Check third derivative accuracy in cubic splines or determine if it matters Better way to unpack the cubic splines Rename variables to be more intuitive? I don't like ff - maybe f and ffact instead of flower Add a spline for F directly instead of the lower triangular factorization to avoid complexity?

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.evaluate_fbar_conditionMethod
evaluate_fbar_condition(psi, ffit, equil, intr; hint=Ref(1))

Evaluate the condition number of the kinetic F̄ matrix at a given ψ. Uses cond(F̄) as a scale-invariant measure of near-singularity. Mirrors the intent of Fortran sing_get_f_det (sing.f:1298-1481) which computes det(F̄).

F̄(i,j) = q₁·f0(i,j)·q₂ - q₁·P(i,j) - conj(P†(j,i))·q₂ + R1(i,j)

where q₁ = m₁ - n·q(ψ), q₂ = m₂ - n·q(ψ) are the direct singularity factors.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.find_kinetic_singular_surfaces!Method
find_kinetic_singular_surfaces!(ffit, equil, intr; ngrid=2000, cond_threshold=1e8)

Find kinetically-displaced singular surfaces — locations where cond(F̄) peaks, indicating near-singularity of the kinetic F̄ matrix in the ODE RHS. Populates intr.kinsing and intr.kmsing.

Mirrors the intent of Fortran ksing_find (sing.f:1486-1616) which finds zeros of det(F̄) via adaptive bisection. Here we use condition number peaks instead of determinant zeros for better numerical robustness and scale invariance.

Algorithm:

  1. Evaluate cond(F̄) on a dense ψ grid
  2. Find local maxima (peaks where gradient changes from + to -)
  3. Refine each peak with golden-section minimization of -cond
  4. Filter by threshold and resonance condition
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_der!Method
sing_der!(
    du::Array{ComplexF64,3},
    u::Array{ComplexF64,3},
    params::Tuple{ForceFreeStatesControl, Equilibrium.PlasmaEquilibrium, FourFitVars, ForceFreeStatesInternal, OdeState, IntegrationChunk},
    psieval::Float64
)

Evaluate the derivative of the Euler-Lagrange equations [Glasser Phys. Plasmas 2016 112506 eq. 24]. This implements du/dψ for both the ideal and kinetic MHD eigenvalue problems.

This function performs the same role as sing_der in the Fortran code, with main differences coming from hiding LAPACK operations under the hood via Julia's LinearAlgebra package, so the code is much more straightforward.

This follows the Julia DifferentialEquations package format for in place updating.

ode_function!(du, u, p, t)

From DifferentialEquations.jl docs: Defining your ODE function to be in-place updating can have performance benefits. What this means is that, instead of writing a function which outputs its solution, you write a function which updates a vector that is designated to hold the solution. By doing this, DifferentialEquations.jl's solver packages are able to reduce the amount of array allocations and achieve better performance.

Wherever possible, in-place operations on pre-allocated arrays are used to minimize memory allocations. All LAPACK operations are handled under the hood by Julia's LinearAlgebra package, so we can obtain a much more simplistic code with similar performance.

Arguments

  • du::Array{ComplexF64,3}: Pre-allocated array to hold the derivative result, shape (mpert, mpert, 2), updated in-place
  • u::Array{ComplexF64,3}: Current state array, shape (mpert, mpert, 2)
  • params::Tuple{ForceFreeStatesControl, PlasmaEquilibrium, FourFitVars, ForceFreeStatesInternal, OdeState, IntegrationChunk}: Tuple of relevant structs
  • psieval::Float64: Current psi value at which to evaluate the derivative
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_find!Method
sing_find!(intr::ForceFreeStatesInternal, equil::Equilibrium.PlasmaEquilibrium)

Locate singular rational q-surfaces (q = m/nn) using a bisection method between extrema of the q-profile, and store their properties in intr.sing. Performs the same function as sing_find in the Fortran code.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_caMethod
sing_get_ca(u::Array{ComplexF64,3}, ua::Array{ComplexF64,3}, intr::ForceFreeStatesInternal)

Compute the asymptotic expansion coefficients according to equation 50 in Glasser 2016 DCON paper. Performs the same function as sing_get_ca in the Fortran code.

Arguments

  • u::Array{ComplexF64,3}: Current solution matrix, shape (numperttotal, numperttotal, 2)
  • ua::Array{ComplexF64,3}: Asymptotic solution matrix, shape (numperttotal, numperttotal, 2)
  • intr::ForceFreeStatesInternal: Internal ForceFreeStates data containing perturbation dimensions
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_duaMethod
sing_get_dua(sing_asymp::SingAsymptotics, dpsi::Float64) -> dua

Compute the derivative of the asymptotic series solution with respect to the positive distance dpsi = |ψ − ψ_res|, consistent with sing_get_ua. Port of Fortran sing_get_dua (sing.f / sing1.f). Shape (numpert_total, 2*numpert_total, 2). Used by the outer-region Galerkin solver (gal_extension, sing_matvec).

Direction is carried by the SingAsymptotics (left vs right vmat built with sig=∓1), exactly as in sing_get_ua, so dpsi is always positive and the arithmetic is real (no analytic continuation). The term-by-term derivative is built via a power array tracking each term's total exponent (in half-powers of dpsi: the 2*singorder offset, the ∓1 shearing on resonant rows, and the ∓2α on resonant columns), then the same shearing/pfac restoration and the chain-rule factor 1/(2·dpsi) are applied. The physical d/dψ sign for the left side (ψ = ψres − dpsi) is applied by the caller.

Arguments

  • sing_asymp::SingAsymptotics: Pre-computed asymptotic data (must be left- or right-specific)
  • dpsi::Float64: Positive distance from singular surface = |ψ − ψ_res|
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_dua_res!Method
sing_get_dua_res!(out, sing_asymp, dpsi) -> out

Evaluate only the big (col 1) and small (col 2) resonant columns of sing_get_dua into the caller-provided out of shape (numpert_total, 2, 2). Allocation-free; scalar per-term exponents replace the full power array. See the section comment.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_uaMethod
sing_get_ua(sing_asymp::SingAsymptotics, dpsi::Float64) -> ua

Compute the asymptotic series solution for a given singular surface. Uses direction-specific asymptotics (left: sig=-1, right: sig=+1) with positive dpsi. Matches Fortran STRIDE's sing_get_ua.

Arguments

  • sing_asymp::SingAsymptotics: Pre-computed asymptotic data (must be left or right specific)
  • dpsi::Float64: Positive distance from singular surface = |ψ - ψ_res|
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_lim!Method
sing_lim!(ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal)

Compute and set integration ψ, q, and q' limits by handling cases where user truncates before the last singular surface. Performs a similar function to sing_lim in the Fortran code. Main differences include renaming of sasflag -> setpsilimviadmlim, removing dW edge storage variables since we now store all integration terms in memory, and simplification of the logic.

The target value qlim is first determined from user-specified control parameters (ctrl.qhigh or ctrl.dmlim), subject to the constraint that it does not exceed equil.params.qmax. If set_psilim_via_dmlim is true, qlim is adjusted to the largest rational surface such that nq + dmlim < qmax. If qlim < qmax, a Newton iteration is performed to find the corresponding psilim to integrate to.

Note that the Newton iteration will be triggered if either set_psilim_via_dmlim is true or ctrl.qhigh < equil.params.qmax. Otherwise, the equilibrium edge values are used.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_matmulMethod
sing_matmul(a, b) -> c

Matrix multiplication specific to singular matrices. Identical to the Fortran sing_matmul subroutine.

Arguments

  • a::AbstractArray{ComplexF64,3}: shape (mpert, 2 * mpert, 2)
  • b::AbstractArray{ComplexF64,3}: shape (mmpert, 2 * mpert, 2)

Returns

  • c::Array{ComplexF64,3}: shape (mpert, 2 * mpert, 2)
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_matvec!Method
sing_matvec!(matvec, kmat, gmat, d1, tmp, sfvec, ffit, intr, psi, q, ua, dua) -> matvec

Allocation-free, in-place form of sing_matvec for the hot resonant-quadrature integrand. All scratch is caller-owned: kmat/gmat are N×N, d1/tmp/sfvec are length-N, matvec is N×size(ua,2). The reduced K̄/Ḡ splines write directly into kmat/gmat. The accumulation order (separate gmat*ua into tmp, then add) reproduces sing_matvec bit-for-bit — a fused 5-arg mul! would round differently, and the ill-conditioned near-singular resonant solve amplifies a 1e-16 change into ~1e-3 in Δ′.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_matvecMethod
sing_matvec(ffit::FourFitVars, intr::ForceFreeStatesInternal, psi::Float64, q::Float64, ua, dua) -> matvec

Apply the Euler-Lagrange residual operator L u = -(F u' + K u)' + (K† u' + G u) to the asymptotic solutions. Port of Fortran sing_matvec (sing.f). Returns matvec, shape (numpert_total, size(ua,2)).

Uses the reduced (Schur-complemented) ffit.kmats (= K̄) and ffit.gmats (= Ḡ) directly, with the direct singular factor singfac = m - n q applied to u' = dua[:,:,1]. The second component ua[:,:,2] is the canonical momentum F u' + K u, so -dua[:,:,2] = -(F u' + K u)'.

Arguments

  • psi: flux coordinate; q: safety factor at psi (passed in to avoid re-evaluating the spline)
  • ua, dua: asymptotic solution and its ψ-derivative from sing_get_ua/sing_get_dua
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_min!Method
sing_min!(intr::ForceFreeStatesInternal, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium)

Set the lower integration bound intr.psilow. Port of Fortran RDCON sing_min (sing.f): when qlow > qmin, the q < qlow core (including any q ≤ 1 sawtooth/internal-kink surfaces) must be excluded from the outer-region Galerkin domain — otherwise the Hermite FEM integrates through those ideal singularities without imposing the ideal constraint, contaminating Δ′ at the innermost kept surface. A Newton iteration locates ψ where q = qlow; scanning starts from the edge inward for robustness in reverse-shear cores. When qlow ≤ qmin the axis value is kept.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.solve_higher_order_vmat!Method
solve_higher_order_vmat!(vmat::Array{ComplexF64,4}, mmat::Array{ComplexF64,4}, m0mat::Matrix{ComplexF64}, alpha::Vector{ComplexF64}, r1::Vector{Int}, r2::Vector{Int}, n1::Vector{Int}, n2::Vector{Int}, power::Vector{ComplexF64}, intr::ForceFreeStatesInternal, k::Int)

Solves iteratively for the next order in the power series vmat. See equation 47 in the Glasser 2016 DCON paper. Identical to the Fortran sing_solve subroutine. Now takes individual arrays instead of singp struct.

Arguments

  • vmat::Array{ComplexF64,4}: V matrix power series (modified in-place)
  • mmat::Array{ComplexF64,4}: M matrix power series
  • m0mat::Matrix{ComplexF64}: Zeroth order M matrix
  • alpha::Vector{ComplexF64}: Eigenvalues of M₀ for resonant modes
  • r1, r2, n1, n2::Vector{Int}: Resonant and nonresonant indices
  • power::Vector{ComplexF64}: α values for all modes (0 for nonresonant)
  • k::Int: The current order in the power series expansion
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.MetricDataType
MetricData

A structure to hold the computed metric tensor components and their Fourier representation. This is the Julia equivalent of the fspline_type named metric in the Fortran fourfit_make_metric subroutine.

Fields

  • mpsi::Int: Number of radial grid points minus one.
  • mtheta::Int: Number of poloidal grid points minus one.
  • xs::Vector{Float64}: Radial coordinates (normalized poloidal flux ψ_norm).
  • ys::Vector{Float64}: Poloidal angle coordinates θ in radians (0 to 2π).
  • fs::Array{Float64, 3}: The raw metric data on the grid, size (mpsi, mtheta, 8). The 8 quantities are: g¹¹, g²², g³³, g²³, g³¹, g¹², J, ∂J/∂ψ.
  • fourier_coeffs::Utilities.FourierCoefficients: The FFT coefficients (no spline interpolation needed).
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.build_kinetic_metric_matricesMethod
build_kinetic_metric_matrices(equil, intr, metric) → NamedTuple

Compute the 5 kinetic geometric matrices (s, t, x, y, z) needed by KineticForces.BounceAveraging for kinetic W vector outer products. These encode the coupling between magnetic perturbation modes and the equilibrium geometry.

Ports Fortran dcon_interface.f lines 895-1013 (idcon_action_matrices).

Steps

  1. Compute 8 fmodb quantities on the (ψ,θ) grid from equilibrium geometry
  2. Fourier decompose via Utilities.FourierCoefficients
  3. Assemble mpert×mpert matrices from Fourier bands at each ψ
  4. Create CubicSeriesInterpolants over ψ

Returns

NamedTuple (smats, tmats, xmats, ymats, zmats) of CubicSeriesInterpolants, each mapping ψ → flattened mpert² complex vector.

Reference: [Logan et al., Phys. Plasmas 20, 122507 (2013)]

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.make_matrixMethod
make_matrix(metric::MetricData, equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal) -> FourFitVars

Constructs main ForceFreeStates matrices for a given toroidal mode number and returns them as a new FourFitVars object. See the appendix of the Glasser Phys. Plasmas 2016 112506 DCON paper for details on the matrix definitions. Performs the same function as fourfit_make_matrix in the Fortran code, except F, G, and K are stored as dense matrices. The matrix F is stored in factorized form with the lower triangle only, because F is Hermitian and can be written as F = L · Lᴴ, which speeds up calculations later (i.e. sing_der!).

Arguments

  • metric::MetricData: Metric coefficients on the (ψ, θ) grid, including Fourier representations of g^ij and J.

Returns

  • ffit::FourFitVars: A struct holding cubic spline fits of the assembled matrices

TODOs

Add kinetic metric tensor components for kinetic mode Set powers if necessary

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.make_metricMethod
make_metric(equil::Equilibrium.PlasmaEquilibrium, mpert::Int) -> MetricData

Constructs the metric tensor data on a (ψ, θ) grid from an input plasma equilibrium. The metric coefficients stored in metric.fs include:

  1. g^ψψ · J
  2. g^θθ · J
  3. g^ζζ · J
  4. g^θζ · J
  5. g^ζψ · J
  6. g^ψθ · J
  7. J (Jacobian)
  8. ∂J/∂ψ

Arguments

  • mpert::Int: Number of poloidal modes

Returns

  • metric::MetricData: A structure containing the metric coefficients, coordinate grids, and Jacobians for the specified equilibrium.
source
GeneralizedPerturbedEquilibrium.ForceFreeStates._compute_fkg_matrices!Method
_compute_fkg_matrices!(ffit, equil, intr, metric, kw_flat, kt_flat)

Pre-compute the derived F, K, G kinetic matrices at each ψ grid point and store as splines. This corresponds to fourfit_kinetic_matrix method=0 in the Fortran code (Fortran fourfit.F lines 1170-1260).

The 9 matrices computed are the Schur complement reductions of ideal (A,B,C,D,E,H) and kinetic (W,T) primitives into the F, K, G sub-matrices of the non-Hermitian kinetic ODE system (Logan 2015 Appendix C, Eqs C.1-C.11). These sub-matrices absorb the singfac dependence so that the ODE RHS in sing_der! can be assembled with explicit (m-nq) factors rather than 1/(m-nq), avoiding numerical blow-up at rational surfaces.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.make_kinetic_matrixMethod
make_kinetic_matrix(ctrl, equil, ffit, intr, metric;
                    calculated_source=nothing)

Construct kinetic energy (W) and torque (T) matrices, store as splines in ffit, and pre-compute the FKG derived matrices used by sing_der!.

Dispatches on ctrl.kinetic_source:

  • "fixed": X-shaped test matrices scaled by ctrl.kinetic_factor relative to ideal matrix Frobenius norms (Ak, Dk, Hk Hermitian; Bk, Ck, Ek non-Hermitian)
  • "calculated": Compute via the calculated_source callback. This is expected to be KineticForces.compute_calculated_kinetic_matrices injected by GeneralizedPerturbedEquilibrium.main. The callback receives (ctrl, equil, intr, metric, ffit) and returns (kw_flat, kt_flat) of shape (mpsi, np^2, 6). Callback injection is used because ForceFreeStates is loaded before KineticForces, so a direct import would invert the dependency order.

Both paths apply ctrl.kinetic_factor as a global scale before the FKG Schur reduction.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.check_for_zero_crossings!Method
check_for_zero_crossings!(odet, profiles, istep) -> zero_cross, nonherm

Check if the critical eigenvalue (crit), i.e. the smallest eigenvalue of W⁻¹, changed signs between a given integration step istep and the previous step. We first compute crit at the current step, writing the result to the crit_store array, then compare with the previous step to see if it changed signs. If so, we perform additional checks to ensure the crossing is physical and not just numerical noise, and if so, we return that a zerocrossing occurred, indicating the presence of a fixed-boundary instability. This performs the same function as `odeoutput_monitor` in the Fortran code, except we can do it post-integration rather than during and don't directly handle file outputs here.

Arguments

  • profiles::ProfileSplines: Profile splines containing equilibrium profiles
  • istep::Int: Current integration step index

Returns

  • zero_cross::Bool: True if a physical zero crossing was detected
  • nonherm::Bool: True if W⁻¹ was non-Hermitian beyond tolerance
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_smallest_eigenvalueMethod
compute_smallest_eigenvalue(u) -> crit, nonherm

Form the inverse plasma response matrix W⁻¹ using the solution matrix u and returns its minimum eigenvalue by magnitude. Performs the same function as ode_output_get_crit in the Fortran code, except we explicitly form W⁻¹ here from U₁ * U₂⁻¹ using Julia's right division operator / instead of adj(adj(U₂)⁻¹ * adj(U₁)) as done in Fortran. We have also added a check to ensure W is Hermitian within tolerance, as the matrix should be Hermitian by construction but may accumulate numerical noise during integration.

Arguments

  • u::AbstractArray{ComplexF64, 3}: Solution matrix at psi

Returns

  • crit::Float64: the computed scaled critical eigenvalue
  • nonherm::Bool: true if W⁻¹ was non-Hermitian beyond tolerance (> 1e-3)
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.evaluate_stability_criterion!Method
evaluate_stability_criterion!(odet, profiles) -> nzero

Evaluate the stability criterion over the entire integration, counting the number of zero crossings of the critical eigenvalue which indicate instability. This acts as an outer wrapper for check_for_zero_crossings! since our in-memory integration allow this to be done post-integration rather than during like the Fortran. We update the crit_store in odet in place, and return the total number of zero crossings found. If the W inverse matrix was non-Hermitian beyond tolerance at any integration steps, a warning is printed with the total count.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.free_compute_totalMethod
free_compute_total(equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal, odet::OdeState) -> ComplexF64

Compute total complex energy eigenvalue (total1). This is a trimmed down version of free_run that only computes the total energy eigenvalue for the mode unstable mode, used in findmax_dW_edge! which calls this function at each step in the psiedge -> psilim region of integration. This performs the same function as free_test in the Fortran code, except we have moved the creation of the wv matrix spline to free_compute_wv_spline and pass it in odet.edge_scan.wvmat (a complex-valued spline).

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.free_compute_wv_splineMethod
free_compute_wv_spline(ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal)

Compute a spline of vacuum response matrices over the range of psi from 'ctrl.psiedge' to intr.qlim. This is used for fast evaluation of wt during `oderecordedge. Performs the same function asfreewvmats` in the Fortran code. Currently defaults to 4 spline points per q-window minimum.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.free_run!Method
free_run!(odet::OdeState, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal) -> VacuumData

Compute the free boundary energies using the Julia port of the VACUUM code. Performs the same function as free_run in the Fortran code, except now all data is passed in memory instead of via files. This modifies odet in place to normalize the eigenfunctions stored in u_store and ud_store, and returns a VacuumData struct containing the data needed for perturbed equilibrium calculations and data dumping.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates._populate_dense_xi_via_serial_el!Method
_populate_dense_xi_via_serial_el!(odet, ctrl, equil, ffit, intr) -> fresh_odet

Replace the propagator-BVP's odet with a fresh serial-EL odet that has dense u_store / ud_store populated in axis basis (the PerturbedEquilibrium convention). The caller's odet is fully replaced by the fresh one because free_run! downstream uses odet.u[:,:,1,end] to normalize odet.u_store, so both must be in the same basis. The parallel BVP results that survive downstream are stored in intr (psilim/qlim, sing[*].deltaprime, …) and in the externally-returned propagators / chunks / `Satsurfaceleft— none of those live onodet, so replacingodet` is safe.

The dense pass uses the serial EL path (sing_der! with standard integrator_callback!, Gaussian reduction, and transform_u!) so that u_store is in the axis basis — the only convention the PerturbedEquilibrium / FieldReconstruction downstream code is known to consume correctly.

We do save and restore the intr.psilim / intr.qlim / intr.sing[*] fields that the parallel BVP populated, because the dense EL pass would otherwise overwrite them (its standard cross_ideal_singular_surf! runs unconditionally and does NOT populate delta_prime; we keep the parallel pass's values which compute_delta_prime_matrix! uses).

Called from parallel_eulerlagrange_integration when ctrl.populate_dense_xi = true (default). Approximate cost: one serial EL integration on top of the parallel BVP phase. Required to make use_parallel = true produce DCON eigenfunctions usable by the PerturbedEquilibrium downstream pipeline.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.apply_propagator!Method
apply_propagator!(odet, prop)

Apply the chunk propagator prop to the current state odet.u in-place.

The propagator acts as a linear map on the (U₁, U₂) pair:

U₁new = blockupperic[:,:,1] · U₁prev + blockloweric[:,:,1] · U₂prev U₂new = blockupperic[:,:,2] · U₁prev + blockloweric[:,:,2] · U₂prev

This correctly propagates any state (not just the identity), including the (S, I) form produced by Riccati-style crossings.

Implements the subpropagator composition Φ(ψ₂, ψ₀) = Φ(ψ₂, ψ₁) · Φ(ψ₁, ψ₀) of Glasser-Kolemen (2018) Phys. Plasmas 25, 032501 Eq. 29.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.apply_propagator_inverse!Method
apply_propagator_inverse!(odet, prop)

Apply the inverse of the chunk propagator prop to the current state odet.u in-place.

Used for backward chunks (direction=-1): the stored propagator Φbwd maps state at `psiend→ state atpsistart(well-conditioned because solutions that grow exponentially forward decay backward). To advance the Riccati state frompsistarttopsiend`, we solve Φbwd · x = uold, which gives x = Φbwd⁻¹ · uold = Φfwd · u_old.

Since Φbwd is well-conditioned, the LU solve is accurate, giving the same result as applying the (ill-conditioned) forward propagator Φfwd but with far better precision.

Implements the inverse subpropagator identity Φ(ψ₂, ψ₁) = Φ(ψ₁, ψ₂)⁻¹ of Glasser-Kolemen (2018) Phys. Plasmas 25, 032501 Eq. 33.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.assemble_fm_matrixMethod
assemble_fm_matrix(propagators, idx_range; condition=false) -> Matrix{ComplexF64}

Assemble the 2N×2N fundamental matrix (propagator) by multiplying chunk propagators in order for indices idx_range. Returns Φend * ... * Φstart, so that the result maps the IC at the start of idx_range[1] to the state at the end of idx_range[end].

Each ChunkPropagator stores the 2N columns of Φ split into two N×N×2 blocks:

  block_upper_ic[:,:,1:2] ↔ Φ[:,1:N]     (result from IC=(I,0))
  block_lower_ic[:,:,1:2] ↔ Φ[:,N+1:2N]  (result from IC=(0,I))

When condition=true, applies Gaussian reduction (condition_propagator!) after each multiplication step, following STRIDE's ode_fixup convention. This prevents exponential growth of the accumulated product: without conditioning, products of K chunk propagators can reach cond ~ (condperchunk)^K, causing catastrophic cancellation. With periodic conditioning, each step stays at O(condperchunk) and only the N well-conditioned U₂ columns (right half) survive.

Use condition=true for the axis→first-surface segment, where the axis BC (U₁=0) means only U₂ ICs are needed. Do NOT use for inter-surface segments where both U₁ and U₂ components carry physical information.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_delta_prime_matrix!Method
compute_delta_prime_matrix!(intr, propagators, chunks; wv, psio, debug, ctrl, equil, ffit)

Compute the inter-surface tearing stability matrix (msing × msing) using the STRIDE global BVP formulation [Glasser 2018 Phys. Plasmas 25, 032501, Sec. III.B].

The BVP encodes the full plasma response with unknowns at each surface boundary:

  x_axis      (N):  free IC parameters at the axis  (U₁ = 0 regular solutions)
  x_left[j]  (2N):  state at left inner-layer boundary of surface j
  x_right[j] (2N):  state at right inner-layer boundary of surface j
  x_edge      (N):  free IC parameters at the edge
  Total unknowns: nMat = (2 + 4·msing)·N

Edge boundary condition

When wv is provided (the vacuum response matrix, singfac-scaled), the edge BC follows the Fortran STRIDE convention:

  U₁ = c,  U₂ = -wv·ψ₀²·c

which is the free-boundary condition wp + wv = 0 at the edge. When wv is nothing, a conducting wall BC (U₁ = 0) is used.

Gaussian reduction (conditioning)

Forward-propagated segment propagators (axis→surface, surface→surface) can be extremely ill-conditioned (cond ~ 10²⁴) due to exponential growth of the big solution. Following STRIDE's ode_fixup, Gaussian reduction is applied to each assembled propagator's U₂ columns before inserting into the BVP matrix. This keeps the BVP matrix full-rank and well-conditioned.

Output: PEST3-convention Δ' (deltap)

The raw BVP solution is a 2·msing × 2·msing matrix dp with left/right sub-indices at each surface. The PEST3-convention Δ' matrix is the linear combination [Chance, PPPL-2527]:

  deltap(i,j) = dp(2i,2j) - dp(2i,2j-1) - dp(2i-1,2j) + dp(2i-1,2j-1)

stored in intr.delta_prime_matrix (msing × msing).

Limitations

This routine currently assumes exactly one resonant mode per singular surface (the standard single-n case). When any surface carries more than one resonant mode — i.e., a multi-n run where a single q value satisfies two distinct (m, n) tuples (e.g. q = 2 with (m=2, n=1) AND (m=4, n=2)) — the routine emits a warning and skips the inter-surface BVP rather than crashing. Generalizing the BVP to multi-resonance surfaces is tracked as a follow-up: the matrix shape becomes n_res_total × n_res_total with n_res_total = sum(length(intr.sing[j].m)) and a (surface, mode, side) ↔ BVP-row map; see PR discussion.

Note: intr.delta_prime_matrix is the only physically valid Δ' produced by this code. The per-surface ca-based stub intr.sing[*].delta_prime / delta_prime_col (populated by riccati_cross_ideal_singular_surf!) is a diagnostic placeholder for future intra-surface coupling work and is not expected to agree with delta_prime_matrix.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.condition_propagator!Method
condition_propagator!(Phi, N)

Apply Gaussian reduction to the U₂-columns (columns N+1:2N) of a 2N×2N propagator matrix in-place, following STRIDE's ode_fixup convention. Triangularizes the U₁ (upper N rows) subblock by pivoted elimination, improving the condition number so the propagator can be used in a BVP without losing numerical rank.

After conditioning, only the U₂ columns carry meaningful information; the U₁ columns (1:N) are zeroed. The BVP axis block uses Phi[:, N+1:2N] (the conditioned half).

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.integrate_fm_with_ua_icMethod
integrate_fm_with_ua_ic(chunks, chunk_range, ua, ctrl, equil, ffit, intr;
                        backward=false) -> Matrix{ComplexF64}

Re-integrate a span of chunks using ua (asymptotic solution) as initial conditions, matching Fortran STRIDE's uFMsinginit behavior. Returns a 2N×2N fundamental matrix where column j is the ODE solution at the span endpoint with IC = column j of T = [ua[:,:,1]; ua[:,:,2]].

When backward=false (default): ua is the IC at psistart, integrate forward to psiend. When backward=true: ua is the IC at psiend, integrate backward to psistart. The result maps asymptotic coefficients at psiend → state at psistart.

This provides numerically accurate propagators near singular surfaces because the ODE integrator maintains per-column relative accuracy even when columns span a 10^8+ dynamic range (big/small solutions). In contrast, post-multiplying a pre-computed identity-IC propagator by T loses the small-solution information to roundoff.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.integrate_propagator_chunk!Method
integrate_propagator_chunk!(prop, chunk, ctrl, equil, ffit, intr, odet_proxy)

Compute the fundamental matrix (propagator) for one integration chunk by solving the EL ODE twice from identity-block initial conditions.

The first solve uses IC = (IN, 0N) (U₁=I, U₂=0) and stores the result in prop.block_upper_ic. The second uses IC = (0N, IN) (U₁=0, U₂=I) and stores the result in prop.block_lower_ic.

odet_proxy is a per-thread lightweight OdeState used to provide thread-local storage for sing_der! side effects (q, ud, spline_hint). Multiple threads may call this function concurrently using distinct odet_proxy objects.

No callback is used: the propagator integration proceeds without normalization or storage steps, since the identity ICs ensure bounded solutions within each chunk.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.parallel_eulerlagrange_integrationMethod
parallel_eulerlagrange_integration(ctrl, equil, ffit, intr) -> OdeState

Parallel fundamental matrix (propagator) driver for the EL integration.

Functionally equivalent to eulerlagrange_integration, integrating all bulk chunks concurrently using Threads.@threads, then re-integrating the outer plasma serially:

  1. Chunk generation: calls chunk_el_integration_bounds, then balance_integration_chunks to sub-divide chunks for load-balanced parallel execution.
  2. Parallel phase: integrate_propagator_chunk! integrates each chunk independently from identity initial conditions (no accumulated state, no normalization/callback). Each thread uses a private OdeState proxy for sing_der! side effects.
  3. Serial assembly: propagators are applied sequentially with apply_propagator!. Rational surface crossings use riccati_cross_ideal_singular_surf! (no Gaussian reduction) matching the Riccati path convention.
  4. Outer plasma re-integration: after the last rational surface crossing, the outer plasma (from last ψs to psilim) is re-integrated using `riccatiintegratechunk!. FM propagation in this region is prone to precision loss for high N (exponential growth without renormalization); Riccati integration keeps matrices bounded and provides dense checkpoints forfindmaxdW_edge!`.

Enable via use_parallel = true in [ForceFreeStates] of gpec.toml, or by setting ctrl.use_parallel = true programmatically. Requires singfac_min != 0.

Key differences from standard integration:

  • No Gaussian reduction in the propagator BVP phase (crossings use the Riccati-style algorithm, parallel odet.ifix stays 0)
  • transform_u! is called on the parallel odet but is a no-op (ifix=0)
  • Outer plasma uses serial Riccati integration for numerical stability
  • A serial Euler-Lagrange dense pass is appended at the end and replaces the parallel odet so that u_store / ud_store are dense and in axis basis — the only convention the PerturbedEquilibrium downstream code consumes correctly. Δ' (singular/delta_prime_matrix) is computed from the parallel BVP and is bit-identical with vs. without this pass. Toggle off with ctrl.populate_dense_xi = false if only Δ' / vacuum / energies are needed and the extra serial-EL cost is unwanted (HDF5 integration/xi_* will then be sparse / zero).

Bidirectional integration for large-N accuracy: The crossing chunk (nearest to each rational surface singL[j]) is integrated backward (direction=-1, tspan reversed). Backward integration of a region where solutions grow exponentially forward causes them to decay, so the resulting backward FM Φbwd is well-conditioned. The accurate forward propagation is recovered as Φbwd⁻¹ via a stable LU solve in apply_propagator_inverse!. This follows the same principle as STRIDE (Glasser 2018 Phys. Plasmas 25, 032501). The all-forward path had ~10% energy error for the DIIID-like example (N=26, n=1); bidirectional reduces this to within 2%.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.renormalize_riccati!Method
renormalize_riccati!(odet, intr)

After a singular surface crossing, restore the canonical Riccati storage convention: u[:,:,1] = Snew = U₁new · U₂_new⁻¹ u[:,:,2] = I

riccati_cross_ideal_singular_surf! leaves u[:,:,1] = U₁new and u[:,:,2] = U₂new (not I), so this step is required before continuing the Riccati integration.

The ustore entry from the crossing correctly has U₁new and U₂new (stored before this call), so `computesmallesteigenvalue` still computes U₁new/U₂new = Snew correctly.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.renormalize_riccati_inplace!Method
renormalize_riccati_inplace!(u, N)

In-place Riccati renormalization on an arbitrary N×N×2 array: u[:,:,1] = U₁ · U₂⁻¹ (new S) u[:,:,2] = I

Used in riccati_integrator_callback! to renormalize the integrator's live state when column norms grow beyond ctrl.ucrit, analogous to Gaussian reduction in the standard ODE. This keeps the inputs to sing_der! bounded, preventing the same exponential growth that occurs in the standard (non-Riccati) ODE without Gaussian reduction.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.riccati_cross_ideal_singular_surf!Method
riccati_cross_ideal_singular_surf!(odet, ctrl, equil, ffit, intr, ising)

Cross a singular surface for the Riccati formulation. Replaces cross_ideal_singular_surf! for the Riccati integration path with two key differences:

  1. No Gaussian reduction: cross_ideal_singular_surf! calls compute_solution_norms! which applies Gaussian reduction to (S, I). This divides by pivot elements of S, which can be near-zero (S = 0 at axis and grows slowly), producing NaN/Inf in U₂. For Riccati, S is bounded so Gaussian reduction is unnecessary.

  2. Direct column zeroing: Instead of using the GR-sorted odet.index to identify the column to zero, we use ipert_res directly (the resonant mode index). This is valid since without GR there is no permutation applied to the columns of S.

Δ' normalization: This function expects odet.u in the bounded (U₁, U₂) form produced by riccati_integrate_chunk! with needs_crossing=true (final renorm skipped). cal is computed from (U₁, U₂) before the crossing, and car from (U₁new, U₂new) before renormalize_riccati!. Since column ipert_res of [U₁new; U₂new] equals the introduced asymptotic solution exactly, car[ipertres,ipertres,2] = 1 regardless of other column normalizations. This gives a physically meaningful Δ' = car - ca_l with consistent left/right normalization.

After the predictor step and asymptotic introduction, renormalize_riccati! is called to restore the canonical (S_new, I) form before continuing integration.

The ustore entry at the crossing step correctly stores (U₁new, U₂new) so that `evaluatestabilitycriterion!` can compute U₁new / U₂new = Snew correctly.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.riccati_der!Method
riccati_der!(du, u, params, psieval)

Evaluate the explicit dual Riccati ODE right-hand side: dS/dψ = w†·F̄⁻¹·w - S·Ḡ·S, w = Q - K̄·S

where Q = diag(1/(m - n·q)) is the diagonal singular factor matrix. The identity slice u[:,:,2] = I does not evolve (du[:,:,2] = 0).

REFERENCE IMPLEMENTATION — not called in production. The explicit Riccati ODE is numerically unstable for explicit solvers: the quadratic S·Ḡ·S term blows up when K̄·S ≫ Q. The production path integrates sing_der! with periodic renormalize_riccati_inplace! instead (see module docstring). Kept here for documentation of Eq. 19 in source form and for future use with implicit solvers; exercised only by unit tests that verify the formula.

See: Glasser (2018) Phys. Plasmas 25, 032507 — Eq. 19 (dual Riccati form)

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.riccati_eulerlagrange_integrationMethod
riccati_eulerlagrange_integration(ctrl, equil, ffit, intr) -> OdeState

Main driver for integrating the dual Riccati ODE across the plasma. Functionally identical to eulerlagrange_integration except:

  1. Uses riccati_integrate_chunk!: drives sing_der! with riccati_integrator_callback! which applies renormalize_riccati_inplace! (instead of Gaussian reduction) when column norms exceed ucrit
  2. Uses riccati_cross_ideal_singular_surf! instead of cross_ideal_singular_surf!: skips Gaussian reduction (avoids near-zero pivot issues when S is small near axis) and renormalizes to (S_new, I) in one step
  3. Skips transform_u! — S is already the true solution, no Gaussian-reduction undo needed

Enable via use_riccati = true in [ForceFreeStates] section of gpec.toml, or by setting ctrl.use_riccati = true programmatically.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.riccati_integrate_chunk!Method
riccati_integrate_chunk!(odet, ctrl, equil, ffit, intr, chunk)

Integrate the dual Riccati ODE from chunk.psi_start to chunk.psi_end.

Uses sing_der! as the ODE RHS with riccati_integrator_callback!, which applies renormalize_riccati_inplace! (instead of Gaussian reduction) when norms exceed ucrit. Starting state: u[:,:,1] = S_prev, u[:,:,2] = I (set by initialization or previous renorm). Ending state: u[:,:,1] = U₁, u[:,:,2] = U₂ (ratio S = U₁·U₂⁻¹ is the updated Riccati matrix).

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.riccati_integrator_callback!Method
riccati_integrator_callback!(integrator)

Callback function for the Riccati ODE integrator. Handles tolerance updates, renormalization, and storage at each step.

Uses sing_der! as the ODE RHS: u[:,:,1] = U₁ (starts as S), u[:,:,2] = U₂ (starts as I). When max(|U₁|) or max(|U₂|) exceeds ctrl.ucrit, applies renormalize_riccati_inplace! to compute S = U₁·U₂⁻¹ and reset U₂ = I. This is the Riccati analogue of Gaussian reduction in the standard integrator_callback!, and keeps the ODE inputs bounded.

source

Example usage

Run stability analysis from a TOML configuration

using GeneralizedPerturbedEquilibrium, TOML

const FFS = GeneralizedPerturbedEquilibrium.ForceFreeStates

ex     = "examples/Solovev_ideal_example"
inputs = TOML.parsefile(joinpath(ex, "gpec.toml"))

ctrl  = FFS.ForceFreeStatesControl(;
            (Symbol(k) => v for (k, v) in inputs["ForceFreeStates"])...)
equil = GeneralizedPerturbedEquilibrium.Equilibrium.setup_equilibrium(
            GeneralizedPerturbedEquilibrium.Equilibrium.EquilibriumConfig(inputs["Equilibrium"], ex))

intr  = FFS.ForceFreeStatesInternal(; dir_path=ex)
intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.WallShapeSettings(;
    (Symbol(k) => v for (k, v) in inputs["Wall"])...)
FFS.sing_lim!(intr, ctrl, equil)
intr.nlow = ctrl.nn_low; intr.nhigh = ctrl.nn_high; intr.npert = 1
FFS.sing_find!(intr, equil)
intr.mlow  = min(intr.nlow * equil.params.qmin, 0) - 4 - ctrl.delta_mlow
intr.mhigh = trunc(Int, intr.nhigh * equil.params.qmax) + ctrl.delta_mhigh
intr.mpert = intr.mhigh - intr.mlow + 1
intr.numpert_total = intr.mpert * intr.npert

metric = FFS.make_metric(equil, intr.mpert)
ffit   = FFS.make_matrix(equil, intr, metric)

# Choose integration driver.  The top-level `eulerlagrange_integration` dispatches
# to the parallel or Riccati path based on ctrl.use_parallel / ctrl.use_riccati,
# and always returns a 4-tuple (odet, propagators, chunks, S_at_surface_left).
odet, _, _, _ = FFS.eulerlagrange_integration(ctrl, equil, ffit, intr)

vac = FFS.free_run!(odet, ctrl, equil, ffit, intr)
println("Energy eigenvalue et[1] = ", real(vac.et[1]))

Inspect Δ' at singular surfaces

for s in 1:intr.msing
    sing = intr.sing[s]
    println("Surface $s: ψ = $(sing.psi_s), m/n = $(sing.m[1])/$(sing.n[1])")
    println("  Δ' = $(real(sing.delta_prime[1]))")
end

Access inter-surface Δ' matrix (parallel FM path)

# intr.delta_prime_matrix is msing × msing after parallel_eulerlagrange_integration.
# Internally the solver builds a 2·msing × 2·msing raw matrix; the stored Δ' is
# the PEST3 four-term combination that folds the raw block into a per-surface
# tearing parameter.
dpm = intr.delta_prime_matrix
println("Δ' matrix size: ", size(dpm))
println("Diagonal (self-response Δ'):")
for j in 1:intr.msing
    println("  Surface $j: ", real(dpm[j, j]))
end

Notes

  • The standard path does not populate delta_prime; use PerturbedEquilibrium.SingularCoupling for Δ' on the standard path (it reads ca_l/ca_r directly).
  • The Riccati and parallel FM paths compute Δ' inline at each crossing, using the direct diagonal formula (no GR permutation). The result in delta_prime_col[ipert_res, i] equals delta_prime[i] to machine precision.
  • delta_prime_matrix contains raw BVP coefficients, not asymptotic-normalized values; its diagonal elements do not in general equal delta_prime.
  • ODE step counts depend on the equilibrium profile and mode count; the numsteps_init parameter sets the initial allocation but the solver adapts automatically.

See also

  • docs/src/galerkin.md — RDCON outer-region Galerkin Δ′ solver (part of this module)
  • docs/src/equilibrium.md — build the PlasmaEquilibrium object required by this module
  • docs/src/vacuum.md — vacuum response computed from the EL solution in free_run!
  • docs/src/perturbed_equilibrium.md — downstream singular coupling analysis using Δ'