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 formalisms are available, selected by integrator. Forward and Riccati solve the same EL system and differ in numerical strategy and in what they leave behind for the rest of the pipeline: the forward driver returns dense displacement profiles, the Riccati driver returns the inter-surface $\Delta'$ matrix. Galerkin solves the outer region variationally instead, and is documented in docs/src/galerkin.md.

Forward integration

forward_eulerlagrange_integration is the baseline driver — our implementation of the standard DCON radial integration [Glasser 2016]. It integrates the EL ODE directly in $(U_1, U_2)$ using the adaptive 9th-order Vern9 solver. Near each rational surface the columns of $U_2$ that correspond to resonant modes are zeroed via Gaussian reduction (GR), keeping the solution bounded; transform_u! undoes the reduction at the end, so u_store comes back dense in the axis (Euler-Lagrange) basis. This is the reference path for correctness comparisons, the only path whose solution the perturbed-equilibrium stage can consume, and the only path that supports kinetic_factor > 0.

Enable with:

[ForceFreeStates]
integrator = "forward"

Riccati integration

riccati_eulerlagrange_integration (the default) is our implementation of the STRIDE approach [Glasser 2018b], built on the dual Riccati reformulation [Glasser 2018a]. It decomposes the radial domain into independent chunks, integrates each chunk's fundamental-matrix (FM) propagator in parallel using Threads.@threads, then multiplies the propagators in order and applies each singular-surface crossing serially. It is the only driver that produces the inter-surface $\Delta'$ matrix. Because chunk endpoints are all it stores, u_store is sparse and stays in the Riccati basis — dense $\xi$ profiles and the ForceFreeStates/Solutions/ForwardIntegration/xi_* datasets require the forward driver.

Crossings and the outer-plasma re-integration use 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). 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 Vern9's full 9th-order accuracy. Renormalization is triggered whenever $\max(|U_1|)$ or $\max(|U_2|)$ exceeds the threshold ucrit, 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]
integrator = "riccati"
nchunks    = 0         # 0 = auto: derived from the singular-surface count alone

Chunking and thread independence

balance_integration_chunks splits the base chunks until the count reaches a target derived from the number of singular surfaces, max(2 m_s + 3, 8(m_s + 1) + m_s), where $m_s$ is msing. Setting nchunks overrides that target; a value below the $2 m_s + 3$ floor is clamped up with a warning. The target never consults Threads.nthreads(), and every chunk integrates independently from identity initial conditions, so the results do not depend on how many threads julia -t provides — threads change wall-clock only.

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.

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

Galerkin

integrator = "galerkin" solves the same EL system variationally instead of integrating it: the outer region is discretized on packed Hermite-cubic elements and solved as one global banded system, giving the RDCON resistive $\Delta'$ matrix and the PEST-3 matching blocks. It computes its own vacuum response and returns no free-boundary energies, no ODE trace, and no fixed-boundary crit scan. With gal_match_flag it also matches the inner layer, producing a driven $\xi$ solution that PerturbedEquilibrium consumes. Kinetic runs are not supported. See docs/src/galerkin.md for the solver and its gal_* knobs.

Enable with:

[ForceFreeStates]
integrator = "galerkin"

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 LocalStability/ HDF5 group, each a profile in normalized poloidal flux $\psi$:

  • Mercier criterion $D_I$ (LocalStability/D_I) — 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$ (LocalStability/D_R) — 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 LocalStability/D_I; $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'$ (LocalStability/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

LocalStability/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 SingularSurfaces/ and PerturbedEquilibrium/SingularCoupling/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 written to the HDF5 output under SingularSurfaces/Delta_prime_matrix. The Galerkin integrator computes the same quantity in the same PEST-3 convention and publishes it on the same path, so downstream consumers (SLAYER among them) never branch on which formalism ran.

Configuration reference

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

[ForceFreeStates]
# Integration driver
integrator   = "riccati"  # "forward" for dense xi profiles and kinetic runs; "galerkin" for the RDCON outer-region solve
nchunks      = 0          # Riccati chunk-count target (0 = auto, from msing alone)

# 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.DebugSettingsType

DebugSettings

A mutable struct containing settings for debugging and benchmarking output.

Fields

  • output_benchmark_data::Bool - Flag to output benchmark data for comparison between codes
  • gal_basis_output::Bool - Write the raw Galerkin outer-region basis functions (per-interval, unconstrained at the rationals) under GalerkinIntegration/Basis/. Solver internals for development verification, not physics output.
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.DeltaPrimeDataType
DeltaPrimeData

