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
| Paper | Content |
|---|---|
| [Glasser 2016] Phys. Plasmas 23, 112506 | Newcomb criterion, EL system, standard DCON integration |
| [Glasser 2018a] Phys. Plasmas 25, 032507 | Riccati reformulation, reduced stiffness near singular surfaces |
| [Glasser 2018b] Phys. Plasmas 25, 032501 | STRIDE code: parallel FM integration, inter-surface Δ' matrix |
Integration methods
Three integration drivers are available, all solving the same EL system but with different numerical strategies.
Standard integration
eulerlagrange_integration is the baseline driver. It integrates the EL ODE directly in $(U_1, U_2)$ using Tsit5 with adaptive step control. Near each rational surface the columns of $U_2$ that correspond to resonant modes are zeroed via Gaussian reduction (GR), keeping the solution bounded. This is the reference path for correctness comparisons.
Enable with (default):
[ForceFreeStates]
use_riccati = false
use_parallel = falseRiccati integration
riccati_eulerlagrange_integration reformulates the problem in terms of the dual Riccati matrix $S = U_1 \cdot U_2^{-1}$ [Glasser 2018a, Eq. 19]:
\[\frac{dS}{d\psi} = w^\dagger \bar{F}^{-1} w - S\bar{G}S, \qquad w = Q - \bar{K}S.\]
$S$ remains bounded near rational surfaces (where $U_1, U_2$ grow exponentially), so the solver takes fewer steps. Rather than integrating the quadratic Riccati ODE directly (which blows up when $|S|$ is large), the code integrates the linear EL system with sing_der! as the RHS and recovers $S = U_1 U_2^{-1}$ via periodic renormalization — an approach that is mathematically equivalent to O(Δψ) but uses the ODE solver's full 5th-order accuracy.
Renormalization is triggered whenever $\max(|U_1|)$ or $\max(|U_2|)$ exceeds the threshold ucrit (default 1e6), and is forced at the end of each chunk. At singular surface crossings, riccati_cross_ideal_singular_surf! applies the small-asymptotic matching directly in column ipert_res — without Gaussian reduction — and renormalizes to $(S, I)$.
Enable with:
[ForceFreeStates]
use_riccati = true
use_parallel = falseSpeedup (benchmarked on reference examples):
| Example | N modes | Speedup vs standard |
|---|---|---|
| Solovev | 8 | ~1.6× (1 thread), ~2.8× (4 threads) |
| DIIID | 26 | ~2.0× (1 thread), ~1.3× (4 threads) |
Parallel fundamental-matrix (FM) integration
parallel_eulerlagrange_integration decomposes the radial domain into independent chunks and integrates each chunk in parallel using Threads.@threads. Each chunk produces a fundamental-matrix (FM) propagator. Serial post-processing multiplies the propagators in order and applies each singular-surface crossing, recovering the same EL trajectory as the Riccati path.
Bidirectional integration for large N
For large mode counts the FM propagator for a chunk ending near a rational surface is ill-conditioned: the EL solutions grow exponentially toward the rational surface, so the forward FM amplifies numerical errors. GPEC follows the STRIDE approach [Glasser 2018b, Sec. III.A]: the crossing chunk (the last sub-chunk before each rational surface) is integrated backward — from the rational surface toward the interior — producing a well-conditioned backward FM $\Phi_L$. The forward propagation is recovered as $\Phi_L^{-1}$ via an LU solve in serial assembly, which is accurate precisely because $\Phi_L$ is well-conditioned.
The implementation uses a direction field on IntegrationChunk:
direction = +1: standard forward integration,tspan = (ψ_start, ψ_end).direction = -1: backward integration,tspan = (ψ_end, ψ_start)(reversed).
chunk_el_integration_bounds(...; bidirectional=true) assigns direction = -1 to every crossing chunk. balance_integration_chunks preserves this: the sub-chunk closest to the rational surface inherits direction, while the earlier sub-chunk always gets direction=+1.
Enable with:
[ForceFreeStates]
use_parallel = trueAccuracy (N=26, DIIID-like example): energy eigenvalue within 2% of standard path. The residual ~2% gap comes from the different crossing convention (Riccati-style direct zeroing vs GR), not from ODE tolerance; it is present in both 1-thread and 4-thread runs.
Local stability: Mercier and ballooning (s–α)
Setting local_stability_flag = true in [ForceFreeStates] runs a local high-$n$ stability scan over every flux surface, in addition to the global ideal analysis above. For the derivation and implementation details behind these diagnostics, see Ballooning and Mercier Local Stability. Three diagnostics are produced and stored under the locstab/ HDF5 group, each a profile in normalized poloidal flux $\psi$:
- Mercier criterion $D_I$ (
locstab/di) — the ideal interchange criterion. A surface is Mercier-unstable where $D_I > 0$. It is evaluated from the $\det(\bar{d}_0)$ of the integrated local-mode matrix. - Resistive interchange $D_R$ (
locstab/dr) — the Glasser–Greene–Johnson resistive interchange criterion $D_R = D_I + (H - 1/2)^2$. The $D_I$ term is the same $\det(\bar{d}_0)$ value reported inlocstab/di; $H$ is computed from the legacy Mercier/GGJ flux-surface averages of the field and metric quantities. $D_R > 0$ indicates resistive interchange instability. - Ballooning $\Delta'$ (
locstab/ballooning_Delta_prime) — the high-$n$ ballooning stability index, obtained by integrating the ballooning equation along the field line and taking the jump in the logarithmic derivative of the solution between the two asymptotic ends.
locstab/ballooning_Delta_prime is the local high-$n$ ballooning index and is distinct from the resistive tearing $\Delta'$ described in the next section, which is written under singular/ and perturbed_equilibrium/singular_coupling/delta_prime. They measure different instabilities; do not confuse them.
s–α diagram
The local criteria can be mapped over a two-parameter $(p', q')$ scan at a single flux surface — the classic s–α (shear–pressure) stability diagram. For a chosen surface the pressure gradient $p'$ and shear $q'$ are scaled away from their equilibrium values, and $\Delta'$ and $D_I$ are recomputed on the resulting grid. The $\Delta' = 0$ and $D_I = 0$ contours bound the ballooning- and Mercier-stable regions, with the equilibrium operating point marked inside.
The example script examples/DIIID-like_ideal_example/analyze_example.jl demonstrates the full workflow: it plots the $s$ and $\alpha$ profiles, the $D_I$ and ballooning $\Delta'$ profiles, and the 2-D s–α maps with their zero contours, using salpha_reference, compute_ballooning_stability!, and scan_delta_prime_map.
Δ' tearing stability parameter
Per-surface Δ' (delta_prime)
At each rational surface the asymptotic matching condition gives the tearing stability parameter [Glasser 2016]:
\[\Delta'_s = \frac{c_{a,r}[i_s,i_s,2] - c_{a,l}[i_s,i_s,2]}{4\pi^2 \psi_0},\]
where $c_{a,l}$ and $c_{a,r}$ are the left and right asymptotic coefficients at surface $s$, and $i_s$ is the column index of the resonant mode. Positive $\Delta' > 0$ indicates a tearing-unstable surface.
The Riccati and parallel FM paths populate intr.sing[s].delta_prime (a length-$n_\mathrm{res}$ vector) inline during each crossing. A companion vector delta_prime_col (length N) stores the coupling of all poloidal modes to the resonant mode at surface $s$:
\[(\Delta'_\mathrm{col})_{j,i} = \frac{c_{a,r}[j,i_s,2] - c_{a,l}[j,i_s,2]}{4\pi^2 \psi_0}.\]
The diagonal element $(\Delta'_\mathrm{col})_{i_s,i}$ equals delta_prime[i] exactly by construction.
Inter-surface Δ' matrix (delta_prime_matrix)
compute_delta_prime_matrix! assembles an $m_\mathrm{sing} \times m_\mathrm{sing}$ inter-surface tearing matrix following the STRIDE global BVP [Glasser 2018b, Sec. III.B]. Internally, the solver builds a raw $2 m_\mathrm{sing} \times 2 m_\mathrm{sing}$ matrix whose rows/columns index the left and right inner-layer boundaries of every rational surface; the stored PEST3-convention $\Delta'$ is the four-term combination $\text{dp\_raw}[2i, 2j] - \text{dp\_raw}[2i, 2j{-}1] - \text{dp\_raw}[2i{-}1, 2j] + \text{dp\_raw}[2i{-}1, 2j{-}1]$ that folds the raw block into a per-surface response. The BVP unknowns are the plasma state at the left and right inner-layer boundaries of every rational surface; the driving terms are unit-amplitude asymptotic solutions at each boundary. The resulting matrix encodes the full plasma response between all pairs of surfaces and is required for resistive stability analysis of multi-surface configurations.
The BVP is well-conditioned because it is formulated using the split $(\Phi_R, \Phi_L)$ propagator blocks from bidirectional integration rather than the monolithic forward product $\Phi_L^{-1} \Phi_R$ (which is ill-conditioned for large N):
\[\Phi_R[j] \cdot x_R[j-1] - \Phi_L[j] \cdot x_L[j] = 0 \quad \text{(junction at } \psi_m[j]\text{)},\]
where $\Phi_R[j]$ is the forward FM product from $\psi_{R,j-1}$ to the junction, and $\Phi_L[j]$ is the backward crossing FM from $\psi_{L,j}$ to the junction.
The matrix is only populated by the parallel FM path and is written to the HDF5 output under singular/delta_prime_matrix.
Configuration reference
All ForceFreeStates options are set in the [ForceFreeStates] section of gpec.toml.
[ForceFreeStates]
# Integration driver
use_riccati = false # true: Riccati path (faster, same accuracy)
use_parallel = false # true: parallel FM path (multi-thread, large N)
# Mode space
nn_low = 1 # lowest toroidal mode number
nn_high = 1 # highest toroidal mode number
delta_mlow = 0 # extra low poloidal modes (m < mlow)
delta_mhigh = 0 # extra high poloidal modes (m > mhigh)
# ODE solver
numsteps_init = 200 # initial step budget per chunk
numunorms_init = 50 # renorm checkpoint budget
reltol = 1e-6 # ODE relative tolerance
# Local stability
local_stability_flag = false # scan Mercier D_I, resistive D_R, and ballooning Δ' over ψ
# Output
verbose = true
write_outputs_to_HDF5 = trueThe 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.ChunkPropagator — Type
ChunkPropagatorFundamental 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.
GeneralizedPerturbedEquilibrium.ForceFreeStates.DebugSettings — Type
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
GeneralizedPerturbedEquilibrium.ForceFreeStates.EdgeScanState — Type
EdgeScanState
Holds the state and results for the edge dW stability scan over ψ ∈ [psiedge, psilim]. Initialized and populated by findmax_dW_edge!; results written to HDF5 under EdgeScan/ (root-area-weighted values) and EdgeScan/XiNorm/ (ξ-space values, for benchmarking).
Fields
wvmat- Precomputed wv matrix spline (raw, no singfac); singfac applied analytically infree_compute_total.wv_hint::Base.RefValue{Int}- Search hint for wvmat spline (different grid from equilibrium profiles).sqrtamat_spline- Precomputed √A convolution matrix + surface area spline for root-area-weighted eigenvalues.sqrtamat_hint::Base.RefValue{Int}- Search hint for sqrtamat spline.psi, q- ψ and q values at each edge scan step.total_eigenvalue, plasma_energy, vacuum_energy, vacuum_eigenvalue- ξ-space energy components at each step (NaN for failed steps). Kept for Fortran benchmarking.rootA_total_eigenvalue, rootA_plasma_energy, rootA_vacuum_energy, rootA_vacuum_eigenvalue- Root-area-weighted flux (Φ-space) energies (Jacobian-invariant; NaN for failed/singular steps). These are the values written toEdgeScan/by default.
GeneralizedPerturbedEquilibrium.ForceFreeStates.ForceFreeStatesControl — Type
ForceFreeStatesControlA mutable struct containing control parameters for stability analysis, set by the user in gpec.toml.
Fields
verbose::Bool- Enable verbose outputlocal_stability_flag::Bool- Enable local stability analysis (D_Iand ballooning)mat_flag::Bool- Enable matrix outputode_flag::Bool- Enable ODE integration diagnosticsvac_flag::Bool- Enable vacuum region calculationmthvac::Int- Number of vacuum poloidal grid points (corresponds tomthetain VacuumInput)nzvac::Int- Number of vacuum toroidal grid points (corresponds tonzetain VacuumInput3D)sing_start::Int- Start integration at thesing_start-th singular surfacenn_low::Int- Lower bound for toroidal modesnn_high::Int- Upper bound for toroidal modesdelta_mlow::Int- Expands lower bound of Fourier harmonics by delta_mlowdelta_mhigh::Int- Expands upper bound of Fourier harmonics by delta_mhighnstep::Int- Maximum number of integration steps (not yet implemented)ksing::Int- Singular surface handling parametereulerlagrange_tolerance::Float64- Relative tolerance for ODE integration of Euler-Lagrange equationsucrit::Float64- Critical value of unorm ratio to trigger solution normalization. In the standard path it triggers Gaussian reduction; in the Riccati path it triggersrenormalize_riccati_inplace!. Default1e4empirically 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 storagenumunorms_init::Int- Initial array size for solution normalization datasingfac_min::Float64- Fractional distance from rational q at which ideal jump condition is enforcedcyl_flag::Bool- Make deltamlow and deltamhigh set the actual m truncation bounds. Default is to expand (nqmin-4, nqmax).set_psilim_via_dmlim::Bool- Truncate the integration domain at(last_rational_q + dmlim) / nrather than atqhigh/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 = trueis 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 setset_psilim_via_dmlim = falseand letqhigh/psihighcontrol the truncation. Multi-nruns are not supported by this truncation (the "outermost rational + dmlim / n" depends on whichn); whenset_psilim_via_dmlim = truewithnn_low != nn_high,sing_lim!warns and falls back toqhigh/psihigh. Defaulttrue.dmlim::Float64- Distance beyond last rational surface (normalised ∈ [0,1) in units of 1/n). Only used whenset_psilim_via_dmlimis 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 equilkinetic_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 equilreform_eq_with_psilim::Bool- Reform equilibrium with computed psilim (not yet implemented)psiedge::Float64- If less than psilim, records a dW(ψ) diagnostic scan over [psiedge, psilim] on odet.edgescan. The integration domain (psilim) is always controlled by qhigh / psihigh and is not modified by this scan (unless `truncateatdWpeak=true`, see caveats below).truncate_at_dW_peak::Bool- Whentrueandpsiedge < psilim, the edge-dW scan's peak location is adopted as the new physical plasma edge —intr.psilim/intr.qlim/odet.uare 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 atfalse(default) for the full-domain Δ' atqhigh/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 formatHDF5_filename::String- Name of HDF5 output fileforce_wv_symmetry::Bool- Boolean flag to enforce symmetry in the vacuum response matrixsave_interval::Int- Save every Nth ODE step (1=all, 10=every 10th). Always saves near rational surfaces. (Same aseuler_stepin the Fortran)force_termination::Bool- Terminate after force-free states (skip perturbed equilibrium calculations)use_riccati::Bool- Use the dual Riccati reformulation S = U₁·U₂⁻¹ instead of the standard U₁/U₂ ODE. Reduces stiffness for faster integration. See Glasser (2018) Phys. Plasmas 25, 032507.use_parallel::Bool- Parallel fundamental matrix (propagator) integration usingThreads.@threads. Each chunk is integrated independently from identity IC and assembled serially. Requiressingfac_min != 0. Uses the same chunk bounds as the standard path but sub-divides chunks for load balancing. Crossings use the Riccati-style algorithm (no Gaussian reduction).parallel_threads::Int- Cap on the number of threads the parallel BVP uses. Default2parallelises the FM chunks across two threads (the BVP has ~10 chunks; 2 threads is enough to amortize them — speedup saturates here, raising to 4 adds scheduling overhead). Setparallel_threads = 1to run the FM chunks SERIALLY (noThreads.@threads), which is bit-deterministic and immune to the thread-schedule sensitivity that historically caused intermittent BVP divergences on numerically delicate equilibria like DIII-D 147131 (see CONVENTIONS.md §7). Empirical reliability sweep (5 trials × {1,2,4} on DIII-D 147131 βₚ≈0.07): 15/15 bit-identical Δ′ at every setting; pt=2 ≈ pt=4 ≈ 20 % faster than serial. If a parallel run diverges, drop toparallel_threads = 1rather than switchinguse_parallel = false— the latter is silently wrong. Capped atThreads.nthreads().populate_dense_xi::Bool- Whenuse_parallel = true, append a serial Euler-Lagrange pass at the end of the propagator BVP and let it replace theodetreturned to the main pipeline. This populatesu_store/ud_storedensely in the axis (EL) basis — the only convention the PerturbedEquilibrium / FieldReconstruction downstream code consumes correctly. Without it the parallel path stores only chunk-endpoint Riccati S matrices and zeros forud_store(see Riccati.jl docstring caveats), and HDF5integration/xi_psi/dxi_psi/xi_sare unusable. Δ' (singular/delta_prime_matrix) is computed from the parallel BVP and is bit-identical betweenpopulate_dense_xi=trueandfalse. Energies (vacuum/ep/ev/et) are computed byfree_run!fromodet, so withpopulate_dense_xi=truethey match what a pure serial run (use_parallel=false) would produce; withpopulate_dense_xi=falsethey use the parallel-pass Riccatiodet.uinstead (differs by the ~0.12 % Riccati-vs-axis algorithmic gap on DIIID-class cases). Defaultfalseto avoid paying the dense-pass cost on Δ'/vacuum/ideal-stability-only runs; PerturbedEquilibrium-using configs must setpopulate_dense_xi = trueexplicitly whenuse_parallel = true(otherwise PE silently reads Riccati-basis garbage). Auto-disabled whenforce_termination = trueregardless of the user setting, since the dense pass has no downstream consumer in that case. Approximate cost when enabled: one extra serial EL integration (~1× the parallel BVP wall-clock for typical N).extended_precision_bvp::Bool- Whentrue(default), promote the Δ' BVP linear system toComplex{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.
GeneralizedPerturbedEquilibrium.ForceFreeStates.ForceFreeStatesInternal — Type
ForceFreeStatesInternalA mutable struct holding internal state variables for stability calculations.
Fields
dir_path::String- Directory path for input/output filesmlow::Int- Lowest poloidal mode numbermhigh::Int- Highest poloidal mode numbermpert::Int- Number of poloidal modes (mhigh - mlow + 1)nlow::Int- Lowest toroidal mode numbernhigh::Int- Highest toroidal mode numbernpert::Int- Number of toroidal modes (nhigh - nlow + 1)numpert_total::Int- Total number of modes (mpert × npert)keq_out::Bool- Flag to output equilibrium quantities (not yet implemented)theta_out::Bool- Flag to output theta coordinate data (not yet implemented)xlmda_out::Bool- Flag to output eigenvalue data (not yet implemented)sol_base::Int- Base index for solution vectors (not yet implemented)msing::Int- Number of ideal singular surfaceskmsing::Int- Number of kinetic singular surfaces (det(F̄) near-zeros)sing::Vector{SingType}- Vector of ideal singular surface datakinsing::Vector{SingType}- Vector of kinetic singular surface datakinsing_scan_psi::Vector{Float64}- ψ grid used byfind_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 exceedkinsing_scan_thresholdkinsing_scan_threshold::Float64- Threshold on cond(F̄) used to accept a peak as a kinetic singular surfacepsilim::Float64- Flux limit for integrationqlim::Float64- Safety factor at psilimq1lim::Float64- Safety factor derivative at psilimlocstab::CubicSeriesInterpolant- Spline for local stability analysiswall_settings::Vacuum.WallShapeSettings- Wall shape settings for vacuum calculations
GeneralizedPerturbedEquilibrium.ForceFreeStates.IntegrationChunk — Type
IntegrationChunkA struct representing a region of integration in the Euler-Lagrange solver.
Fields
psi_start::Float64- Starting ψ coordinate for this integration regionpsi_end::Float64- Ending ψ coordinate for this integration regionneeds_crossing::Bool- Whether a rational surface crossing is needed after this chunkising::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). Fordirection=-1chunks,psi_start<psi_endbut integration proceeds frompsi_endtowardpsi_start. The resulting propagator maps state atpsi_end→ state atpsi_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.
GeneralizedPerturbedEquilibrium.ForceFreeStates.OdeState — Type
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, likeistepin the original Fortran).psi_store::Vector{Float64}- Stored psi values at each saved integration step (lengthnumsteps_init).q_store::Vector{Float64}- Stored q values at each saved integration step (lengthnumsteps_init).u_store::Array{ComplexF64,4}- Stored solution arrays at each saved step with shape(numpert_total, numpert_total, 2, numsteps_init)(complex solution state used by the solver).ud_store::Array{ComplexF64,4}- Stored derivatives of the solution at each saved step with same shape asu_store.crit_store::Vector{Float64}- Stored crit parameter values (smallest eigenvalue of W⁻ꜝ) (lengthnumsteps_init).ca_r::Array{ComplexF64,4}- Asymptotic coefficients just to the right of each singular surface with shape(numpert_total, numpert_total, 2, msing).ca_l::Array{ComplexF64,4}- Asymptotic coefficients just to the left of each singular surface with shape(numpert_total, numpert_total, 2, msing).edge_scan::EdgeScanState- Edge dW scan state and results. Initialized as a disabled sentinel (Nedge=0) and replaced by `findmaxdW_edge!` when a scan runs.psifac::Float64- Current normalized flux coordinate for the integrator.q::Float64- Safety factor value atpsifac(current q during integration).u::Array{ComplexF64,3}- Current working solution arrays with shape(numpert_total, numpert_total, 2).ud::Array{ComplexF64,3}- Current working solution derivative (different than du) arrays with shape(numpert_total, numpert_total, 2).ising_start::Int- Index of the starting singular surface to be crossed during integration.Initialization parameters
psimax::Float64- Maximum psi value for which the integrator is allowed to run in next integration region.needs_crossing::Bool- Flag indicating whether a rational surface needs to be crossed after the current integration region.nzero::Int- Count of detected zero crossings (used for diagnostics).Saved data throughout integration
new::Bool- Flag indicating whether a newunorm0should be computed after a fixup.
Total ODE solver steps taken (all steps, not just saved ones)
unorm::Vector{Float64}- Current norms of the solution vectors (lengthnumpert_total).unorm0::Vector{Float64}- Reference/initial norms of the solution vectors (lengthnumpert_total).ifix::Int- Number of normalization operations performed (index into normalization arrays).index::Array{Int,2}- Index matrix used for sorting solution norms with shape(numpert_total, numunorms_init).sing_flag::Vector{Bool}- Boolean flags indicating which stored normalizations correspond to singular solutions # Edge dW scan state and results (disabled sentinel when psiedge >= psilim, i.e. no edge scan) (lengthnumunorms_init).zeroed_idx::Vector{Vector{Int}}- For each ideal rational surface jump, a vector of indices of solutions that were zeroed. # Data for integratorfixfac::Array{ComplexF64,3}- Fix-up factors for Gaussian reduction with shape # Initialization parameters(numpert_total, numpert_total, numunorms_init).fixstep::Vector{Int64}- Step indices (psi step positions) at which normalization/fixups were performed (lengthnumunorms_init).
GeneralizedPerturbedEquilibrium.ForceFreeStates.SingAsymptotics — Type
SingAsymptoticsA 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 eigenvaluesr1::Vector{Int}- Resonant indices along first indexr2::Vector{Int}- Resonant indices along second indexn1::Vector{Int}- Nonresonant indices along first indexn2::Vector{Int}- Nonresonant indices along second indexpower::Vector{ComplexF64}- Power series coefficientsvmat::Array{ComplexF64,4}- Power series of V matrix for asymptotic analysismmat::Array{ComplexF64,4}- Power series of M matrix for asymptotic analysism0mat::Matrix{ComplexF64}- Zeroth order M matrix projected onto resonant subspace
GeneralizedPerturbedEquilibrium.ForceFreeStates.SingType — Type
SingTypeA mutable struct holding data related to the singular surfaces in the equilibrium.
Fields
psifac::Float64- Normalized flux coordinate at the singular surfacerho::Float64- Radial coordinate (√ψ)m::Vector{Int}- Poloidal mode number(s)n::Vector{Int}- Toroidal mode number(s)q::Float64- Safety factor (= m/n)q1::Float64- Derivative of safety factor with respect to ψgrri::Array{Float64,2}- Interior Green's function at this surface [2mthvac, 2mpert]grre::Array{Float64,2}- Exterior Green's function at this surface [2mthvac, 2mpert]delta_prime::Vector{ComplexF64}- STUB (not physically valid). Per-surface ca-based Δ' estimate retained for future work / debugging only. The physically valid Δ' isForceFreeStatesInternal.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 withdelta_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.
GeneralizedPerturbedEquilibrium.ForceFreeStates.VacuumData — Type
VacuumDataA struct containing relevant data from the vacuum calculation. Populated in Free.jl.
Fields
numpoints::Int- Total number of points in the vacuum calculation (mthvac * nzvac)numpert_total::Int- Total number of modes (mpert × npert)mthvac::Int- Number of vacuum poloidal grid points (corresponds tomthetain VacuumInput) - only needed for GPEC functionality currentlywt::Array{ComplexF64, 2}- Free-boundary eigenvector matrix after diagonalising W (numperttotal × numperttotal). Columns are the eigenmodes sorted most-unstable first. ξ-space.wt0::Array{ComplexF64, 2}- Free-boundary total-energy matrix W = wp + wv before diagonalisation (numperttotal × numperttotal). ξ-space.wp::Array{ComplexF64, 2}- Plasma energy matrix (numperttotal × numperttotal). ξ-space.wv::Array{ComplexF64, 2}- Vacuum energy matrix (numperttotal × numperttotal). ξ-space.rootA_wt0, rootA_wp, rootA_wv::Array{ComplexF64,2}- Root-area-weighted flux (Φ-space) counterparts ofwt0,wp,wvat the plasma edge (see RootAreaWeighted.jl). Jacobian-invariant up to the M†·W·M transform.rootA_wt::Array{ComplexF64,2}- Φ-space eigenvector matrix ofrootA_wt0(columns sorted most-unstable first, phase-normalized so each column's largest-magnitude entry is real-positive).ep::Vector{ComplexF64}- Plasma eigenvaluesev::Vector{ComplexF64}- Vacuum eigenvalueset::Vector{ComplexF64}- Total eigenvalues of plasma + vacuumn_tor_idx::Vector{Int}- 0-based toroidal mode number index of each sorted eigenvalue (numperttotal). Needed in `writeimas`vacuum_eigenvalue::Float64- Least stable (minimum) eigenvalue of the vacuum matrix wv, clamped to zerogrri::Array{Float64, 2}- Interior Green's function matrices (2 * mthvac * nzvac × 2 * numpert_total)grre::Array{Float64, 2}- Exterior Green's function matrices (2 * mthvac * nzvac × 2 * numpert_total)plasma_pts::Array{Float64, 3}- Cartesian coordinates of plasma points, shape (mthvac * nzvac) × 3 for (x, y, z)wall_pts::Array{Float64, 3}- Cartesian coordinates of wall points, shape (mthvac * nzvac) × 3 for (x, y, z)
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_alpha_boundaries — Method
ballooning_alpha_boundaries(ctrl, plasma_eq; theta_k=0.0, max_alpha_scale=8.0, n_scan=24)Profile driver returning the experimental pressure gradient alpha, the first stability boundary alpha_critical1 (lowest Δ' zero), and the second stability boundary alpha_critical2 (next zero above it) versus normalized flux psi, from ballooning_alpha_crossings at each surface. Arrays contain NaN where no boundary exists.
n_scan sets the scan resolution of the crossing search; crossings closer together than the scan step can be missed, which shows up as isolated outliers on otherwise smooth boundary curves.
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_alpha_boundary — Method
ballooning_alpha_boundary(ctrl, plasma_eq; theta_k=0.0, n_scan=24)Profile driver for the BALOO-style ballooning stability diagram. Loops over flux surfaces returning the experimental pressure gradient alpha (from salpha_reference) and the first stability boundary alpha_critical (from critical_ballooning_alpha) versus normalized flux psi. Surfaces whose experimental alpha exceeds alpha_critical are ballooning-unstable.
This is the fast first-boundary-only driver: critical_ballooning_alpha stops at the first crossing, so cost scales with the boundary location rather than the full α range. Use this (not ballooning_alpha_boundaries) when only the 1st boundary is needed, e.g. a pedestal-stability constraint. n_scan sets the scan resolution; raise it (e.g. 64) on edge surfaces where Δ' poles can shift a coarse first crossing.
Per-surface failures and surfaces with no boundary within range are returned as NaN.
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_alpha_crossings — Method
ballooning_alpha_crossings(psi_idx, plasma_eq; theta_k=0.0, max_alpha_scale=8.0, n_scan=24, tol=1e-3)Locate the marginal-stability crossings of the ballooning Δ' along the pressure-gradient scaling α/α_exp ∈ [0, max_alpha_scale] at fixed magnetic shear. Marching up from the ballooning-stable α = 0 anchor, every sign change between scan samples is bisected to tol; sign changes with endpoint |Δ'| far above the anchor magnitude are Δ' pole crossings (not marginal points) and are skipped. The first crossing is the first stability boundary, the second the second-stability boundary, and so on.
Returns (scales, alphas, reference) with alphas = alpha_ref * scales, ordered in increasing α. Surfaces that never cross return empty vectors. With max_crossings > 0 the scan stops after that many crossings (e.g. 1 for a first-boundary-only search, which avoids scanning the full α range above the boundary).
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_delta_prime — Method
ballooning_delta_prime(psi_idx, plasma_eq; corr_qprime=0.0, corr_pprime=0.0, theta_k=0.0)Evaluate one corrected local ballooning point. Corrections use the same normalized units as prepare_ballooning_coefficients: add corr_qprime to dq/dpsi_norm and corr_pprime to d(mu0*p)/dpsi_norm. di is the det(d0bar) diagnostic assembled from the same corrected coefficients.
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_delta_prime_map — Method
ballooning_delta_prime_map(ctrl, plasma_eq; theta_k=0.0, max_alpha_scale=8.0, n_alpha=61, max_surfaces=40)Map the ballooning Δ' over the (ψN, α) plane at fixed magnetic shear: at each flux surface, scan the pressure-gradient scaling from 0 to `maxalphascaletimes the experimental α. The flux grid is thinned to at mostmaxsurfaces` surfaces to bound the cost (each point is one ballooning ODE solve).
Returns a NamedTuple (psi, alpha_scales, alpha_ref, delta_prime) where delta_prime is [n_surf × n_alpha] with NaN on failed surfaces; physical α = alpha_ref * scale.
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_qprime_boundaries — Method
ballooning_qprime_boundaries(ctrl, plasma_eq; theta_k=0.0, min_qprime_scale=-2.0, max_qprime_scale=4.0, n_scan=24)Profile driver returning the experimental shear profile qprime (dq/dpsi_norm), the critical shear qprime_critical1 below which the surface is ballooning unstable at the experimental pressure gradient (first crossing marching down from max_qprime_scale), and qprime_critical2, the next crossing at lower (or reversed) shear, versus normalized flux psi, from ballooning_qprime_crossings at each surface. Arrays contain NaN where no boundary exists.
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_qprime_crossings — Method
ballooning_qprime_crossings(psi_idx, plasma_eq; theta_k=0.0, min_qprime_scale=-2.0, max_qprime_scale=4.0, n_scan=24, tol=1e-3)Locate the marginal-stability crossings of the ballooning Δ' along the magnetic shear scaling q'/q'_exp ∈ [min_qprime_scale, max_qprime_scale] at the fixed experimental pressure gradient — the q'-channel counterpart of ballooning_alpha_crossings. The march starts from the first-regime-stable high shear end (max_qprime_scale) and proceeds downward; sign changes are bisected and Δ' pole crossings skipped exactly as in the α scan, so the first crossing is the critical q' and the second the re-entry to stability at lower (or reversed) shear.
Returns (scales, qprimes, reference) where qprimes = qprime_norm_ref * scales is in dq/dpsi_norm units, ordered in decreasing q'.
GeneralizedPerturbedEquilibrium.ForceFreeStates.ballooning_qprime_delta_prime_map — Method
ballooning_qprime_delta_prime_map(ctrl, plasma_eq; theta_k=0.0, min_qprime_scale=-2.0, max_qprime_scale=4.0, n_qprime=61, max_surfaces=40)Map the ballooning Δ' over the (ψN, q') plane at the fixed experimental pressure gradient: at each flux surface, scan the magnetic shear scaling from `minqprimescaletomaxqprimescaletimes the experimental q' — the q'-channel counterpart of [ballooningdeltaprimemap](@ref). The flux grid is thinned to at mostmax_surfaces` surfaces to bound the cost.
Returns a NamedTuple (psi, qprime_scales, qprime_ref, delta_prime) where delta_prime is [n_surf × n_qprime] with NaN on failed surfaces; physical q' = qprime_ref * scale in dq/dpsi_norm units.
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_ballooning_ode — Method
compute_ballooning_ode(solution, parameters, poloidal_angle) -> SVector{2,Float64}Defines the RHS of the first-order ODE system for ideal marginal ballooning modes. Used by the DifferentialEquations.jl ODE solver.
The second-order equation d/dθ(f·dy/dθ) + g·y = 0 is rewritten as a 2x1 system:
dy₁/dθ = y₂ / fdy₂/dθ = -g · y₁
where y₁ is the solution and y₂ = f·dy/dθ.
Arguments
solution: Current state vector[y₁, y₂](anSVector{2}). Returns the derivative[dy₁/dθ, dy₂/dθ]as anSVector{2}.parameters::Tuple: (odecoefficientspline, reference_angle).poloidal_angle::Float64: Current poloidal angle θ (independent variable).
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_ballooning_stability! — Method
compute_ballooning_stability!(ctrl, locstab_fs, plasma_eq)Main driver routine for local high-n stability analysis. Iterates over all magnetic flux surfaces, prepares ballooning coefficients, stores det(d0bar) as the local Di diagnostic, and optionally integrates the ballooning equation to compute Delta Prime.
Arguments
ctrl::ForceFreeStatesControl: Control parameters for the analysis.locstab_fs::Matrix{Float64}: Local stability matrix to store results (modified in place).plasma_eq::Equilibrium.PlasmaEquilibrium: Plasma equilibrium data.
This function modifies locstab_fs in place with:
- Column 1:
det(d0bar) * ψ(Mercier interchangeD_I) - Column 2: resistive interchange
D_R * ψ, usingdet(d0bar)forD_Iand the surface-average route forH(seeresistive_interchange_h) - Column 4: Delta Prime (Δ')
GeneralizedPerturbedEquilibrium.ForceFreeStates.critical_ballooning_alpha — Method
critical_ballooning_alpha(psi_idx, plasma_eq; theta_k=0.0, max_alpha_scale=8.0, n_scan=24, tol=1e-3)First infinite-n ballooning stability boundary at one flux surface: the lowest Δ' zero from ballooning_alpha_crossings. α is linear in dp/dψ, so the boundary maps directly to α_crit = α_ref * scale_crit.
Returns (alpha_crit, alpha_scale_crit, found). When no crossing exists within max_alpha_scale (always stable, or second-stability access) alpha_crit = NaN and found = false.
GeneralizedPerturbedEquilibrium.ForceFreeStates.integrate_ballooning_ode — Method
integrate_ballooning_ode(ode_coefficient_spline; theta_k=0.0)Integrates the ballooning ODE from -Infinity to -1e-3 and +Infinity to +1e-3. Computes Delta' = (y'/y)right - (y'/y)left.
Returns
delta_prime: The stability index Δ'.
GeneralizedPerturbedEquilibrium.ForceFreeStates.resistive_interchange_h — Method
resistive_interchange_h(flux_surface_index, plasma_eq)Resistive interchange helper for D_R = D_I + (H - 1/2)² at a single flux surface [Glasser-Greene-Johnson; Glasser Phys. Plasmas 23, 112506 (2016)]. The intermediate H is formed from flux-surface averages of the field and metric quantities.
The main local-stability scan takes D_I from the det(d0bar) calculation reported as locstab/di, then combines it with this surface-average H to form locstab/dr. This avoids recomputing a separate surface-average D_I inside the D_R path.
GeneralizedPerturbedEquilibrium.ForceFreeStates.salpha_reference — Method
salpha_reference(psi_idx, plasma_eq)Compute local s-alpha reference values and physical profile derivatives at one flux surface.
GeneralizedPerturbedEquilibrium.ForceFreeStates.scan_delta_prime_map — Method
scan_delta_prime_map(psi_idx, plasma_eq; s_scales, alpha_scales, ...)Run a local s-alpha style scan at one flux surface using the Ballooning.jl makefromZero ballooning path. The scan perturbs physical dq/dpsi and dp/dpsi, converts them to the normalized units expected by the local ballooning evaluator, and returns delta-prime and det(d0bar) Di maps.
GeneralizedPerturbedEquilibrium.ForceFreeStates.second_critical_ballooning_alpha — Method
second_critical_ballooning_alpha(psi_idx, plasma_eq, alpha_scale_crit1; max_alpha_scale=8.0, n_scan=24, tol=1e-3)Second (upper) ballooning stability boundary at one flux surface: the first Δ' zero above alpha_scale_crit1 (as returned by critical_ballooning_alpha), from ballooning_alpha_crossings. Returns the physical critical α, or NaN if no such crossing exists within max_alpha_scale.
GeneralizedPerturbedEquilibrium.ForceFreeStates.resist_eval — Method
resist_eval(sing, equil, intr; eta, rho, gamma, ising=0) -> InnerLayer.GGJParametersCompute 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.
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.
GeneralizedPerturbedEquilibrium.ForceFreeStates.balance_integration_chunks — Method
balance_integration_chunks(chunks, ctrl, intr) -> Vector{IntegrationChunk}Sub-divide integration chunks to produce a load-balanced set for parallel execution. Starts from the output of chunk_el_integration_bounds and iteratively splits the highest-cost chunk (by ode_itime_cost) until the total chunk count reaches max(2*msing + 3, 4 * Threads.nthreads()).
Each split finds the equal-cost midpoint ψmid via bisection: odeitimecost(psistart, psimid) ≈ odeitimecost(psistart, psi_end) / 2
Sub-chunks inherit needs_crossing=false and ising=0. Only the LAST sub-chunk of each original chunk retains needs_crossing=true and the original ising, so the rational surface crossing still fires at the correct ψ in the serial assembly phase.
GeneralizedPerturbedEquilibrium.ForceFreeStates.chunk_el_integration_bounds — Method
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 parametersintr::ForceFreeStatesInternal- Internal data (singular surfaces, limits)
Returns
Vector{IntegrationChunk}- Array of integration chunks to process
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_axis_init — Method
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 = 0For 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.
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.
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
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
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.
GeneralizedPerturbedEquilibrium.ForceFreeStates.eulerlagrange_integration — Method
eulerlagrange_integration(ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal)Main driver for integrating the Euler-Lagrange equations across the plasma and detecting singular surfaces. Formerly ode_run. Has the same functionality as ode_run in the Fortran code, with the addition of a single dump to the euler.h5 file at the end of integration instead of multiple dumps to euler.bin throughout the integration. We have made the control logic more clear by pre-computing all integration chunks upfront and using a for loop to iterate through them, eliminating the while-loop logic and making integration bounds explicit at each step. We now perform significant post-processing after integration including finding the peak dW in the edge region and evaluating the stability criterion over the entire integration, which were previously done during integration in the Fortran code.
TODOs
restype functionality if we decide to do this
Returns
An OdeState struct containing the final state of the ODE solver after integration is complete.
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.
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?
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 parametersequil::Equilibrium.PlasmaEquilibrium- Plasma equilibriumffit::FourFitVars- Fourier fit variablesintr::ForceFreeStatesInternal- Internal datachunk::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
GeneralizedPerturbedEquilibrium.ForceFreeStates.ode_itime_cost — Method
ode_itime_cost(psi1, psi2, intr) -> Float64Estimate 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.
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.
GeneralizedPerturbedEquilibrium.ForceFreeStates._golden_section_max — Method
Golden-section search to find the ψ that maximizes f(ψ) on [a, b].
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_sing_asymptotics — Method
compute_sing_asymptotics(singp::SingType, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal)Calculate asymptotic vmat and mmat matrices for a singular surface. Formerly sing_vmat!. Returns a SingAsymptotics struct with the computed data instead of mutating the SingType struct. This makes it clear that asymptotics are computed on-demand for ideal ForceFreeStates and are not inherent properties of the singular surface.
See equations 41-48 in the Glasser Phys. Plasmas 2016 112506 for the mathematical details.
Arguments
singp::SingType: Singular surface parameters
Returns
SingAsymptotics: Struct containing all asymptotic expansion data
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 valuessingp::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?
GeneralizedPerturbedEquilibrium.ForceFreeStates.evaluate_fbar_condition — Method
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.
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:
- Evaluate cond(F̄) on a dense ψ grid
- Find local maxima (peaks where gradient changes from + to -)
- Refine each peak with golden-section minimization of -cond
- Filter by threshold and resonance condition
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-placeu::Array{ComplexF64,3}: Current state array, shape (mpert, mpert, 2)params::Tuple{ForceFreeStatesControl, PlasmaEquilibrium, FourFitVars, ForceFreeStatesInternal, OdeState, IntegrationChunk}: Tuple of relevant structspsieval::Float64: Current psi value at which to evaluate the derivative
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.
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_ca — Method
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
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_dua — Method
sing_get_dua(sing_asymp::SingAsymptotics, dpsi::Float64) -> duaCompute 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|
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_dua_res! — Method
sing_get_dua_res!(out, sing_asymp, dpsi) -> outEvaluate 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.
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_dua_res — Method
Allocating wrapper for sing_get_dua_res!; returns the big/small columns as (N, 2, 2).
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_ua — Method
sing_get_ua(sing_asymp::SingAsymptotics, dpsi::Float64) -> uaCompute 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|
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_ua_res! — Method
sing_get_ua_res!(out, sing_asymp, dpsi) -> outEvaluate only the big (col 1) and small (col 2) resonant columns of sing_get_ua into the caller-provided out of shape (numpert_total, 2, 2). Allocation-free. See the section comment.
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_get_ua_res — Method
Allocating wrapper for sing_get_ua_res!; returns the big/small columns as (N, 2, 2).
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_lim! — Method
sing_lim!(ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal)Compute and set integration ψ, q, and q' limits by handling cases where user truncates before the last singular surface. Performs a similar function to sing_lim in the Fortran code. Main differences include renaming of sasflag -> setpsilimviadmlim, removing dW edge storage variables since we now store all integration terms in memory, and simplification of the logic.
The target value qlim is first determined from user-specified control parameters (ctrl.qhigh or ctrl.dmlim), subject to the constraint that it does not exceed equil.params.qmax. If set_psilim_via_dmlim is true, qlim is adjusted to the largest rational surface such that nq + dmlim < qmax. If qlim < qmax, a Newton iteration is performed to find the corresponding psilim to integrate to.
Note that the Newton iteration will be triggered if either set_psilim_via_dmlim is true or ctrl.qhigh < equil.params.qmax. Otherwise, the equilibrium edge values are used.
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_matmul — Method
sing_matmul(a, b) -> cMatrix 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)
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_matvec! — Method
sing_matvec!(matvec, kmat, gmat, d1, tmp, sfvec, ffit, intr, psi, q, ua, dua) -> matvecAllocation-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 Δ′.
GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_matvec — Method
sing_matvec(ffit::FourFitVars, intr::ForceFreeStatesInternal, psi::Float64, q::Float64, ua, dua) -> matvecApply 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 atpsi(passed in to avoid re-evaluating the spline)ua,dua: asymptotic solution and its ψ-derivative fromsing_get_ua/sing_get_dua
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.
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 seriesm0mat::Matrix{ComplexF64}: Zeroth order M matrixalpha::Vector{ComplexF64}: Eigenvalues of M₀ for resonant modesr1, r2, n1, n2::Vector{Int}: Resonant and nonresonant indicespower::Vector{ComplexF64}: α values for all modes (0 for nonresonant)k::Int: The current order in the power series expansion
GeneralizedPerturbedEquilibrium.ForceFreeStates.MetricData — Type
MetricDataA 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).
GeneralizedPerturbedEquilibrium.ForceFreeStates.build_kinetic_metric_matrices — Method
build_kinetic_metric_matrices(equil, intr, metric) → NamedTupleCompute 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
- Compute 8
fmodbquantities on the (ψ,θ) grid from equilibrium geometry - Fourier decompose via
Utilities.FourierCoefficients - Assemble mpert×mpert matrices from Fourier bands at each ψ
- 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)]
GeneralizedPerturbedEquilibrium.ForceFreeStates.make_matrix — Method
make_matrix(metric::MetricData, equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal) -> FourFitVarsConstructs 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
GeneralizedPerturbedEquilibrium.ForceFreeStates.make_metric — Method
make_metric(equil::Equilibrium.PlasmaEquilibrium, mpert::Int) -> MetricDataConstructs the metric tensor data on a (ψ, θ) grid from an input plasma equilibrium. The metric coefficients stored in metric.fs include:
- g^ψψ · J
- g^θθ · J
- g^ζζ · J
- g^θζ · J
- g^ζψ · J
- g^ψθ · J
- J (Jacobian)
- ∂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.
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.
GeneralizedPerturbedEquilibrium.ForceFreeStates.make_kinetic_matrix — Method
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 byctrl.kinetic_factorrelative to ideal matrix Frobenius norms (Ak, Dk, Hk Hermitian; Bk, Ck, Ek non-Hermitian)"calculated": Compute via thecalculated_sourcecallback. This is expected to beKineticForces.compute_calculated_kinetic_matricesinjected byGeneralizedPerturbedEquilibrium.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.
GeneralizedPerturbedEquilibrium.ForceFreeStates.check_for_zero_crossings! — Method
check_for_zero_crossings!(odet, profiles, istep) -> zero_cross, nonhermCheck 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 profilesistep::Int: Current integration step index
Returns
zero_cross::Bool: True if a physical zero crossing was detectednonherm::Bool: True if W⁻¹ was non-Hermitian beyond tolerance
GeneralizedPerturbedEquilibrium.ForceFreeStates.compute_smallest_eigenvalue — Method
compute_smallest_eigenvalue(u) -> crit, nonhermForm 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 atpsi
Returns
crit::Float64: the computed scaled critical eigenvaluenonherm::Bool: true if W⁻¹ was non-Hermitian beyond tolerance (> 1e-3)
GeneralizedPerturbedEquilibrium.ForceFreeStates.evaluate_stability_criterion! — Method
evaluate_stability_criterion!(odet, profiles) -> nzeroEvaluate 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.
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, ud_store, psi_store, and q_store, and copies over existing data to the new arrays.
GeneralizedPerturbedEquilibrium.ForceFreeStates.trim_storage! — Method
trim_storage!(odet::OdeState)Trim storage arrays in odet to the actual number of steps taken. Resizes u_store, ud_store, psi_store, and q_store to the current step count, removing any unused allocated space.
GeneralizedPerturbedEquilibrium.ForceFreeStates.free_compute_total — Method
free_compute_total(equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal, odet::OdeState) -> ComplexF64Compute 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).
GeneralizedPerturbedEquilibrium.ForceFreeStates.free_compute_wv_spline — Method
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.
GeneralizedPerturbedEquilibrium.ForceFreeStates.free_run! — Method
free_run!(odet::OdeState, ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, ffit::FourFitVars, intr::ForceFreeStatesInternal) -> VacuumDataCompute the free boundary energies using the Julia port of the VACUUM code. Performs the same function as free_run in the Fortran code, except now all data is passed in memory instead of via files. This modifies odet in place to normalize the eigenfunctions stored in u_store and ud_store, and returns a VacuumData struct containing the data needed for perturbed equilibrium calculations and data dumping.
GeneralizedPerturbedEquilibrium.ForceFreeStates._populate_dense_xi_via_serial_el! — Method
_populate_dense_xi_via_serial_el!(odet, ctrl, equil, ffit, intr) -> fresh_odetReplace the propagator-BVP's odet with a fresh serial-EL odet that has dense u_store / ud_store populated in axis basis (the PerturbedEquilibrium convention). The caller's odet is fully replaced by the fresh one because free_run! downstream uses odet.u[:,:,1,end] to normalize odet.u_store, so both must be in the same basis. The parallel BVP results that survive downstream are stored in intr (psilim/qlim, sing[*].deltaprime, …) and in the externally-returned propagators / chunks / `Satsurfaceleft— none of those live onodet, so replacingodet` is safe.
The dense pass uses the serial EL path (sing_der! with standard integrator_callback!, Gaussian reduction, and transform_u!) so that u_store is in the axis basis — the only convention the PerturbedEquilibrium / FieldReconstruction downstream code is known to consume correctly.
We do save and restore the intr.psilim / intr.qlim / intr.sing[*] fields that the parallel BVP populated, because the dense EL pass would otherwise overwrite them (its standard cross_ideal_singular_surf! runs unconditionally and does NOT populate delta_prime; we keep the parallel pass's values which compute_delta_prime_matrix! uses).
Called from parallel_eulerlagrange_integration when ctrl.populate_dense_xi = true (default). Approximate cost: one serial EL integration on top of the parallel BVP phase. Required to make use_parallel = true produce DCON eigenfunctions usable by the PerturbedEquilibrium downstream pipeline.
GeneralizedPerturbedEquilibrium.ForceFreeStates._two_sided_singular_asymptotics — Method
_two_sided_singular_asymptotics(singp, ctrl, equil, ffit, intr) -> (left, right)Compute left- (sig=-1) and right- (sig=+1) side singular asymptotics matching Fortran STRIDE's separate vmatl/vmatr (sing_vmat). Alpha is taken from the right side and shared with the left.
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.
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.
GeneralizedPerturbedEquilibrium.ForceFreeStates.assemble_fm_matrix — Method
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.
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)·NEdge boundary condition
When wv is provided (the vacuum response matrix, singfac-scaled), the edge BC follows the Fortran STRIDE convention:
U₁ = c, U₂ = -wv·ψ₀²·cwhich 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.
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).
GeneralizedPerturbedEquilibrium.ForceFreeStates.integrate_fm_with_ua_ic — Method
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.
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.
GeneralizedPerturbedEquilibrium.ForceFreeStates.parallel_eulerlagrange_integration — Method
parallel_eulerlagrange_integration(ctrl, equil, ffit, intr) -> OdeStateParallel fundamental matrix (propagator) driver for the EL integration.
Functionally equivalent to eulerlagrange_integration, integrating all bulk chunks concurrently using Threads.@threads, then re-integrating the outer plasma serially:
- Chunk generation: calls
chunk_el_integration_bounds, thenbalance_integration_chunksto sub-divide chunks for load-balanced parallel execution. - Parallel phase:
integrate_propagator_chunk!integrates each chunk independently from identity initial conditions (no accumulated state, no normalization/callback). Each thread uses a privateOdeStateproxy forsing_der!side effects. - Serial assembly: propagators are applied sequentially with
apply_propagator!. Rational surface crossings usericcati_cross_ideal_singular_surf!(no Gaussian reduction) matching the Riccati path convention. - Outer plasma re-integration: after the last rational surface crossing, the outer plasma (from last ψs to psilim) is re-integrated using `riccatiintegratechunk!
. FM propagation in this region is prone to precision loss for high N (exponential growth without renormalization); Riccati integration keeps matrices bounded and provides dense checkpoints forfindmaxdW_edge!`.
Enable via use_parallel = true in [ForceFreeStates] of gpec.toml, or by setting ctrl.use_parallel = true programmatically. Requires singfac_min != 0.
Key differences from standard integration:
- No Gaussian reduction in the propagator BVP phase (crossings use the Riccati-style algorithm, parallel
odet.ifixstays 0) transform_u!is called on the parallel odet but is a no-op (ifix=0)- Outer plasma uses serial Riccati integration for numerical stability
- A serial Euler-Lagrange dense pass is appended at the end and replaces the parallel
odetso thatu_store/ud_storeare dense and in axis basis — the only convention the PerturbedEquilibrium downstream code consumes correctly. Δ' (singular/delta_prime_matrix) is computed from the parallel BVP and is bit-identical with vs. without this pass. Toggle off withctrl.populate_dense_xi = falseif only Δ' / vacuum / energies are needed and the extra serial-EL cost is unwanted (HDF5integration/xi_*will then be sparse / zero).
Bidirectional integration for large-N accuracy: The crossing chunk (nearest to each rational surface singL[j]) is integrated backward (direction=-1, tspan reversed). Backward integration of a region where solutions grow exponentially forward causes them to decay, so the resulting backward FM Φbwd is well-conditioned. The accurate forward propagation is recovered as Φbwd⁻¹ via a stable LU solve in apply_propagator_inverse!. This follows the same principle as STRIDE (Glasser 2018 Phys. Plasmas 25, 032501). The all-forward path had ~10% energy error for the DIIID-like example (N=26, n=1); bidirectional reduces this to within 2%.
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.
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.
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:
No Gaussian reduction:
cross_ideal_singular_surf!callscompute_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.Direct column zeroing: Instead of using the GR-sorted
odet.indexto identify the column to zero, we useipert_resdirectly (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.
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)
GeneralizedPerturbedEquilibrium.ForceFreeStates.riccati_eulerlagrange_integration — Method
riccati_eulerlagrange_integration(ctrl, equil, ffit, intr) -> OdeStateMain driver for integrating the dual Riccati ODE across the plasma. Functionally identical to eulerlagrange_integration except:
- Uses
riccati_integrate_chunk!: drivessing_der!withriccati_integrator_callback!which appliesrenormalize_riccati_inplace!(instead of Gaussian reduction) when column norms exceed ucrit - Uses
riccati_cross_ideal_singular_surf!instead ofcross_ideal_singular_surf!: skips Gaussian reduction (avoids near-zero pivot issues when S is small near axis) and renormalizes to (S_new, I) in one step - Skips
transform_u!— S is already the true solution, no Gaussian-reduction undo needed
Enable via use_riccati = true in [ForceFreeStates] section of gpec.toml, or by setting ctrl.use_riccati = true programmatically.
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).
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.
Example usage
Run stability analysis from a TOML configuration
using GeneralizedPerturbedEquilibrium, TOML
const FFS = GeneralizedPerturbedEquilibrium.ForceFreeStates
ex = "examples/Solovev_ideal_example"
inputs = TOML.parsefile(joinpath(ex, "gpec.toml"))
ctrl = FFS.ForceFreeStatesControl(;
(Symbol(k) => v for (k, v) in inputs["ForceFreeStates"])...)
equil = GeneralizedPerturbedEquilibrium.Equilibrium.setup_equilibrium(
GeneralizedPerturbedEquilibrium.Equilibrium.EquilibriumConfig(inputs["Equilibrium"], ex))
intr = FFS.ForceFreeStatesInternal(; dir_path=ex)
intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.WallShapeSettings(;
(Symbol(k) => v for (k, v) in inputs["Wall"])...)
FFS.sing_lim!(intr, ctrl, equil)
intr.nlow = ctrl.nn_low; intr.nhigh = ctrl.nn_high; intr.npert = 1
FFS.sing_find!(intr, equil)
intr.mlow = min(intr.nlow * equil.params.qmin, 0) - 4 - ctrl.delta_mlow
intr.mhigh = trunc(Int, intr.nhigh * equil.params.qmax) + ctrl.delta_mhigh
intr.mpert = intr.mhigh - intr.mlow + 1
intr.numpert_total = intr.mpert * intr.npert
metric = FFS.make_metric(equil, intr.mpert)
ffit = FFS.make_matrix(equil, intr, metric)
# Choose integration driver. The top-level `eulerlagrange_integration` dispatches
# to the parallel or Riccati path based on ctrl.use_parallel / ctrl.use_riccati,
# and always returns a 4-tuple (odet, propagators, chunks, S_at_surface_left).
odet, _, _, _ = FFS.eulerlagrange_integration(ctrl, equil, ffit, intr)
vac = FFS.free_run!(odet, ctrl, equil, ffit, intr)
println("Energy eigenvalue et[1] = ", real(vac.et[1]))Inspect Δ' at singular surfaces
for s in 1:intr.msing
sing = intr.sing[s]
println("Surface $s: ψ = $(sing.psi_s), m/n = $(sing.m[1])/$(sing.n[1])")
println(" Δ' = $(real(sing.delta_prime[1]))")
endAccess inter-surface Δ' matrix (parallel FM path)
# intr.delta_prime_matrix is msing × msing after parallel_eulerlagrange_integration.
# Internally the solver builds a 2·msing × 2·msing raw matrix; the stored Δ' is
# the PEST3 four-term combination that folds the raw block into a per-surface
# tearing parameter.
dpm = intr.delta_prime_matrix
println("Δ' matrix size: ", size(dpm))
println("Diagonal (self-response Δ'):")
for j in 1:intr.msing
println(" Surface $j: ", real(dpm[j, j]))
endNotes
- The standard path does not populate
delta_prime; usePerturbedEquilibrium.SingularCouplingfor Δ' on the standard path (it readsca_l/ca_rdirectly). - 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]equalsdelta_prime[i]to machine precision. delta_prime_matrixcontains raw BVP coefficients, not asymptotic-normalized values; its diagonal elements do not in general equaldelta_prime.- ODE step counts depend on the equilibrium profile and mode count; the
numsteps_initparameter 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 thePlasmaEquilibriumobject required by this moduledocs/src/vacuum.md— vacuum response computed from the EL solution infree_run!docs/src/perturbed_equilibrium.md— downstream singular coupling analysis using Δ'