ForceFreeStates - BUGFIX! - Default to the DCON fixed-axis initial condition - #440
ForceFreeStates - BUGFIX! - Default to the DCON fixed-axis initial condition#440d-burg wants to merge 2 commits into
Conversation
…ndition `fixed_axis` now defaults to `true`: the Euler-Lagrange integration starts from U1 = 0, U2 = I at psilow (xi^psi = 0 at the magnetic axis), the condition used by Fortran DCON (dcon/ode.f, ode_axis_init) and Glasser 2016 Sec. VI. The previous default (`false`) started from the free-axis Frobenius state of `compute_axis_init`. At practical psilow (1e-2 ... 1e-4) that state has U1 entries of order 0.1-10 with mixed signs instead of the documented psilow^(|m|/2) limit. Started from it, the signed critical eigenvalue of W_p^-1 is displaced from the first stored step onward and never recovers: one eigenvalue of the plasma response matrix W_p sits ~10x off its DCON value in every equilibrium tested, and where it crosses zero the free-boundary energy et[1] reports a spurious -1e2 ... -1e5 "instability". Verified against Fortran DCON on identical geqdsks (diverted DIII-D-like, n = 1): with the fixed-axis start crit(psi) agrees to a median relative error of 8e-5, the stiff W_p eigenvalue to ~1e-4, and et[1] returns to the physical fundamental. The Delta' BVP is unaffected (<= 1e-4 relative). Integrator (riccati/forward), tolerances, coefficient matrices and the free-boundary assembly were all exonerated by measurement. The Frobenius start is retained behind `fixed_axis = false` for comparison; its docstring now records the measured discrepancy. Two figures documenting the defect and the fix are added under docs/src/assets/. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…iverted fixture Adds test/runtests_fixed_axis.jl and a 129x129 synthetic DIII-D-like ramp-up fixture (q0 = 3.42, diverted, no wall, n = 1; analytic profiles, 303 kB) on which the Frobenius free-axis start produces et[1] ~ -1e5 and a negative plasma-matrix eigenvalue while the DCON fixed-axis start gives et[1] = +1.50 and a stiff W_p eigenvalue of +1.609e4, matching Fortran DCON v1.5.5 on the 257x257 parent. Asserts: the default is fixed_axis = true; with it et[1] > 0, W_p has no negative eigenvalue and its stiff eigenvalue is within 2 % of the DCON value; the Delta' diagonal is independent of the axis condition (rtol 1e-3). The defect of the Frobenius start is pinned with @test_broken so a repaired compute_axis_init reports an unexpected pass. Runtime ~2 min (two full free-boundary runs at mpsi 128). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
This PR changes ForceFreeStates’ default Euler–Lagrange axis initial condition to the DCON fixed-axis condition (fixed_axis = true) to eliminate spurious free-boundary instabilities and align crit(ψ), W_p, and et behavior with Fortran DCON.
Changes:
- Default
ForceFreeStatesControl.fixed_axistotrueand document both axis-init options and their measured discrepancy. - Update related docstrings/comments in Euler–Lagrange and Riccati driver code paths to reflect the new default.
- Add a new regression test and synthetic fixture to lock in the corrected default behavior and preserve the known defect as
@test_brokenforfixed_axis = false.
Reviewed changes
Copilot reviewed 8 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
src/ForceFreeStates/CoreTypes.jl |
Switches fixed_axis default to true and adds detailed docstring rationale/impact. |
src/ForceFreeStates/EulerLagrange.jl |
Updates axis-init documentation and clarifies fixed-axis vs Frobenius behavior. |
src/ForceFreeStates/Riccati/Driver.jl |
Updates documentation to match the new default and DCON alignment claims. |
test/runtests_fixed_axis.jl |
Adds regression coverage for the default fixed-axis condition + records Frobenius defect as broken tests. |
test/runtests.jl |
Wires the new regression test into the test suite. |
test/test_data/regression_rampup_fixed_axis/gpec.toml |
Adds a test deck used by the new fixed-axis regression test. |
test/test_data/regression_rampup_fixed_axis/forcing.dat |
Adds forcing input for the new synthetic regression fixture. |
test/test_data/README_test_data.md |
Documents the new regression fixture and expected behaviors. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| wp = Matrix(fb.wp) | ||
| wpe = eigvals(Hermitian((wp + wp') / 2)) | ||
| dp = real.(diag(Matrix(r.ffs.delta_prime.matrix))) | ||
| return (et1 = minimum(real.(fb.et)), wp_min = minimum(wpe), wp_max = maximum(wpe), |
| dir = mktempdir() | ||
| for f in readdir(FIXED_AXIS_FIXTURE) | ||
| cp(joinpath(FIXED_AXIS_FIXTURE, f), joinpath(dir, f)) | ||
| end | ||
| toml = joinpath(dir, "gpec.toml") | ||
| s = read(toml, String) | ||
| s = replace(s, r"^fixed_axis.*\n"m => "") | ||
| s = replace(s, "[ForceFreeStates]\n" => "[ForceFreeStates]\nfixed_axis = $(fixed_axis)\n") | ||
| write(toml, s) | ||
| r = GeneralizedPerturbedEquilibrium.main([dir]) | ||
| fb = r.ffs.free_boundary | ||
| wp = Matrix(fb.wp) | ||
| wpe = eigvals(Hermitian((wp + wp') / 2)) | ||
| dp = real.(diag(Matrix(r.ffs.delta_prime.matrix))) | ||
| return (et1 = minimum(real.(fb.et)), wp_min = minimum(wpe), wp_max = maximum(wpe), | ||
| n_neg = count(<(0), wpe), dprime = dp) |
| # Deprecated + ignored upstream (warning asks for removal), deleted here: | ||
| # mer_flag, ode_flag, mat_flag, use_parallel, force_wv_symmetry. |
| # ForceFreeStates BVP (dW) + Delta'. | ||
| # | ||
| # Wall: nowall (CONVENTIONS.md §6 — required for valid Delta'). | ||
| # use_parallel: true (CONVENTIONS.md §7 — false silently invalidates Delta'). |
Release note
et[1]by ≤ 5e-6 relative on the DIII-D-like cases and ≤ 4e-4 on the Solovev cases,ep[1]/ev[1]by ~2e-5, the fullet/epspectra by 3–9 % (the stiff non-resonant modes, which is the defect); Δ′ (Riccati BVP diagonal and raw matrix, Galerkin), ballooning, singular-surface and equilibrium quantities bit-identical; downstream SLAYER γ 0.02 %, PerturbedEquilibrium energies/fields ≤ 0.6 %, NTV torques 1.1–1.3 %;PE toroidal torqueondiiid_n15.1e-2 → 1.2e-4 (harness @ c4276de) — 12 cases, report belowfixed_axis = falseunder[ForceFreeStates]; everyone else changes nothing.Free-boundary ideal δW from the ForceFreeStates (FFS) solver could report a spurious −10²…−10⁵ "instability" on well-behaved diverted equilibria. The cause is the default axis initial condition: the Euler-Lagrange integration started from a free-axis Frobenius state that does not reduce to the axis regularity condition at practical
psilow, so one eigenvalue of the plasma response matrix W_p was wrong in every run and passed through a pole wherever it crossed zero. The default is now the DCON condition U₁ = 0, U₂ = I (ξ^ψ = 0 at the axis), which reproduces Fortran DCON'scrit(ψ), W_p spectrum andet[1]on identical geqdsks. The Frobenius start remains available asfixed_axis = false.The defect
On a 27-point n = 1, no-wall, diverted DIII-D-like ramp-up scan (TokaMaker geqdsks,
set_psilim_via_dmlim = true,integrator = "riccati"), FFS reportedet[1]≈ −8e4 … −1.5e2 over a contiguous block q₀ ≈ 3.4–2.7 while every neighbouring point was stable at +1.5. Fortran DCON (GPEC/bin/dcon, identicalmlow = −14,mhigh = 27,qlim = 7.2,psilim = 0.988) on the same files: stable, plasma matrix +0.25 … +1.6e4, no negative eigenvalue.Signed smallest eigenvalue of W_p⁻¹ (DCON's
crit) along ψ, flagged slice (left) and a clean neighbour (right). Black: Fortran DCON. Orange: julia_GPEC with the old default — displaced from the first stored step and never recovering; on the flagged slice it stays negative to the edge. Green: this PR — on top of DCON (median relative error 8e-5 over the 38 stored points).Mechanism, established by measurement (read-only diagnostics; four independent investigations plus an adversarial pass):
initialize_el_at_axis!,compute_axis_init)psilow = 0.01(docstring promised ~ψ_low^(|m|/2)). DCON: U₁ = 0."forward"reproduces the same signed λ_min(S) as"riccati"to 5 significant figures (−5.979e-4)ucrit,extended_precision_bvpFree.jlvsfree.f)wp = u₂ u₁⁻¹ / ψ₀²The single-variable intervention
fixed_axis = true, nothing else changed:crit(psilim)et[1]On the clean neighbour the old default's stiff eigenvalue was +2.76e5 against DCON's +1.61e4 (17×): the eigenvalue is wrong everywhere, and the "pole" is merely where it crosses zero.
Left:
et[1]over the 27-point scan, old default (orange) vs this PR (green). Right: the stiffest W_p eigenvalue on the flagged and clean slices, both settings, against Fortran DCON.Full-scan sweep with the fix: 27/27 points, zero spurious values,
et[1]equal to the previously e_v-filtered physical value at every point to ≤ 0.013, Δ′(2/1) changed by ≤ 1e-4 relative.What changes
src/ForceFreeStates/CoreTypes.jl—fixed_axis::Bool = true; new docstring entry explaining both settings and the measured discrepancy of the Frobenius start.src/ForceFreeStates/EulerLagrange.jl— branch comments updated;compute_axis_initdocstring gains a warning that it does not deliver its documented limit at practicalpsilow. No code path changed.src/ForceFreeStates/Riccati/Driver.jl— docstring updated.test/runtests_fixed_axis.jl(new, wired intoruntests.jl) — two full free-boundary runs on a new 129×129 synthetic diverted ramp-up fixture (test/test_data/regression_rampup_fixed_axis/, 303 kB, analytic profiles). With the default:et[1] > 0, no negative W_p eigenvalue, stiff W_p eigenvalue within 2 % of Fortran DCON's +1.609e4 (this fixture: +1.6090e4). Withfixed_axis = false: the same three assertions as@test_broken, so a repairedcompute_axis_initreports an unexpected pass. Δ′ diagonal asserted independent of the axis condition (rtol 1e-3). ~2 min.docs/src/assets/fixed_axis_*.png— the two figures above.Not in this PR (separate issues)
ep/ev/etand Fortran DCON's (e.g.et[1]1.505 vs 1.425); the W_p spectrum agrees to ~1e-4, so this sits in the eigenproblem normalisation (Free.jlgeneralised pencil with the power norm vsfree.fstandard problem + ξ†Jξ/V′ division).compute_axis_inititself: repair to the documented asymptotic limit, or demote. Suspects are the diagonal 2×2 truncation of A₀ and the regular-branch selection rule.Verification
GeneralizedPerturbedEquilibrium.mainon the staged decks; scripts and logs available on request).regress --cases diiid_n1,diiid_n1_riccati,diiid_slayer_n1,efit_fixedbdy_separatrix,gal_resistive_diiid,gal_resistive_pe,solovev_kinetic_calculated,solovev_kinetic_multiion,solovev_kinetic_ntv,solovev_kinetic_nuzero,solovev_multi_n,solovev_n1 --refs 987e63f3b,c4276de63(julia 1.11.6, arm64, manifest 7e5c34ad pinned, 8 threads / 2 BLAS). Summary per case:diiid_n1diiid_n1_riccatidiiid_slayer_n1efit_fixedbdy_separatrixgal_resistive_diiidgal_resistive_pesolovev_kinetic_calculatedsolovev_kinetic_multiionsolovev_kinetic_ntvsolovev_kinetic_nuzerosolovev_multi_nsolovev_n1Δ′ is bit-identical wherever it is computed (
diiid_n1_riccati: BVP diagonal and raw matrix, diff 0.0;gal_resistive_diiid: PEST3 Δ diagonal, ‖Δ′‖, coil block, inner-layer Δ all 0.0). The imaginary parts ofet[1], which should vanish, move toward zero onsolovev_n1(−1.05e-3 → −1.4e-5). One line reviewers should look at:PE toroidal torqueondiiid_n1drops from 5.1e-2 to 1.2e-4 — the ideal perturbed-equilibrium response should carry no net torque, so this reads as the old axis condition having injected a spurious one, but I have not traced it.All changed quantities (62 lines)
diiid_n1 — DIII-D-like equilibrium, n=1, ideal + perturbed equilibrium
Summary: 16 changed, 31 unchanged
diiid_n1_riccati — DIII-D-like equilibrium, n=1, Riccati integrator Δ' matrix
Summary: 6 changed, 11 unchanged
diiid_slayer_n1 — DIII-D-like H-mode equilibrium, n=1, SLAYER tearing-mode analysis (uncoupled per-surface, AMR, validity-gated)
Summary: 1 changed, 14 unchanged
efit_fixedbdy_separatrix — Fixed-boundary EFIT (box hugs LCFS), Newton separatrix-find regression
Summary: 5 unchanged
gal_resistive_diiid — DIII-D-like, n=1, RDCON outer-region Galerkin Δ′ with rpec coil columns (delta_coil)
Summary: 10 unchanged
gal_resistive_pe — DIII-D-like, n=1, DRIVEN/RPEC: gal-matched resistive ξ → PerturbedEquilibrium (coil-driven singular coupling)
Summary: 8 missing/N/A
solovev_kinetic_calculated — Solovev, n=1, kinetic_source=calculated, kinetic_factor=1.0, 50/50 D-T multi-ion kinetic matrices, mode-converged band (delta_m=8, mpert=32); pressure-consistent kinetic.dat (n·T tied to the Solovev P₀≈4.27e4 Pa).
Summary: 7 changed, 7 unchanged
solovev_kinetic_multiion — Solovev n=1, ideal + PE + multi-ion NTV (D + T + electron summed)
Summary: 5 changed, 1 unchanged
solovev_kinetic_ntv — Solovev analytical equilibrium, n=1, ideal + PE + NTV torque quadrature
Summary: 3 changed, 3 unchanged
solovev_kinetic_nuzero — Solovev, n=1, kinetic_source=calculated, nutype=zero (collisionless energy integral), 50/50 D-T multi-ion kinetic matrices, mode-converged band (delta_m=8, mpert=32). Reuses the calculated example deck via override; pressure-consistent kinetic.dat.
Summary: 7 changed, 7 unchanged
solovev_multi_n — Solovev analytical equilibrium, multi-n, ideal stability
Summary: 7 changed, 8 unchanged
solovev_n1 — Solovev analytical equilibrium, n=1, ideal stability
Summary: 10 changed, 12 unchanged
julia --project=. test/runtests.jl runtests_fixed_axis.jl runtests_eulerlagrange.jl runtests_riccati.jl— Fixed-axis default: 6 pass, 3 broken (the recorded Frobenius defect), 1m56s; ODE Tests 91 pass; materialize_derivative_stores! 26 pass; Riccati Integration Tests 14 pass.🤖 Generated with Claude Code