The solve's Δ′/outer-region matching payload, in one formalism-independent layout. The Riccati/STRIDE boundary-value problem and the RDCON Galerkin solve compute the same quantities in the same PEST-3 convention — the four parity blocks are the identical ± combination of the raw side-major matrix in both (pest3_decompose, Riccati.jl, and gal_pest3_blocks, GalerkinSolve.jl, both porting Fortran gal_write_pest3_data) — so consumers never branch on which integrator ran.

Every matrix is indexed by the singular surfaces the producing formalism actually solved across, ordered core→edge: result.surfaces for Riccati, the in-domain in-band subset of it for Galerkin (gal_resonant_surfaces). Side-major orderings run [L_s1, R_s1, L_s2, R_s2, …].

Fields

  • matrix::Matrix{ComplexF64} - Inter-surface Δ′ of shape (msing × msing) in PEST3 convention, the tearing↔tearing parity projection of raw. Same as Delta of the PEST-3 block set. Both formalisms.
  • raw::Matrix{ComplexF64} - Raw outer-region matching matrix D′ of shape (2msing × 2msing), side-major on both axes. Both formalisms.
  • coil::Matrix{ComplexF64} - Edge coil-response matrix of shape (2msing × numperttotal); column k is the resonant small-solution response at each surface side to a unit source on edge poloidal mode k. Riccati fills it from the vacuum-edge BVP, Galerkin from the `galrpecflag` columns (transposed at pack time from the (numperttotal × 2msing) block the Galerkin solve produces). Empty when neither ran.
  • A::Union{Nothing,Matrix{ComplexF64}} - PEST-3 interchange↔interchange block (msing × msing). Galerkin only; nothing for Riccati, which persists only raw and recovers the blocks on demand via pest3_decompose.
  • B::Union{Nothing,Matrix{ComplexF64}} - PEST-3 interchange↔tearing block; see A.
  • Gamma::Union{Nothing,Matrix{ComplexF64}} - PEST-3 tearing↔interchange block; see A.
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/. The energies are generalized (W, N) pencil values: power-normalized and invariant to the working (Jacobian) coordinate (see power_norm_matrix!).

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).
  • psi, q - ψ and q values at each edge scan step.
  • total_eigenvalue, plasma_energy, vacuum_energy, vacuum_eigenvalue - Power-normalized energy components at each step (NaN for steps where the wp solve was singular). These drive the truncation choice and are written to EdgeScan/.
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.ForceFreeStatesControlType
ForceFreeStatesControl

An immutable struct containing 'ForceFreeStates' parameters 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)
  • 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
  • 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
  • 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
  • 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)
  • integrator::String - Which formalism integrates the Euler-Lagrange system. "forward" sweeps the plasma serially with Gaussian reduction and returns u_store / du_store / xi_s_store dense in the axis (EL) basis — the only convention PerturbedEquilibrium and FieldReconstruction consume correctly, and the only path that supports kinetic_factor > 0. "riccati" (default) runs the chunked fundamental-matrix propagator driver (Glasser 2018 Phys. Plasmas 25, 032507): chunks are integrated independently from identity initial conditions and assembled serially with Riccati-style crossings, which is the only way to obtain the singular-surface Δ' matrix for the tearing-mode solvers downstream, but leaves u_store as sparse chunk-endpoint Riccati states, so dense ξ profiles are unavailable. "galerkin" solves the same Euler-Lagrange system variationally instead of by radial ODE integration — the RDCON outer-region singular Galerkin method (Glasser, Wang & Park 2016 Phys. Plasmas 23, 112506), which discretizes the displacement on packed Hermite-cubic elements and solves one global banded system — producing the resistive Δ′ matrix and, when gal_match_flag is set, the RPEC inner-layer-matched ξ; it computes its own vacuum response and returns no free-boundary energies, and does not support kinetic_factor > 0. Requires singfac_min != 0 for "riccati".
  • nchunks::Int - Target number of Riccati integration chunks. 0 (the default) derives the count from problem structure alone: max(2·msing + 3, 8·(msing + 1) + msing), enough sub-chunks per segment to keep the accumulated propagator products well-conditioned. An explicit value below 2·msing + 3 is clamped up with a warning. Chunk sizing never consults Threads.nthreads(), so Riccati outputs are identical whatever thread count julia -t provides; threads only change wall-clock.
  • 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, resolved from ctrl.nn_low/nn_high
  • nhigh::Int - Highest toroidal mode number, resolved from ctrl.nn_low/nn_high
  • npert::Int - Number of toroidal modes (nhigh - nlow + 1)
  • numpert_total::Int - Total number of modes (mpert × npert)
  • 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
  • wall_settings::Vacuum.WallShapeSettings - Wall shape settings for vacuum calculations
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.FreeBoundaryResultType
FreeBoundaryResult

Result of the free-boundary calculation, returned by free_run. All matrices are in the ξ Fourier basis and are numpert_total × numpert_total; the energies are generalized (W, N) pencil values, power-normalized and invariant to the working (Jacobian) coordinate.

Fields

  • wt::Matrix{ComplexF64} - Eigenvector matrix of W·v = λ·N·v. Columns are eigenmodes sorted most-unstable first, normalized to unit power norm v†·N·v = 1.
  • wt0::Matrix{ComplexF64} - Total-energy matrix W = wp + wv before diagonalisation
  • wp::Matrix{ComplexF64} - Plasma energy matrix
  • wv::Matrix{ComplexF64} - Vacuum energy matrix, singfac-scaled at qlim
  • ep::Vector{ComplexF64} - Plasma energy per eigenmode (power quotient v†·wp·v with v†·N·v = 1)
  • ev::Vector{ComplexF64} - Vacuum energy per eigenmode (power quotient v†·wv·v with v†·N·v = 1)
  • et::Vector{ComplexF64} - Total energy eigenvalues of the pencil (W, N); et = ep + ev per mode
  • n_tor_idx::Vector{Int} - 0-based toroidal mode number index of each sorted eigenvalue
  • vacuum_eigenvalue::Float64 - Least stable (minimum) eigenvalue of the pencil (wv, N), clamped to zero
  • plasma_pts, wall_pts::Matrix{Float64} - Cartesian (x, y, z) surface coordinates, numpoints × 3, retained for HDF5 output
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).

  • du_store::Array{ComplexF64,3} - dΞψ/dψ (the u₁ block only) at each saved step, shape `(numperttotal, numperttotal, step). Empty untilmaterializederivative_stores!` fills it, except on the galerkin-matched path which supplies the analytic derivative at construction. du₂/dψ is never stored densely — its only consumer evaluates it on demand at bracket nodes.

  • xi_s_store::Array{ComplexF64,3} - Clebsch displacement Ξs at each saved step, eq. 18 of Glasser 2016, shape `(numperttotal, numperttotal, step). Empty until materialized, same asdustore`.

  • u_store_el_basis::Bool - True when u_store holds the Euler-Lagrange state (u₁, u₂), so the derivative kernel can be re-applied to it. False on the sparse parallel path, whose stored columns are chunk-endpoint Riccati matrices; materialize_derivative_stores! refuses to run there.

  • du_store_populated::Bool - True once du_store/xi_s_store hold valid data in the final (post-transform, post-normalization) basis. Set by materialize_derivative_stores! or by the galerkin-matched constructor; stays false where the stores cannot be materialized, e.g. the sparse parallel path whose solution is in the Riccati basis.

  • 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).

  • ca_populated::Bool - True once an ideal singular-surface crossing has filled ca_l/ca_r; kinetic and galerkin-matched runs never populate them and leave this false, and the HDF5 writer then emits zero-extent ca_left/ca_right datasets instead of unpopulated arrays.

  • 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).

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

  • 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).

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

    Initialization parameters

  • 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).

    Saved data throughout integration

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

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

  • 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 (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 ψ
  • 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.ForceFreeStatesResultType
ForceFreeStatesResult

The published product of a force-free-states solve: everything downstream stages (PerturbedEquilibrium, KineticForces, SLAYER, the HDF5 writer) are allowed to read. ForceFreeStatesInternal remains solve-time scratch and does not cross a module boundary once this has been built.

Optional fields are Union{Nothing,T} and follow a presence-equals-capability rule: a consumer that needs one gates on require (or require_solution for the ξ profiles) and warns-and-skips rather than erroring, so a result from an integrator that cannot supply a given product still flows through the pipeline.

A jump condition at the rational surfaces is always applied before a final result is built, so bpen and closure are always present.

Fields

  • integrator::Symbol - :forward, :riccati, or :galerkin.
  • control::ForceFreeStatesControl - Provenance snapshot of the controls the solve ran with.
  • equil::Equilibrium.PlasmaEquilibrium - The equilibrium actually integrated against (the re-formed one on the two-pass path).
  • mlow, mhigh, mpert, nlow, nhigh, npert, numpert_total - Resolved (m, n) mode space; see ModeSpace.
  • psilow, psilim, qlim, q1lim - Integration bounds, and q with its ψ-derivative at psilim.
  • dir_path::String - Working directory of the run.
  • wall_settings::Vacuum.WallShapeSettings - Wall shape used by the vacuum calculation.
  • debug_settings::DebugSettings - Diagnostic dump settings (the [DEBUG] deck section / debug= API keyword).
  • metric::MetricData, ffit::FourFitVars - Metric data and Euler-Lagrange matrix interpolants.
  • surfaces::Vector{SingType} - Ideal singular surfaces in the integration domain, with asymptotic bases and GGJ coefficients.
  • kinetic::NamedTuple - Kinetic singular-surface scan (kmsing, kinsing, scan_psi, scan_cond, scan_threshold); empty unless the finder ran.
  • closure::Symbol - How the basis is closed at the rationals: :ideal (the ideal jump condition was imposed) or :matched (an inner-layer solution was matched in).
  • bpen::Matrix{ComplexF64} - (msing × numpert_total) penetrated resonant field from the inner layer, per surface and driving mode; all zeros under :ideal closure.
  • solution::Union{Nothing,SolutionProfiles} - THE solve's ξ solution; nothing when the formalism produced none (Riccati, and Galerkin without a match).
  • diagnostics::Union{Nothing,OdeState} - The integrator's raw ODE state (ψ trace, crit, edge scan, asymptotic coefficients). Written to HDF5; not a consumable solution.
  • wp::Union{Nothing,Matrix{ComplexF64}} - Fixed-boundary plasma energy matrix W_p = (U₂·U₁⁻¹)/ψ₀² at psilim. Present for any Euler-Lagrange sweep (forward or Riccati) regardless of vac_flag — the fixed-boundary run's energy product — and identical to free_boundary.wp when the free-boundary calculation ran.
  • free_boundary::Union{Nothing,FreeBoundaryResult} - Free-boundary energies and eigenmodes; nothing when the vacuum step was skipped or the formalism computes none.
  • delta_prime::Union{Nothing,DeltaPrimeData} - Δ′/outer-region matching payload from whichever formalism ran (Riccati BVP or Galerkin); nothing when neither produced one.
  • galerkin::Union{Nothing,GalerkinResult} - RDCON Galerkin solver internals and FEM diagnostics, including the RPEC inner-layer match when requested. Its Δ′ payload lives in delta_prime, not here.
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.SolutionProfilesType
SolutionProfiles

The solve's ξ solution on its radial grid, in the exact shape PerturbedEquilibrium consumes. Field names mirror the OdeState store subset so the consumers read the same names whichever formalism produced the solution.

Fields

  • basis::Symbol - Which formalism's basis the profiles are in: :el_axis (forward integrator, axis Euler-Lagrange basis) or :gal_native (inner-layer-matched Galerkin solution, identity-at-edge basis).
  • step::Int - Number of stored radial nodes.
  • psi_store::Vector{Float64} - ψ at each node.
  • q_store::Vector{Float64} - Safety factor at each node.
  • u_store::Array{ComplexF64,4} - (N, N, 2, step) solution state: Ξ_ψ in the first component and its conjugate momentum in the second.
  • du_store::Array{ComplexF64,3} - (N, N, step) dΞ_ψ/dψ.
  • xi_s_store::Array{ComplexF64,3} - (N, N, step) Clebsch displacement Ξ_s.
source
GeneralizedPerturbedEquilibrium.ForceFreeStates.build_resultMethod
build_result(integrator, ctrl, equil, intr, metric, ffit, odet, free_energies, gal_data, gal_dp)
    -> ForceFreeStatesResult

Assemble the published result once the solve is finished — the one place that decides what a formalism's raw output means downstream. Beyond materializing the forward path's derivative stores, packing the matched Galerkin solution, and forming the fixed-boundary W_p when the free-boundary stage did not run, every field is copied or aliased from what the stages already produced.

odet is the integrator's ODE state (nothing for Galerkin); gal_data/gal_dp the Galerkin solver internals and its Δ′ payload (nothing otherwise). The two formalisms are never both present: additive Galerkin does not exist.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.requireMethod
require(result::ForceFreeStatesResult, field::Symbol, calc::AbstractString) -> Bool

Warn-and-skip gate: true iff the optional field field was populated by the integrator that produced result. Warns naming the calculation being skipped otherwise.

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 the Riccati BVP. 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 the target set by ctrl.nchunks (0 = auto). The target is derived from problem structure only — never from Threads.nthreads() — so the chunk list, and hence every Riccati output, is identical whatever thread count julia -t provides.

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, equil, ffit, intr) -> (odet, propagators, chunks, S_left)

Integrate the Euler-Lagrange equations from the axis to intr.psilim, crossing each singular surface on the way (Fortran ode_run). Dispatches on ctrl.integrator to riccati_eulerlagrange_integration (the chunked propagator BVP) or forward_eulerlagrange_integration.

Only the Riccati branch populates propagators / chunks / S_left, which compute_delta_prime_matrix! consumes for the Δ' BVP; the forward branch returns nothing for all three.

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.forward_eulerlagrange_integrationMethod
forward_eulerlagrange_integration(ctrl, equil, ffit, intr; verbose=ctrl.verbose) -> (odet, nothing, nothing, nothing)

Forward branch of eulerlagrange_integration: integrates chunk by chunk from the axis, applying Gaussian reduction whenever a solution norm ratio exceeds ctrl.ucrit and undoing it via transform_u! at the end, so odet.u_store comes back dense in the axis basis. Call directly to force this branch regardless of ctrl.integrator; verbose overrides ctrl.verbose for progress logging.

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._find_rational_surfacesMethod
_find_rational_surfaces(equil::Equilibrium.PlasmaEquilibrium, nlow::Int, nhigh::Int)

Locate all rational q-surfaces q = m/n for n in nlow:nhigh by Brent bisection between consecutive extrema of the q-profile (reverse shear gives one root per monotone segment). Returns a vector of (m, n, psifac) named tuples in discovery order (n outer, ψ-interval inner). Requires equilibrium_qfind! to have populated equil.params.qextrema_*.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_node_xi_s!Method
compute_node_xi_s!(xi_s, du1, u1, ffit, psieval; kinetic=false, hint=Ref(1))

Evaluate Ξs = -A⁻¹(B·Ξ′ψ + C·Ξψ) [Glasser Phys. Plasmas 2016 112506 eq. 18] at psieval, writing into `xis.du1andu1are the Ξ′_ψ and Ξ_ψ blocks at the same ψ, i.e. slices of ael_derivatives!` result and its input state.

Split out of the derivative kernel because Ξ_s is needed only at saved nodes, not at every Runge-Kutta stage. Ideal runs factor the Hermitian A by Cholesky; with kinetic=true A picks up non-Hermitian contributions and needs an LU.

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
  • sing_order: Expansion order, defaulting to ctrl.sing_order. The Galerkin path overrides it per surface (gal_sing_order, raised for high-Mercier-index surfaces).

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.el_derivatives!Method
el_derivatives!(du, u, kinetic, equil, ffit, intr, psieval, spline_hint, ffit_hint) -> q

Euler-Lagrange (or, when kinetic is true, FKG) derivative kernel: writes du₁/dψ and du₂/dψ at psieval into du and returns q there. Holds no state of its own — the two hints are the caller's interval-search accelerators, so concurrent callers just pass their own.

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.rational_psi_nodesMethod
rational_psi_nodes(equil::Equilibrium.PlasmaEquilibrium; nlow::Int, nhigh::Int=nlow)

Unique ψ_N locations of all rational surfaces q = m/n for n in nlow:nhigh, sorted increasing. Used as mandatory knots for the two-pass equilibrium grid refinement (the same physical surface reached through several (m, n) pairs is deduplicated by q value).

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

The unpacked-argument method carries the arithmetic; this tuple method is the thin adapter the integrator calls. Ξs is not computed here — it is a save-point quantity, obtained from [`computenodexis!`](@ref) only where it is actually consumed.

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_get_ua_res_cut!Method
sing_get_ua_res_cut!(out, sing_asymp, dpsi) -> out

Leading-order ("cut") form of sing_get_ua_res!: keeps only the t = 0 term of the Frobenius series instead of summing all 2·sing_order terms, then applies the same unshear.

This is the resonant basis used to build the cut outer solution, whose role is to supply the regular background that the resistive inner-layer solution is added to when forming the composite solution near a rational surface. Because the full and cut series share the same leading term, the difference between them is exactly the higher-order content that the layer solution replaces.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_lim!Method
sing_lim!(intr::ForceFreeStatesInternal, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium)

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.materialize_derivative_stores!Method
materialize_derivative_stores!(odet, equil, ffit, intr) -> Bool

Fill odet.du_store (dΞψ/dψ) and `odet.xisstorefrom the stored solution, returning whether they hold valid data afterwards. Idempotent: a no-op whendustore_populated` is already true, so the galerkin-matched path keeps its analytic derivatives.

Derivatives are recomputed rather than accumulated during integration because they are needed only at saved nodes, not at every Runge-Kutta stage. Recomputing after the Gaussian fixup transforms and the free-boundary normalization is exact rather than merely close: the Euler-Lagrange system is linear in u, and both operations right-multiply the solution by a mixing matrix T, so du(ψ, u·T) = du(ψ, u)·T.

Returns false without allocating when there is nothing to work from — no ffit, no stored steps, or a solution held in a basis the Euler-Lagrange kernel does not apply to (the sparse parallel path, which stores chunk-endpoint Riccati matrices).

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.resize_storage!Method
resize_storage!(odet::OdeState)

Resize storage arrays in odet when the current step exceeds allocated size. Doubles the size of the storage arrays for u_store, psi_store, and q_store, and copies over existing data to the new arrays. The derivative stores are not grown here: they are filled at exact size by materialize_derivative_stores! after integration.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.store_ode_data!Method
store_ode_data!(odet::OdeState, psi::Float64, u)

Save the current integration state at psi: u plus odet.q, which callers set for the accepted point. Derivatives are not stored — materialize_derivative_stores! recomputes them from (psi_store, u_store) after integration, where they are actually consumed.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.trim_storage!Method
trim_storage!(odet::OdeState)

Trim storage arrays in odet to the actual number of steps taken. Resizes u_store, psi_store, and q_store to the current step count, removing any unused allocated space. The derivative stores are left alone — they are either empty (serial/Riccati, filled later by materialize_derivative_stores!) or already exact-size (galerkin-matched states).

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_scaled_wvMethod
compute_scaled_wv(ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal) -> (wv, vac)

Vacuum response matrix at the control surface intr.psilim, scaled by the singular factors (m - n·q)(m' - n'·q) [Chance Phys. Plasmas 1997 2161 eq. 126]. Handles 2D single-n, 2D multi-n block-diagonal and 3D vacuum problems through Vacuum.compute_vacuum_response.

Needs no ODE state, so both the free-boundary calculation and the standalone Galerkin solve share it. Returns the scaled wv alongside the full vacuum response vac, whose surface point clouds the free-boundary result carries to HDF5. wv aliases vac.wv, which is scaled in place.

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_runMethod
free_run(odet::OdeState, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal) -> FreeBoundaryResult

Compute the free boundary energies using the Julia port of the VACUUM code. Performs the same function as free_run in the Fortran code.

Returns a FreeBoundaryResult struct containing the data needed for perturbed equilibrium calculations and data dumping.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.normalize_eigenfunctions!Method
normalize_eigenfunctions!(odet::OdeState, wt::AbstractMatrix{ComplexF64}, psio::Float64) -> OdeState

Rescale the stored EL solution vectors in odet.u_store (and du_store/xi_s_store when a path has already filled them) so the edge displacement matches the free-boundary eigenvectors wt (scaled by 2π·psio·1e-3). Modifies odet in place. Call after free_run when downstream code consumes the stored ξ profiles.

source
GeneralizedPerturbedEquilibrium.ForceFreeStates.power_norm_matrix!Method
power_norm_matrix!(Nmat, jmat, mpert, npert, dV_dpsi) -> Nmat

Assemble the power-normalization (surface-norm) matrix N from the conjugate-symmetric Jacobian Fourier band jmat (length 2·mpert−1, evaluated from the ffit.jmats spline), such that

ξ†·N·ξ = ∮ J |ξ(θ)|² dθ / (dV/dψ) = ⟨|ξ|²⟩

is the flux-surface average of the squared boundary displacement — the DCON power normalization as a quadratic form. N is Hermitian Toeplitz within each n-block (block-diagonal over n since the Jacobian is axisymmetric) and positive definite (J > 0). It is the metric of the generalized eigenproblem W·v = λ·N·v solved in free_run and free_compute_total: because W and N transform by the same congruence under a change of working (Jacobian) coordinate, the pencil eigenvalues are power-normalized mode energies that are invariant to that coordinate choice.

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.pest3_decomposeMethod
pest3_decompose(dp_raw::AbstractMatrix) -> (A', B', Γ', Δ')

Rotate the raw 2m×2m outer-region matching matrix dp_raw (side-major ordering [L_s1, R_s1, L_s2, R_s2, …]) into the Pletzer–Dewar 1991 parity blocks. Given rows and columns paired by surface (odd index = left, even index = right), the Fortran RDCON parity combination is

A'(i,j) = RR + RL + LR + LL    (even-i, even-j)   — interchange↔interchange
B'(i,j) = RR − RL + LR − LL    (even-i, odd-j)    — interchange↔tearing
Γ'(i,j) = RR + RL − LR − LL    (odd-i,  even-j)   — tearing↔interchange
Δ'(i,j) = RR − RL − LR + LL    (odd-i,  odd-j)    — tearing↔tearing

where RR = dp_raw[2i, 2j], RL = dp_raw[2i, 2j−1], LR = dp_raw[2i−1, 2j], LL = dp_raw[2i−1, 2j−1]. Each block is m×m.

Matches Fortran exactly — no ½ prefactor (Pletzer–Dewar multiply by ½, but the Fortran RDCON code leaves it commented out and our Julia port follows Fortran to keep the benchmark bit-identical; the prefactor cancels in det(D' − D(γ)) = 0).

The Δ' block returned here equals intr.delta_prime_matrix (the m×m PEST3 tearing projection computed inside compute_delta_prime_matrix!).

Arguments

  • dp_raw — 2m×2m complex matrix (typically intr.delta_prime_raw).

Returns

Named tuple (A=A', B=B', Γ=Gp, Δ=Dp) of four m×m complex matrices. In the full det(D' − D(γ)) = 0 eigenvalue problem, these fill the 2m×2m outer matrix as D' = [[A' B'] [Γ' Δ']] with the interchange channel (Glasser stabilization) in the upper-left block and the tearing channel in the lower-right.

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) -> (odet, propagators, chunks, S_left)

The Riccati/STRIDE integrator: a chunked fundamental matrix (propagator) driver for the EL integration. The trailing three return values feed the Δ' BVP in compute_delta_prime_matrix!; this is the only branch that produces them.

Solves the same system as forward_eulerlagrange_integration, but integrates all bulk chunks concurrently using Threads.@threads, then re-integrates the outer plasma serially:

  1. Chunk generation: calls chunk_el_integration_bounds, then balance_integration_chunks to sub-divide chunks for load balancing. The chunk count depends only on intr.msing and ctrl.nchunks, never on the thread count, so results are thread-independent.
  2. Propagator 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).
  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!`.

Select via integrator = "riccati" in [ForceFreeStates] of gpec.toml. Requires singfac_min != 0. Uses whatever threads julia -t provides; ctrl.nchunks is the only tunable.

Key differences from the forward integrator:

  • No Gaussian reduction in the propagator BVP phase (crossings use the Riccati-style algorithm, odet.ifix stays 0)
  • transform_u! is called on the odet but is a no-op (ifix=0)
  • Outer plasma uses serial Riccati integration for numerical stability
  • odet.u_store holds chunk-endpoint Riccati states, not dense Euler-Lagrange ξ, and odet.u_store_el_basis stays false: this integrator never claims the EL basis, so PerturbedEquilibrium and the HDF5 forward-integration ξ datasets require the forward path.

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.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"])...)
intr.nlow = ctrl.nn_low; intr.nhigh = ctrl.nn_high; intr.npert = 1
FFS.sing_lim!(intr, ctrl, equil)
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
# on ctrl.integrator and always returns a 4-tuple
# (odet, propagators, chunks, S_at_surface_left).  The trailing three are `nothing`
# on the forward path.
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 (Riccati path)

# intr.delta_prime_matrix is msing × msing after riccati_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; the canonical Δ' is the STRIDE BVP SingularSurfaces/Delta_prime_matrix from the parallel FM path. ca_l/ca_r are filled only by ideal surface crossings (kinetic and galerkin-matched runs emit zero-extent ca_left/ca_right sentinels).
  • 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 Δ'