Skip to content

PerturbedEquilibrium - FEATURE - Decompose the plasma energy of free-boundary eigenmodes on the (psi,theta) grid - #465

Open
jaesun57 wants to merge 8 commits into
developfrom
feature/energy-decomposition
Open

jaesun57 wants to merge 8 commits into
developfrom
feature/energy-decomposition

Conversation

@jaesun57

@jaesun57 jaesun57 commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Release note

  • Audience: users
  • Numerical impact: none (harness @ 4daa3b1)
  • Migration: none

Analysis.EnergyDecomposition.decompose_plasma_energy(ffs; eigenmodes) decomposes the plasma potential energy δW_p of free-boundary eigenmodes on the (ψ,θ) grid, in the effective-field form ½∫[|b_eff|²/μ₀ − (K₁+K₂+K₃)|ξ_n|²] and the standard form ½∫[|b|²/μ₀ − j·(b×ξ*) + (∇·ξ)*(ξ·∇p)], scaled to compare with ffs.free_boundary.ep[k]. plot_energy_decomposition draws the radial profiles and the cumulative δW_p.

Regression report

Regression Report: diiid_n1
=====================================================================================================================
Ref 1: origin/develop  @ 728a7e7d5 (2026-10-30)
       env: julia 1.11.6, arm64-apple-darwin24.0.0, manifest 44d8c447 (pinned), 4 threads/4 BLAS, jit build
Ref 2: feature/energy-decomposition-rework  @ 4daa3b1da (2026-10-30)
       env: julia 1.11.6, arm64-apple-darwin24.0.0, manifest 44d8c447 (pinned), 4 threads/4 BLAS, jit build
---------------------------------------------------------------------------------------------------------------------
Quantity                                      origin/develop   feature/energy-decomposition-rework  Diff       Status
---------------------------------------------------------------------------------------------------------------------
total energy Re(et[1])                        7.979262e-01     7.979262e-01                         0.0e+00    OK    
total energy Im(et[1])                        1.122437e-06     1.122437e-06                         0.0e+00    OK    
plasma energy Re(ep[1])                       -1.377302e+00    -1.377302e+00                        0.0e+00    OK    
vacuum energy Re(ev[1])                       2.175228e+00     2.175228e+00                         0.0e+00    OK    
vacuum matrix min eigenvalue                  1.873975e-01     1.873975e-01                         0.0e+00    OK    
plasma energy (all)                           [35 elem]        [35 elem]                            0.0e+00    OK    
vacuum energy (all)                           [35 elem]        [35 elem]                            0.0e+00    OK    
total energy (all)                            [35 elem]        [35 elem]                            0.0e+00    OK    
ODE steps (saved)                             1172             1172                                 0.0e+00    OK    
ODE steps (total)                             1167             1167                                 0.0e+00    OK    
q0                                            1.204212e+00     1.204212e+00                         0.0e+00    OK    
q95                                           4.781723e+00     4.781723e+00                         0.0e+00    OK    
beta_t                                        1.327024e-02     1.327024e-02                         0.0e+00    OK    
beta_n                                        1.372511e+00     1.372511e+00                         0.0e+00    OK    
internal inductance li1                       8.842268e-01     8.842268e-01                         0.0e+00    OK    
internal inductance li2                       7.080757e-01     7.080757e-01                         0.0e+00    OK    
internal inductance li3                       7.304340e-01     7.304340e-01                         0.0e+00    OK    
poloidal beta betap1                          6.680739e-01     6.680739e-01                         0.0e+00    OK    
poloidal beta betap2                          5.349836e-01     5.349836e-01                         0.0e+00    OK    
poloidal beta betap3                          5.518764e-01     5.518764e-01                         0.0e+00    OK    
# singular surfaces                           5                5                                    0.0e+00    OK    
singular psi locations                        [5 elem]         [5 elem]                             0.0e+00    OK    
singular q values                             [5 elem]         [5 elem]                             0.0e+00    OK    
current beta betaj                            4.236480e-01     4.236480e-01                         0.0e+00    OK    
plasma volume                                 1.829472e+01     1.829472e+01                         0.0e+00    OK    
plasma current                                1.152130e+00     1.152130e+00                         0.0e+00    OK    
mpert                                         35               35                                   0.0e+00    OK    
npert                                         1                1                                    0.0e+00    OK    
toroidal field bt0                            2.006573e+00     2.006573e+00                         0.0e+00    OK    
wall field bwall                              3.880145e-01     3.880145e-01                         0.0e+00    OK    
aspect ratio                                  2.845746e+00     2.845746e+00                         0.0e+00    OK    
elongation kappa                              1.708322e+00     1.708322e+00                         0.0e+00    OK    
q profile (checksum)                          dac8e966989b...  dac8e966989b...                      identical  OK    
pressure profile (checksum)                   df0baeff71ef...  df0baeff71ef...                      identical  OK    
Mercier D_I profile (checksum)                d885f8847ccb...  d885f8847ccb...                      identical  OK    
resistive interchange D_R profile (checksum)  b4462cc4f60b...  b4462cc4f60b...                      identical  OK    
ballooning Delta' profile (checksum)          a2631a388123...  a2631a388123...                      identical  OK    
island half-widths                            [5 elem]         [5 elem]                             0.0e+00    OK    
Chirikov parameter                            [5 elem]         [5 elem]                             0.0e+00    OK    
||resonant area-weighted field||              5.286411e-04     5.286411e-04                         0.0e+00    OK    
dominant-coupling singular values             [3 elem]         [3 elem]                             0.0e+00    OK    
|forcing overlap with dominant mode|          1.419632e-04     1.419632e-04                         0.0e+00    OK    
|delta_nominal| of coil set 1                 7.074909e-05     7.074909e-05                         0.0e+00    OK    
||ddelta/d(shift)|| over coil sets            1.246839e-04     1.246839e-04                         0.0e+00    OK    
||ddelta/d(tilt)|| over coil sets             4.175435e-07     4.175435e-07                         0.0e+00    OK    
PE plasma energy                              3.321081e+00     3.321081e+00                         0.0e+00    OK    
PE vacuum energy                              3.174319e+00     3.174319e+00                         0.0e+00    OK    
PE surface energy                             5.521988e+00     5.521988e+00                         0.0e+00    OK    
PE toroidal torque                            9.083689e-07     9.083689e-07                         0.0e+00    OK    
NTV torque FGAR [N·m]                         5.539368e-01     5.539368e-01                         0.0e+00    OK    
NTV kinetic energy dW FGAR [J]                8.225496e-02     8.225496e-02                         0.0e+00    OK    
Runtime (s)                                   112.8s           110.9s                                          --    
||forcing b~|| (root-area-weighted)           4.683171e-04     4.683171e-04                         0.0e+00    OK    
resonant area-weighted field b^r              [5 elem]         [5 elem]                             0.0e+00    OK    
=====================================================================================================================
Summary: 53 unchanged

Regression Report: solovev_n1
=====================================================================================================
Ref 1: origin/develop  @ 728a7e7d5 (2026-10-30)
       env: julia 1.11.6, arm64-apple-darwin24.0.0, manifest 44d8c447 (pinned), 4 threads/4 BLAS, jit build
Ref 2: feature/energy-decomposition-rework  @ 4daa3b1da (2026-10-30)
       env: julia 1.11.6, arm64-apple-darwin24.0.0, manifest 44d8c447 (pinned), 4 threads/4 BLAS, jit build
-----------------------------------------------------------------------------------------------------
Quantity                      origin/develop   feature/energy-decomposition-rework  Diff       Status
-----------------------------------------------------------------------------------------------------
total energy Re(et[1])        7.361948e-03     7.361948e-03                         0.0e+00    OK    
total energy Im(et[1])        -2.881106e-13    -2.881106e-13                        0.0e+00    OK    
plasma energy Re(ep[1])       -1.042158e+01    -1.042158e+01                        0.0e+00    OK    
vacuum energy Re(ev[1])       1.042894e+01     1.042894e+01                         0.0e+00    OK    
vacuum matrix min eigenvalue  2.170703e+00     2.170703e+00                         0.0e+00    OK    
plasma energy (all)           [32 elem]        [32 elem]                            0.0e+00    OK    
vacuum energy (all)           [32 elem]        [32 elem]                            0.0e+00    OK    
total energy (all)            [32 elem]        [32 elem]                            0.0e+00    OK    
ODE steps (saved)             385              385                                  0.0e+00    OK    
ODE steps (total)             383              383                                  0.0e+00    OK    
q0                            1.900006e+00     1.900006e+00                         0.0e+00    OK    
q95                           3.147422e+00     3.147422e+00                         0.0e+00    OK    
beta_t                        4.621027e-02     4.621027e-02                         0.0e+00    OK    
beta_n                        3.217775e+00     3.217775e+00                         0.0e+00    OK    
# singular surfaces           2                2                                    0.0e+00    OK    
singular psi locations        [2 elem]         [2 elem]                             0.0e+00    OK    
singular q values             [2 elem]         [2 elem]                             0.0e+00    OK    
mpert                         32               32                                   0.0e+00    OK    
npert                         1                1                                    0.0e+00    OK    
q profile (checksum)          c5ee811e7ed1...  c5ee811e7ed1...                      identical  OK    
pressure profile (checksum)   0bdeb79b3618...  0bdeb79b3618...                      identical  OK    
ca_left (checksum)            c0f52c6d6cc8...  c0f52c6d6cc8...                      identical  OK    
Runtime (s)                   84.6s            82.4s                                           --    
=====================================================================================================
Summary: 22 unchanged

Run on a laptop (arm64, Julia 1.11.6, pinned manifest 44d8c447, 4 threads), develop @ 728a7e7: diiid_n1 53 unchanged, solovev_n1 22 unchanged, no new rows.

Notes for reviewers

This is the rework @logan-nc asked for in the 2026-10-19 review. The branch was rebuilt from develop; the physics is unchanged, the packaging is new.

Review item Now
Not a main-pipeline stage No TOML section, no main stage, no gpec.h5 group, no harness rows, no example-deck sections. A scripted diagnostic in Analysis.
Duplicated geometry All geometry from ffs.metric: the contravariant products by cofactors of the covariant metric, B² = (χ'/J)²(g₂₂ + 2q g₂₃ + q² g₃₃). flux_surface_basis and metric_bsq_interpolant are gone; Equilibrium is identical to develop. Verified on both Solovev decks that ffs.metric reproduces the former flux_surface_basis (g_ij, J, ∂J/∂ψ, |∇ψ|², ∇ψ·∇θ, ∇ψ·∇ζ, B²) to ≤1.1e-15 at every knot.
Machinery 351-line module (over the ~300 target only by the References section and the two success checks below), no scratch structs, threading, BLAS.set_num_threads or getfield/setfield! loops. Returns one NamedTuple: psi, theta, R, Z, terms, eigenmodes, density (npsi, mtheta, 7, neig), profile (npsi, 7, neig), dW_effective, dW_standard, relative_error_effective, relative_error_standard, curl_residual.
Tests that can't fail Rewritten (130 lines, 81 assertions, ~23 s): standard-form surface integral = 2/ψ₀² × DCON Lagrangian ξ'†Fξ' + 2Re(ξ'†Kξ) + ξ†Gξ on every knot (tested 1e-9), on the Solovev fixture at mpsi = 32 in Boozer coordinates (g_θζ ≠ 0) and on the DIII-D-like example (F′ ≠ 0, measured 7e-11, tested 1e-8); the two success checks within their measured values plus margin, and the DIII-D standard form within 1e-4 of ep; effective ≈ standard; ⟨J S⟩ = χ'²q'; off-knot metric vs the rzphi bicubic (1.2e-5); the ψ integrals start at the ODE start for a qlow run; solution_state_at; plot. Of 33 single-term errors injected into the module, the earlier version of these tests missed 5; the current tests catch 4 of them, and the fifth scales (∇·ξ)*(ξ·∇p), which vanishes for DCON displacements. The sum-restating, self-integral, arithmetic and bookkeeping tests are gone.
Kinetic results (@matt-pharr) decompose_plasma_energy itself throws on kinetic, multi-n, non-forward and fixed-boundary results.
Citations in references.md Not in references.md or citations.md; Bernstein 1958/1983, Chance 1992 and Boozer 2004 are cited in a References section of the module docstring, as suggested inline.
Docs Folded into docs/src/analysis.md (physics, usage, Fortran deviations, convergence table); no separate page.
Curl residual not a physics diagnostic; derived values not stored Kept on purpose as the two success checks of each call, returned and warned but not written to gpec.h5 or the harness: the δW_p mismatch against ep[k] (warned above 5 %) flags an mpsi too coarse for the ψ quadrature, which does happen on real grids, and the curl residual (normalized and warned above 1e-8 as in Fortran gpeq_cveri; measured 7e-14 on the test fixture and 4e-12 on the DIII-D-like example) flags an inconsistent b_eff assembly.

Kept from the old branch: ForceFreeStates.solution_state_at (ξ at each knot integrated from the nearest saved step). Not kept: the compute_node_xi_s! widening — ξ_s is linear in (ξ', ξ), so the module calls the existing matrix signature on the basis and applies the edge combination afterwards.

Port check: on the same ffs the new module reproduces the former PerturbedEquilibrium.decompose_energy to ≤1e-13 in both totals and ≤1e-10 in every density (fixture and examples/Solovev_ideal_example).

The ψ integrals run over every saved ODE step as well as the equilibrium knots, and start where the forward sweep starts, max(psilow, first knot).

  • Why the saved steps: the knots follow the equilibrium profiles, not the eigenmode, so near the rational surfaces they miss the structure of ξ. Integrated over the knots only, the standard form missed ep by:
    • 0.3–0.5 % on the DIII-D-like example, at every knot grid;
    • 35 % for eigenmode 1 of the LAR reversed-shear example at mpsi = 128;
    • 5 % on the a10 example at mpsi = 64.
  • DIII-D split: Re(ξ†u₂) at the edge equals ep, the jumps at the five rational surfaces sum to 5e-6, and ∫L over the saved steps equals ep to 1e-5. So the whole error was knot quadrature.
  • With the saved steps the standard form matches ep to 1e-5 on DIII-D.
  • Why the start: the sweep saves its first step one ODE step after the start; with qlow = 2.5 integrating from that step instead had biased mode 2 by −1.25 %.

Relative error against ep on the test fixture in its PEST coordinates (mpert = 8):

mpsi mode 1 effective / standard mode 2 effective / standard
16 −4.44e-3 / −4.50e-3 −3.19e-2 / −3.23e-2
32 −2.17e-6 / −8.19e-6 −3.35e-5 / −6.86e-5
64 −1.07e-6 / −1.37e-6 −9.14e-6 / −1.11e-5
128 −2.01e-6 / −1.98e-6 −1.72e-5 / −1.72e-5

At mpsi = 16 the metric splined between the knots is too coarse. From mpsi = 32 the two forms agree to 4e-5 on this fixture.

Open: the effective form differs from the standard form by a grid-independent amount on some equilibria, while the standard form matches ep:

Example Effective − standard, relative to ep
DIII-D-like −0.41 % (mode 1), −0.49 % (mode 2)
LAR reversed-shear −9.5 % (mode 1), −1.05 % (mode 2)
Solovev, a10, TJ analytic ≤7e-5

The gap is unchanged when the integral runs over the saved ODE steps, so it is not quadrature. On DIII-D it also does not change with mtheta, mpert, the coordinates or the B² source.

Open, in ForceFreeStates and not in this PR: when the forward sweep gets very close to a rational surface, the stored states left of the surface carry a spurious component. On examples/Solovev_ideal_example, Re(ξ†u₂) of eigenmode 1 jumps across the q = 3 crossing by:

Setting Jump of Re(ξ†u₂) Standard form vs ep
Deck settings, and singfac_min down to 1e-7 ≤3e-5·|ep| as expected
eulerlagrange_tolerance = 1e-11 36·|ep| −187 × ep
singfac_min = 1e-8 6e4·|ep| −3.4e6 × ep

The decomposition flags both cases and the warning fires. Integrated over the knots only, the same corruption had shown up as a shift of about 1e-3.

🤖 Generated with Claude Code

@github-actions github-actions Bot added the feature New capability label Sep 22, 2026
@jaesun57 jaesun57 closed this Sep 22, 2026
@jaesun57
jaesun57 deleted the feature/energy-decomposition branch September 22, 2026 23:40
@jaesun57
jaesun57 restored the feature/energy-decomposition branch September 22, 2026 23:47
@jaesun57 jaesun57 reopened this Sep 23, 2026
@jaesun57 jaesun57 self-assigned this Sep 23, 2026
Copilot stopped work on behalf of jaesun57 due to an error September 23, 2026 19:19
@jaesun57
jaesun57 marked this pull request as ready for review September 24, 2026 17:40
@jaesun57
jaesun57 requested review from logan-nc and a lite review from Copilot September 24, 2026 17:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The review found one critical integration issue and two moderate correctness/schema issues.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Adds an optional energy-decomposition diagnostic for free-boundary eigenmodes, with effective-field and standard-form calculations, HDF5 output, tests, regression tracking, and documentation.

Changes:

  • Adds decomposition APIs and pipeline/TOML integration.
  • Adds regression quantities and example configuration.
  • Adds tests, documentation, and HDF5 schema guidance.
  • Refactors displacement regularization to a keyword argument.

Unresolved findings: critical integration gating issue (4 votes), moderate disabled-field HDF5 shape issue (3 votes), and moderate truncated-domain integration issue (1 vote).

File Description
test/​runtests.jl Registers energy-decomposition tests.
test/​runtests_energy_decomposition.jl Tests calculations, toggles, metadata, and output.
src/​PerturbedEquilibrium/​PerturbedEquilibrium.jl Includes and exports the new APIs.
src/​PerturbedEquilibrium/​FieldReconstruction.jl Refactors regularization argument handling.
src/​PerturbedEquilibrium/​EnergyDecomposition.jl Implements decomposition, diagnostics, and HDF5 writing.
src/​GeneralizedPerturbedEquilibrium.jl Integrates the optional stage and scripting API.
regression-harness/​cases/​solovev_n1.toml Tracks decomposition diagnostics.
regression-harness/​cases/​diiid_n1.toml Tracks decomposition diagnostics.
examples/​Solovev_ideal_example/​gpec.toml Enables the feature in the example.
examples/​DIIID-like_ideal_example/​gpec.toml Enables the feature in the example.
docs/​src/​energy_decomposition.md Documents theory, usage, outputs, and API.
docs/​src/​citations.md Adds supporting citations.
docs/​src/​api.md Documents the scripting entry point.
docs/​make.jl Adds the documentation page to navigation.
docs/​development/​references.md Records implementation references.
docs/​development/​hdf5-conventions.md Documents the new output schema.
docs/​development/​architecture.md Updates the architecture overview.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/GeneralizedPerturbedEquilibrium.jl Outdated
Comment on lines +863 to +864
ForceFreeStates.require_solution(result, "energy decomposition") || return nothing
ForceFreeStates.require(result, :free_boundary, "energy decomposition") || return nothing
:effective_b_curl_residual_density, :b_squared_density, :current_coupling_density, :pressure_compression_density)

# Empty result arrays are written zero-extent (the file's not-computed sentinel).
_h5_value(x::AbstractArray) = isempty(x) ? similar(x, 0) : x

@logan-nc logan-nc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consolidated review from three independent passes (structural, performance, code clarity), each claim checked against the source. Ordered by what I'd want settled before merge. The physics itself was not reviewed here — see the end.

1. Blocking: the stage aborts the run instead of skipping

run_energy_decomposition (GeneralizedPerturbedEquilibrium.jl, new hunk) gates only require_solution and require(:free_boundary), which warn and return. But decompose_energy throws ArgumentError on nlow != nhigh and mats.kinetic !== nothing (EnergyDecomposition.jl:698–699). A legal multi-n or kinetic deck that carries [EnergyDecomposition] therefore kills the whole run after FFS — before PE, KineticForces or SLAYER — contradicting the stage docstring ("warned and skipped, like every optional stage") and docs/src/energy_decomposition.md. Copilot flagged the same gap. ~10 lines: warn-and-return for both conditions in the driver, keep the throws for scripted use.

2. Decide before merge: where the output lives

  • The group ForceFreeStates/EnergyDecomposition/ bakes "free-boundary eigenmodes only" into the schema, while the producer is a PE-module stage. Free-boundary enters the code through just wt[:,k] (:342) and ep[k] (:814); a decompose_energy(ffs, edge::Vector; reference=nothing) entry would serve fixed-boundary modes and forced responses at ~half a day, and PerturbedEquilibrium/… would then be the natural home. HDF5 renames are clean breaks here with no shim, so the placement is worth deciding now even if the generalization waits.
  • eigenmode_index = copy(eigenmodes) (:719) echoes a control value into an output group (hdf5-conventions.md: never echo control-struct values outside Input/). It is defensible as the coordinate of the eigenmode dimension — in which case attach it as a dimension scale; only psi is attached today (:926–927).

3. Structural: the geometry block shadows Equilibrium

  • surface_geometry! (:160–166) reproduces Equilibrium.flux_surface_metric (FluxSurfaceMetrics.jl:16–23) line for line — the function whose docstring declares it "the single source of truth for the |∇ψ| computation reused across the FFS and PE modules" — then extends it with w21/w22/w31/w32 and the tangent basis. Extend flux_surface_metric (or add a sibling in Equilibrium) and call it, rather than carrying a second implementation in PE.
  • The same block re-interpolates geometry with 13 bicubic evaluations per point at the equilibrium knots, where rzphi_*.nodal_derivs.partials[1:3, i, j] already holds f, ∂f/∂ψ, ∂f/∂θ and make_metric (Fourfit.jl:193–197) and GeometryProfiles.jl:44 read them directly. Only the appended edge node needs the interpolant. The feature is cheap overall (order 0.1–1 s), so this is about consistency with the existing pattern more than speed.
  • metric_bsq_interpolant (:213–230) is a private, persistent B² that competes with equil.eqfun_B and is rebuilt on every call (:717). Whether metric-consistent B² is the right physics is for the physics review; either way its owner should be Equilibrium or make_metric, so a future consumer (LocalStability shear, KineticForces) does not grow a third B².
  • eigenmode_modes (:361–367) re-encodes −A⁻¹(Bξ′ + Cξ) by hand; compute_node_xi_s! (ForceFreeStates/EulerLagrange.jl:1397, used at Result.jl:174) already does exactly that.
  • surface_geometry! and curvature_kernel! depend only on (equil, psi) but sit inside the eigenmode loop (:755–770): all geometry is computed twice for eigenmodes = [1, 2]. Hoist per surface.

4. Robustness

  • surface_geometry!(…; bsq=nothing) leaves geom.bsq zero, and curvature_kernel! divides by it (:285). Make bsq required.
  • similar(x, 0) (:921–922) collapses disabled 2-D/3-D outputs to rank-1 while the metadata table declares (psi, eigenmode) / (psi, theta, eigenmode) — the writer cannot attach the declared dims (Copilot). The zero-extent sentinel has precedent, but only if the rank is kept.
  • Single-n is load-bearing, not just declared: N = ffs.numpert_total (:349) is written into xi_s[i, :] of width mpert (:367), a DimensionMismatch the guard at :698 is what prevents.
  • Constructors hard-code field counts (for _ in 1:16, 1:6, 1:13, 1:6, 1:8 at :131/248/400/451/539); fieldcount(T) stops them rotting when a field is added.

5. Cleanup

  • Code comments are clean — no run exceeds two lines. The narration is in docstrings: :208–211 and :409–412 carry session history (keep the one-sentence reason; drop the rest), and test:176, :208 record observed percentages that belong in the docs, not a comment.
  • docs/src/energy_decomposition.md:64 says "better than 1 %"; the tests record 1.4 %, 1.2 % and 3.3 % at the mpsi = 16 fixture (test:176, :208). "≲1–3 % at mpsi = 16, converging with resolution" matches the evidence.
  • test/runtests_energy_decomposition.jl (292 lines) would be ~200 with the same coverage: _ed_metadata_violations (:216–231) duplicates test/h5_metadata_check.jl — include it; :32–50 tests the reg_spot keyword, not this kernel; :70–73 asserts kwdef defaults; :86–90 re-derive K1/K2/K3 from :293–295 (tautological); :99–128 hand-plumbs the curl residual that :173 already pins; decompose_energy runs eight times — hoist the shared results. :287–291 writes into the shared fixture's gpec.h5 and only works because it is the last testset — write to a temp copy.
  • Five structs describe their fields in prose rather than a ## Fields block (:104, :232, :376, :435, :516); the unnamed 1e-10 at :315; μ₀j^θ and μ₀j^ζ computed three times (:286, :475, :572); the 13 hand-named accumulators plus three mirrored copy blocks (:737–749, :796–837) could be driven from one name tuple, as _allocate_result! already half-does.

6. Before merge

  • The branch is ~250 commits behind and develop has since rewritten FieldReconstruction.jl (225 lines) and the driver (+455); your 6-line reg_spot change and 47-line hook land on rewritten code. DIRTY here is semantic, not textual (zero conflict markers) — worth re-reading both hunks after the merge-forward.
  • The regression report was a laptop run (arm64, 4 threads) against a develop that has moved; please re-run on the shared machine after merging forward and refresh the stamp.
  • This review deliberately did not cover the physics (metric-B² vs eqfun_B, the pointwise covariant lowering your docstring at :408–412 justifies, the ξ_s recomputation at knots). Those deserve a physics pass and I'd ask for one before approval.

Credit where due: Copilot's two inline comments — the gating gap and the similar(x, 0) rank collapse — were both real and are folded in above.

🤖 Generated with Claude Code

https://claude.ai/code/session_0186CLfjeJeSusNTva5RMfGU

@logan-nc logan-nc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Physics pass, to close the gap my earlier review left open. Checked term by term against the Fortran counterpart and the theory note on origin/recon of the Fortran repository (gpout_recon/gpout_recon2 in gpout.f, gpeq_c/gpeq_K/gpeq_shear/gpeq_curvature/gpeq_cveri/gpeq_firstform in gpeq.f, docs/tex/recon/main.tex) — not on Fortran develop, which is worth stating in the docs since "fidelity to Fortran" means fidelity to that branch. Every claim marked verified was read or derived; the Fortran line references were checked by me as well.

Verdict: correct with caveats. No missing terms, no placeholders; every kernel either matches origin/recon line for line or is a defensible, more accurate deviation. One thing should be settled before merge — see §6 — because it is the untested explanation for the 1.4–3.3 % mismatch.

1. Both δW forms — verified

  • Standard form (:565–585): derived from −½∫ξ*·F(ξ) with the full ξ (the ξ∥ pieces of the J×Q and ∇·ξ terms cancel, so using the full Jξ^θ, Jξ^ζ is right). det_term at :572–575 is J·det[j, b, ξ*] with j^ψ = 0; ∇·ξ at :577 is (1/J)[∂ψ(Jξ^ψ) + ∂θ(Jξ^θ) − 2πin Jξ^ζ] in the exp(2πi(mθ−nζ)) convention. Term for term identical to gpeq_firstform. One divergence: ∂θ(Jξ^θ) is spectral here (:415–418) and a periodic spline there — fine, but annotate it as a Fortran deviation.
  • Effective field b_eff = b + ξ_n μ₀ j×n̂ (:455–480): all six component formulas verified by hand and identical to gpeq_c (cwt/cwz/cvp/cvt/cvz). μ₀j^θ = −F′/J and μ₀j^ζ = −(μ₀p)′/χ′ − qF′/J follow from ∇×B and (j×B)·∇ψ = p′, given F_deriv = d(2πF)/dψ_N (inferred from the Fortran sq%f1(1) usage; please confirm).
  • K = K1 + K2 + K3 (:293–295): identical to gpeq_K and main.tex; units consistent (K|ξ_n|² is Pa); K3 = 2p′κ_ψ checks against the interchange term. Caveat: K1, K2 and the completing-the-square identity were verified against main.tex and gpeq_K only — Bernstein 1983, Chance 1992 and Boozer 2004 are not in docs/resources/, so the equation numbers this PR adds to references.md/citations.md are unverified. Please either add the PDFs or cite the theory note.
  • ½, Re, conjugation, normalization: Re[(Jc^i)* c_i]/(μ₀J) is Hermitian-correct; the ½ matches Fortran dw_raw = 0.5*total; the 2μ₀/ψ₀² scale (:602) with unit-power-normalized wt[:,k] as edge BC (:342) is the same convention as Fortran's dcon_fac after idcon.f undoes its rescaling; ep = diag(wt†·wp·wt) with wp = (u2/u1)/ψ₀² matches free.f. No vacuum or surface term belongs in the comparison — ep is the plasma boundary term Ξ†(FΞ′+KΞ), i.e. the volume δW_p; ev is separate. Verified in concept; the DCON ½/μ₀ scale was not re-derived from Glasser 2016, but the 1e-3 agreement rules out a missing O(1) factor.

2. Metric-B² vs eqfun_B — your choice is right; say so at the call site

Fortran gpeq_K and gpeq_curvature take B² from eqfun (bsq_val = eqfun%f(1)**2, with fx/fy for its derivatives). make_metric builds only g_ij/J, J, J_ψ — no eqfun — so the Euler–Lagrange operator and the b reconstruction carry the metric B², and the C∥ = K2|ξ_n|² cancellation holds only with that B². Using it is operator-consistent and an improvement over Fortran. The ~1 % is the mismatch between the equilibrium's direct |B| and the inverse-map metric at mpsi = 16 — coordinate-transformation accuracy converging with equilibrium resolution, not hidden physics. It needs the project's one-line deviation note at :285, :291, :294, not only in the docstring.

3. Pointwise covariant lowering — the claim holds, with two corrections

gpeq_c back-transforms band-limited modes before squaring, so ∮|C|² and ∮K2|ξ_n|² each lose out-of-band energy that does not cancel between them; pointwise products on the mtheta grid are Parseval-exact (aliasing aside). So the Julia effective-field numbers will differ from Fortran recon reference values at the few-% level, and Julia is the more accurate. Two corrections to the docstring at :408–412: "resolution-independent" should read "ψ-resolution-independent, mpert-dependent" (the deficit shrinks with the band); and this is a hybrid — Jξ^ψ is formed pointwise (:416, :426) but Jξ^θ, Jξ^ζ still come from the truncated jmat convolution in compute_contra_displacements — the docstring implies all-pointwise.

4. ξ_s at the knots — verified, no physical content in knot-vs-node

−A⁻¹(Bξ′ + Cξ) (:361–367) matches Fortran gpeq_sol and Julia's own compute_node_xi_s! (EulerLagrange.jl:1397, same raw A; Cholesky vs LDLᵀ immaterial). It is the algebraic Euler–Lagrange relation for Ξ_s, so evaluating at knots rather than ODE nodes changes only the interpolation accuracy of (ξ, ξ′), nothing physical. That argues for calling the existing helper rather than re-encoding it (as my earlier review noted).

5. The curl residual — right check, state its scope

(:495–515) is identical to gpeq_cveri and is the Ξ_s equation itself (∇ψ·∇×C = 0 ⇔ δj·∇ψ = 0 closure), so at the knots it holds by construction. It validates the ξ_s algebra, the g22/g23/g33 lowering and the b_θ, b_ζ assembly against A, B, C — not the ψ-ODE and not b_ψ or g11/g12/g13/|∇ψ|². A roundoff-level value says nothing about the eigenmode's correctness. Worth one sentence in the docs so it isn't read as a global check.

6. Convergence — the one thing to settle before merge

Expected: ~h⁴ for the cubic-spline ψ-quadrature on a smooth integrand, spectral in θ. Observed from the PR's own numbers (mpsi 16 → 128): effective form ~h^0.9, standard form ~h^1.8. That is not quadrature-order behaviour.

The leading explanation is a mis-documented Fortran deviation. gpout.f says verbatim, just above the energy check: "Perform the energy check on the DCON solution grid. Do not use rzphi%xs for this comparison; it is a coarse geometry grid." — and integrates on psifac. decomposition_grid (:309–316) integrates on the rzphi_xs equilibrium knots, and the docstring at :305 says gpout_recon "loops over the same knots" — true of its diagnostics loop, not of its energy integral. With the 1/(m − nq) structure in ξ_s and Jξ^θ, the coarse-knot integral is the natural suspect for both the size of the mismatch and the sub-quadrature rate; the slower effective-form rate is consistent with K depending on spline derivatives (bsq_psi, dratio) that lose an order. A confound: ep itself moves with mpsi.

Discriminating test, cheap to run: fix mpsi, integrate on ODE nodes ∪ knots instead of knots alone, and report how the relative error changes. If it collapses, fix the grid and the docstring; a separate mpert scan would then bound the band-truncation floor at mpsi = 128.

7. Fortran correspondence and the annotations owed

gpout_recon/gpeq_c/gpeq_K/gpeq_shear/gpeq_curvature/gpeq_cveri/gpeq_epf/gpeq_dst/gpeq_firstform ↔ decompose_energy/effective_field!/curvature_kernel!/curl_residual!/surface_densities!. Term-level divergences, each needing the project's one-line deviation comment at the call site: (a) metric B² vs eqfun B²; (b) pointwise vs band-truncated C_i, ξ_n; (c) spectral vs spline ∂θ; (d) radial grid knots vs psifac; (e) reg_spot = 0 vs Fortran's regularized xmp1/xmt path in gpeq_firstform (inferred from variable names, not traced). Also: the long_name on current_coupling and pressure_compression (:940–941) says "imaginary part is roundoff" — true of the volume totals, not of the per-surface profiles; reword.

Not checked: the Bernstein/Chance/Boozer equation numbers; the DCON ½/μ₀ convention from Glasser 2016 directly; the edge amplitude in sum_eigenmode_contributions; gpeq_firstform's regularization path; gpout_recon2's radial grid; aliasing error of the mtheta Riemann sum.

🤖 Generated with Claude Code

https://claude.ai/code/session_0186CLfjeJeSusNTva5RMfGU

Comment thread docs/development/references.md Outdated
- Published: Physics of Plasmas **24**, 032505 (2017)
- Describes: Self-consistent coupling with neoclassical effects

The energy decomposition of free-boundary eigenmodes (`EnergyDecomposition.jl`) follows:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if these should be here.

  1. This does not conform to the standard module markdown section formatting (minor)
  2. This references.md is intended to be references for people to cite our code - the papers by us, the developers (not just every paper about relevant physics).

I am torn since this new feature does not have a paper yet, but I think we should wait for @jaesun57 to publish something using it and then add that paper here. In the meantime, these papers can be cited in source code annotations to help developers looking into the details without confusing the general users into thinking Bernstein and Boozer are GPEC developers 😆

@logan-nc logan-nc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I flagged a few general organizational things for now. A more in depth AI assisted review can come in a bit

xwp_modes, xwt_modes, xwz_modes, xmt_modes, xmz_modes = compute_contra_displacements(
xi_psi_modes, clebsch_psi1, clebsch_alpha,
psi_grid, equil, ffs, metric, ctrl
psi_grid, equil, ffs, metric; reg_spot=ctrl.reg_spot

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has me a bit worried. We just had a number of issues/PRs about regularization from @jhalpern30 and @matt-pharr and some deeper thought than I can give skimming this during the ITPEA meeting is needed to ensure any use here is consistent with what we are doing elsewhere.

Comment thread src/GeneralizedPerturbedEquilibrium.jl Outdated
push!(runtimes, "force_free_states" => ffs_dt)
@info "Force-Free States completed in $(@sprintf("%.3f", ffs_dt)) s"

run_energy_decomposition(ffs_result, inputs)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@matt-pharr this is already quite clean - but do you need it tweaked into a "solve" type format to conform to the new standard API you are enforcing? Is there an open issue describing it that we can point claude to and ask it to conform to for things like this?

@logan-nc logan-nc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of #465: energy decomposition of free-boundary eigenmodes

@jaesun57, first: this is a lot of careful work, and much of it is good.
You ported a hard diagnostic from the Fortran.
You checked the result against ep[k] in two independent forms.
You also chased the convergence question further than most people would.
The finding that splining ξ through the saved ODE steps hid a cancellation, so the old 1.7e-5 agreement was luck and not convergence, is a real result.
solution_state_at is a small, well-argued, reusable piece.
You also answered every item of the last round of reviews.
Thank you.

Even so, I'm requesting changes, and they are not small.
The physics can stay.
The way it is built into the code cannot.
The rest of this review explains why, then gives a concrete plan for the rework.

Important

This PR (and its rework) must not be merged without approval from a third-party human reviewer. That is non-negotiable.

#465 has no requested reviewer, and the author is the only assignee.
Please request a human reviewer, or mark the PR as Draft while the rework is under way.


Big picture

1. It does not belong in the main run chain

The decomposition is a verification diagnostic.
It reconstructs δW_p of eigenmodes that ForceFreeStates (FFS) has already computed, and checks the result against ep[k].
Nothing downstream consumes its output.
Even so, it adds:

  • a new [EnergyDecomposition] TOML section with four keys
  • a new stage in main_from_inputs
  • a new gpec.h5 group with about 38 annotated datasets
  • the section in two example decks
  • five rows in each of two regression-harness cases

All of that is upkeep the whole team carries, for a calculation a user runs on purpose, a few times, while investigating a mode.
That is what a scripted diagnostic is for: call one function on a ForceFreeStatesResult in a script or notebook and plot what comes back.
The PR already contains that entry point (energy_decomposition(ffs; eigenmodes=...)), so the TOML section and the stage add nothing a user can't already do.

The placement is also inconsistent:

  • It is configured as its own stage.
  • The code lives in PerturbedEquilibrium.
  • It writes under PerturbedEquilibrium/ even when PerturbedEquilibrium never runs.
  • It is a diagnostic of FFS eigenmodes.

The rework below moves it into Analysis, which already loads after PerturbedEquilibrium and is where our post-processing lives.

2. It recomputes geometry the code already has

Equilibrium.flux_surface_basis hand-expands the Fortran gpeq_c v and w matrices from a 12-scalar positional argument list.

  • The v matrix already exists four times. The same tangent-basis expressions, built from the same rzphi_*.nodal_derivs, are in:

    • ForceFreeStates.make_metric (src/ForceFreeStates/Fourfit.jl)
    • build_kinetic_metric_matrices (same file)
    • src/Equilibrium/Equilibrium.jl (two places)
    • src/ForceFreeStates/Surfaces/Resist.jl

    This PR adds a fifth copy.

  • ffs.metric already holds the result on exactly the grid you integrate on. make_metric stores e_i·e_j/J, J and ∂J/∂ψ at every equilibrium knot. The decomposition already reads ffs.metric (in eigenmode_modes).

  • The w matrix carries no new information. It is exactly the cofactor matrix of v divided by J (I checked all six entries). The contravariant pieces therefore follow from the covariant metric g_ij:

    • |∇ψ|² = (g₂₂g₃₃ − g₂₃²)/J²
    • ∇ψ·∇θ = (g₁₃g₂₃ − g₁₂g₃₃)/J²
    • ∇ψ·∇ζ = (g₁₂g₂₃ − g₁₃g₂₂)/J²
  • B² comes from the same metric. The "metric B²" is (χ'/J)²(g₂₂ + 2q g₂₃ + q² g₃₃), so it doesn't need a new exported Equilibrium interpolant (metric_bsq_interpolant) either.

This is the pattern we most need to avoid in the Julia code base: porting a Fortran routine's private scaffolding verbatim next to an equivalent that already exists.

3. Too much machinery for the size of the calculation

By the PR's own measurement the whole decomposition takes about 0.15 s, but it is built like a performance-critical kernel:

  • Five all-Vector scratch structs. SurfaceGeometry, CurvatureKernel, SurfaceFields, EffectiveField and SurfaceDensities are built by reflection (_zeros_struct) and filled field by field. For example, surface_geometry! copies 14 fields out of a NamedTuple one at a time.
  • Hand-managed threading. Per-threadid() buffer arrays under @threads :static, and per-thread hint tuples.
  • Global state changed inside a library function. BLAS.set_num_threads(1) is set and then restored in a finally block.
  • Fields addressed by Symbol. getfield/setfield! loops over _ED_*_PROFILES and names built with Symbol(name, :_density). A typo becomes a runtime error, and the loops are type-unstable inside the threaded loop.
  • A 35-field result struct. It is filled with zero-extent sentinels so two Bool toggles can switch a form off. Several fields can be derived from others: dW_cumulative*, dW_plasma_reference (a copy of ep[k]), both *_relative_error, and three statistics for one curl check.
  • Dead code. The repeated-node bookkeeping in _radial_integral never runs, because decomposition_grid already returns strictly increasing nodes.
  • Integrals done twice. print_energy_decomposition_summary re-integrates profiles that decompose_energy integrated a few lines earlier.
  • Validation done twice. Both run_energy_decomposition and decompose_energy check the integrator, n range and kinetic flag.

4. Most of the tests can't fail

These assertions only restate the implementation:

  • Sums restated. The component sum equals the total, and surface_integral equals the same sum written out again.
  • One integral checked against itself. The last cumulative value equals the total, which comes from the same cumulative_integrate call.
  • Densities checked against themselves. Densities re-integrated equal the profiles they were integrated into.
  • Arithmetic, not physics. rms ≤ max, and reference ≈ ep.
  • Bookkeeping. The name tuples cover the struct fields, and the return value of main has no "energy" key.

The 1e-8 bound on the curl residual is a fair unit test of the b_eff formulas.
But the PR itself notes that (∇×b_eff)·∇ψ = 0 is an algebraic identity on the knots, so it is not a physics diagnostic.
It should not go to gpec.h5, and it should not be in the harness: a 1e-9 noise threshold on a roundoff-level number will mostly raise false alarms.

The tight test is the one described in your own PR body: on the knots, the standard-form integrand equals the DCON Lagrangian density to about 1e-11.
That assertion is independent of the implementation and would catch real bugs.
It isn't in the suite.

What should survive

These parts are good and should carry over:

  • ForceFreeStates.solution_state_at
  • widening compute_node_xi_s! to AbstractVecOrMat
  • the reg_spot keyword refactor of compute_contra_displacements
  • the physics: both forms, K₁/K₂/K₃, the pointwise (not band-truncated) products, and ξ at the knots
  • the citations
  • your convergence results, which belong in the docs page

Rework plan

The plan below is written the way I would write it for myself (or for Claude Code) before starting.
Treat it as the spec for the rework.
If you disagree with a step, say so in this thread before writing code.

Context

PR #465 ports the Fortran gpout_recon/gpout_recon2 energy reconstruction.
The physics is verified: both forms reproduce ep[k] to 1e-4–1e-2, with the error converging as mpsi and etol are refined.
The integration is wrong:

  • a main-pipeline stage and its TOML section
  • duplicated geometry (flux_surface_basis, metric_bsq_interpolant)
  • about 950 lines of scratch structs, symbol-driven result plumbing and an HDF5 writer
  • tests that mostly restate the implementation

Goal: the same physics as a scripted diagnostic in Analysis.
It reads all geometry from ffs.metric, returns plain arrays, and is checked by a few independent tests.

Non-goals:

  • no TOML section and no main stage
  • no gpec.h5 output and no harness rows
  • no new Equilibrium exports
  • no threading
  • no fixed-boundary or forced-response generalization

Target API

using GeneralizedPerturbedEquilibrium
res = GeneralizedPerturbedEquilibrium.main(["path/to/run"])        # forward integrator, vac_flag = true
ed  = Analysis.EnergyDecomposition.decompose_plasma_energy(res.ffs; eigenmodes=[1, 2])
Analysis.EnergyDecomposition.plot_energy_decomposition(ed; eigenmode=1)

decompose_plasma_energy returns one NamedTuple:

field shape meaning
psi (npsi,) decomposition nodes (equilibrium knots inside the ξ domain, plus ψ_edge)
theta (mtheta,) θ grid, in [0, 1)
R, Z (npsi, mtheta) node positions, for 2-D maps
terms NTuple{7,Symbol} (:b_eff_sq, :K1, :K2, :K3, :b_sq, :j_b_xi, :divxi_gradp)
density (npsi, mtheta, 7, nmodes) pointwise integrands, already scaled by 2μ₀/ψ₀²
profile (npsi, 7, nmodes) ∮J·density dθ
dW_effective, dW_standard (nmodes,) ½∫ of the two combinations

There are no toggles and no sentinels: both forms are always computed, because it costs a fraction of a second.
The densities are always returned.
Store them as ComplexF64, which is about 4 MB per mode at mpsi = 128 and mtheta = 256.
The caller derives everything else: cumulative profiles come from cumulative_integrate, and the relative error is (dW − real(ffs.free_boundary.ep[k])) / abs(ep[k]).

Files

New

  • src/Analysis/EnergyDecomposition.jl, the submodule Analysis.EnergyDecomposition. Target at most about 300 lines including docstrings.
  • test/runtests_energy_decomposition.jl, rewritten. Target at most about 120 lines.

Edit

  • src/Analysis/Analysis.jl: add the include and the module docstring entry.
  • docs/src/analysis.md: add an @autodocs block for the new submodule.
  • docs/src/energy_decomposition.md: trim it and keep the physics and convergence tables. Alternatively, fold it into analysis.md.

Revert to develop

  • src/Equilibrium/FluxSurfaceMetrics.jl and the Equilibrium.jl export line. _grad_psi_poloidal only existed to share code with flux_surface_basis.
  • src/GeneralizedPerturbedEquilibrium.jl: remove run_energy_decomposition, energy_decomposition, the stage call and the export.
  • src/PerturbedEquilibrium/PerturbedEquilibrium.jl: remove the include and exports.
  • examples/*/gpec.toml: remove the [EnergyDecomposition] sections.
  • regression-harness/cases/{solovev_n1,diiid_n1}.toml: remove the five new rows.
  • docs/development/hdf5-conventions.md and docs/development/architecture.md: revert the PR's one-line additions.

Keep as they are on the branch

  • ForceFreeStates.solution_state_at (src/ForceFreeStates/Result.jl)
  • compute_node_xi_s! widened to AbstractVecOrMat (src/ForceFreeStates/EulerLagrange.jl)
  • the reg_spot keyword change (src/PerturbedEquilibrium/FieldReconstruction.jl)
  • the citation additions in docs/src/citations.md and docs/development/references.md

The cleanest history is a fresh branch from develop.
Cherry-pick 66c60af (reg_spot), then bring over solution_state_at and the compute_node_xi_s! change.
Then either force-push feature/energy-decomposition or open a new PR linked from here.

Algorithm

All geometry comes from ffs.metric (make_metric, src/ForceFreeStates/Fourfit.jl).
metric.fs[:, :, 1:6] hold e_i·e_j/J in the order (ψψ, θθ, ζζ, θζ, ζψ, ψθ).
Index 7 is J and index 8 is ∂J/∂ψ.
The docstring labels them g^ij·J; the code computes the covariant quantities.
Confirm this numerically in milestone M0.

  1. Nodes. Use the equilibrium knots in [psi_store[1], psi_store[step]], plus ψ_edge when the last knot falls short of it (keep decomposition_grid's logic).

  2. Geometry per node. Read the eight columns at each node.

    • At a knot, index the row directly.
    • At ψ_edge, the one node off the knots, evaluate a single CubicSeriesInterpolant along ψ over the 8 × mtheta columns. That is a native op; write no new geometry function.
    • Build one series interpolant for all nodes, so there is a single code path (it reproduces the knot rows exactly), and take ∂ψ of the columns from the same object.
    • Derive |∇ψ|², ∇ψ·∇θ and ∇ψ·∇ζ from the cofactor identities above.
    • Derive B² = (χ'/J)²(g₂₂ + 2q g₂₃ + q² g₃₃). Take ∂ψB² from the series interpolant and ∂θB² from a periodic spline or the spectral derivative on the surface.
    • Take R and Z from existing helpers (grep Analysis and Equilibrium first) or from the rzphi nodal values. Add no exported function.
  3. ξ and b per eigenmode. Keep the PR's chain unchanged: solution_state_at, then el_derivatives!, then the wt[:, k] edge condition, then compute_node_xi_s!, then PerturbedEquilibrium.compute_perturbed_field_modes and compute_contra_displacements(...; reg_spot=0.0). Then inverse-transform to θ with Utilities.FourierTransforms.

  4. Densities. Write one plain loop over (ψ, θ) that computes the seven terms with local scalars:

    • b_eff from the effective-field relations
    • K₁, K₂, K₃
    • |b|², j·(b×ξ*) and (∇·ξ)*(ξ·∇p)

    Use no scratch structs. Allocate per surface where needed. Use no threading and leave BLAS settings alone.

  5. Integrals. profile = sum(density .* J; dims=θ) / mtheta, written with sum/dot, not a helper.
    For the totals, use FastInterpolations.integrate(cubic_interp(psi, profile)).
    For complex terms, take real before the ψ-integral and say why in one line: the imaginary part integrates to roundoff.

  6. Plot. Write one function, plot_energy_decomposition(ed; eigenmode). It shows radial profiles of the seven terms and the two cumulative curves, following docs/development/plotting.md.

Comments are one line, with no step numbers.
A deviation from the Fortran gets a single short comment stating the choice made.
Docstrings state what a function computes, not how we got there.

Milestones

Each milestone has an acceptance check.
Commit after each one, using Area - TAG - Imperative summary subjects.

  • M0: Pin down the geometry identities (scratch script, not committed).
    On the Solovev fixture, compare the PR's flux_surface_basis (at branch tip) with metric.fs × J and the cofactor identities, at every knot.
    Accept when the maximum relative difference is at most 1e-12 for g_ij, J, ∂J/∂ψ, |∇ψ|², ∇ψ·∇θ, ∇ψ·∇ζ and B².
    If any of them fails, stop and report: the plan's premise would then be wrong.
  • M1: Baseline numbers (scratch, not committed).
    Record dW_plasma and dW_plasma_standard_form from the current branch on the Solovev fixture and on examples/Solovev_ideal_example, for eigenmodes [1, 2].
    These are the reference values for M3.
  • M2: Strip the branch.
    Do the reverts listed under Files.
    Accept when main on both example decks produces a gpec.h5 identical to develop, Pkg.test passes, and Documenter builds.
  • M3: The new diagnostic.
    Add Analysis.EnergyDecomposition.
    Accept when both totals match the M1 baselines to at most 1e-10 relative difference on both decks, the source file is at most about 300 lines, and there are no getfield/setfield! loops, no threadid, and no BLAS.set_num_threads calls.
  • M4: Tests (below).
    Accept when every assertion could fail for a physics or algebra bug and none of them restates the implementation.
    Each testset should run in a few seconds after the fixture is built.
  • M5: Docs and PR.
    Trim the docs page, add the @autodocs block and update the PR body.
    The body should name the rework, and its release note should say "Numerical impact: none", with the harness run against develop.
    Every existing row should be unchanged and there should be no new rows.
    Request a human reviewer.

Tests (replaces the current file)

Use the existing Solovev fixture (mpsi = 16, forward integrator, vac_flag = true).
Run one main and one decompose_plasma_energy(ffs; eigenmodes=[1, 2]), and share them.

  1. Standard form equals the DCON Lagrangian, pointwise.
    At every knot, profile[:, :b_sq] − profile[:, :j_b_xi] + profile[:, :divxi_gradp] must equal the Euler-Lagrange quadratic form in ξ and ξ′, built from the ffs.mats F, K and G splines with the same scaling.
    Use rtol = 1e-9.
    This is the main correctness test, and it is independent of the θ-space code.
  2. Effective form equals standard form, integrated.
    dW_effective ≈ dW_standard at a tolerance set from the measured fixture offset with a stated margin (not an arbitrary 0.03), plus a one-line comment citing where the offset comes from: equilibrium etol.
  3. Totals equal ep[k].
    Check abs(dW_standard[k] − real(ep[k])) / abs(ep[k]) against the measured fixture value plus a stated margin.
  4. Curl identity.
    Check that (∇×b_eff)·∇ψ / max|∂θb_eff,ζ/J| < 1e-10 at the knots.
    It is an algebraic identity, so it is a unit test of the b_eff construction only.
    It is not returned or stored.
  5. Shear surface average.
    Check ⟨J S⟩ = χ'² q′ (keep the PR's analytic check).
  6. solution_state_at.
    A saved step must come back unchanged, and integrating from the left and from the right must meet (keep the PR's two checks).
  7. The off-knot geometry node.
    At ψ_edge, the series-interpolated metric.fs must match a direct rzphi-spline evaluation to the interpolation tolerance.

Drop everything else: shape checks, haskey checks, toggle tests, sum-equals-components checks, and the checks on what main returns.

Risks and open questions

  • ψ_edge accuracy. If the series interpolation of metric.fs at ψ_edge is much less accurate than the rzphi spline evaluation, test 7 will show it. The fallback is to end the radial integral at the last knot and report how much energy that drops. Decide that from data, not up front.
  • The make_metric docstring mislabels its arrays as g^ij·J. Fixing the docstring is a separate one-line DOCS commit and should not be folded into this PR.
  • Size. If M3 grows well past about 300 lines, stop and post the reason here before adding structure.

I know this throws away a lot of code you worked hard on.
The physics and the verification are the valuable part, and they carry over almost entirely.
What changes is the packaging.
I'm happy to go through the plan on a call before you start.

@matt-pharr

Copy link
Copy Markdown
Collaborator

energy_decomposition(ffs) should refuse kinetic results itself. Right now only the deck wrapper run_energy_decomposition skips them, and since #550 a script can pass a kinetic solve result straight in. A check inside energy_decomposition (or decompose_energy) covers both routes.

@matt-pharr

Copy link
Copy Markdown
Collaborator

Merged develop, retained the new geometry helpers in Derived/FluxSurfaceMetrics.jl, and integrated the decomposition stage with the current driver. Adapted its reconstruction call to develop’s positional regularization-width API; decomposition equations are unchanged. Preserved both sides’ docs, example settings, and tests.

jaesun57 and others added 6 commits October 30, 2026 14:51
… ψ from the nearest saved step

Add ForceFreeStates.solution_state_at(ffs, psi), which returns the stored
basis state of a forward run at an arbitrary ψ. A saved step comes back as
stored; otherwise the linear Euler-Lagrange system is integrated to psi from
the nearest saved step that no singular surface separates from it, so the
state does not depend on how densely the sweep saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…enmodes on the (ψ,θ) grid

Add Analysis.EnergyDecomposition, a scripted diagnostic that evaluates δW_p
of the free-boundary DCON eigenmodes of a forward, ideal, single-n solve in
the effective-field form ½∫[|b_eff|²/μ₀ − (K₁+K₂+K₃)|ξ_n|²] and the standard
form ½∫[|b|²/μ₀ − j·(b×ξ*) + (∇·ξ)*(ξ·∇p)], scaled to compare with ep[k].
decompose_plasma_energy returns a NamedTuple of the seven pointwise
integrands, their surface integrals and both totals; plot_energy_decomposition
draws the radial profiles and cumulative δW_p.

All geometry comes from ffs.metric: the contravariant products follow from
the covariant metric by cofactors and B² is the metric field strength. ξ at
each knot comes from ForceFreeStates.solution_state_at. It refuses kinetic,
multi-n, non-forward and fixed-boundary results. On the same solve it
reproduces the former PerturbedEquilibrium stage to 1e-13 in both totals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rangian and the plasma energy

On the Solovev fixture: the standard-form surface integral equals 2/ψ₀²
times the Euler-Lagrange quadratic form on every knot; both totals match
each other and ep[k] within the measured quadrature error; the
(∇×b_eff)·∇ψ identity, the shear surface average, the off-knot metric and
solution_state_at are checked; the plot is drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rinciple references

Add the EnergyDecomposition section to the Analysis page (both forms, the
K split, usage, grid and accuracy, Fortran deviations, the mpsi convergence
table) with its @autodocs block, and cite Bernstein et al. 1958,
Bernstein 1983, Chance et al. 1992 and Boozer 2004 in citations.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…docstring, not the citations page

The citations page lists the papers to cite for GPEC itself. Move Bernstein
et al. 1958, Bernstein 1983, Chance et al. 1992 and Boozer 2004 out of
citations.md and the Analysis page text into a References section of the
EnergyDecomposition module docstring, next to the code that uses them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ismatch of each decomposed eigenmode

decompose_plasma_energy now returns and checks two success measures per
eigenmode: relative_error_effective/relative_error_standard, the mismatch of
each δW_p total against ep[k] (warned above 5 %; it is the ψ quadrature of the
knot grid and flags an mpsi that is too coarse), and curl_residual, the
largest relative (∇×b_eff)·∇ψ over the knots (warned above 1e-8; an algebraic
identity there that checks the b_eff assembly). The tests bound the returned
values instead of recomputing the curl, and the Analysis page lists the checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jaesun57
jaesun57 force-pushed the feature/energy-decomposition branch from f6f5739 to fb2ab23 Compare October 30, 2026 19:30
…tart and normalize its curl check as Fortran does

The ψ integrals started at the first saved ODE step. The forward sweep
stores that step one ODE step after its start, so the integrals dropped
[start, first knot]: 1.25 % of ep for a qlow = 2.5 start (Solovev
fixture, mode 2, mpsi 16 to 64). They now start at
max(psilow, first profile knot), where initialize_el_at_axis! starts.

curl_residual was normalized surface by surface by max|∂θ b_eff,ζ/J|,
which inflates it where the mode is weak. It is now the largest residual
on the knots over the largest (|∂θ b_eff,ζ| + |∂ζ b_eff,θ|)/J, as in
Fortran gpeq_cveri. On the DIII-D-like example with mpsi = 512 that is
2.2e-5 instead of 3.4e-3.

The tests could not see the F′ terms or the g_θζ and g_ψζ terms: the
Solovev fixture has F′ = 0 and uses PEST coordinates, and 5 of 33
injected errors survived. Changes to the tests:
- The fixture now runs in Boozer coordinates.
- A DIII-D-like case checks the standard form against the DCON
  Lagrangian and the curl residual.
- A qlow case checks where the integration starts.
4 of the 5 are now caught. The fifth scales (∇·ξ)*(ξ·∇p), which
vanishes for DCON displacements.

The docs now say what each success check can and cannot detect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… ODE steps as well as the equilibrium knots

The ψ integrals ran over the equilibrium knots only. The knots follow the
equilibrium profiles, not the eigenmode, so near the rational surfaces a
spline through the knot densities misses the structure of ξ. Against ep, the
standard form was off by:
- 0.3–0.5 % on the DIII-D-like example, at every knot grid;
- 35 % for eigenmode 1 of the LAR reversed-shear example at mpsi = 128;
- 5 % on the a10 example at mpsi = 64.

On DIII-D the whole error was knot quadrature: Re(ξ†u₂) at the edge equals
ep, the jumps at the five rational surfaces sum to 5e-6, and ∫L over the
saved ODE steps equals ep to 1e-5.

The nodes are now the ODE start and every saved step, as in Fortran
gpout_recon, plus the knots, where the geometry is exact and the curl check
runs. The standard form now matches ep to:
- 1e-5 on DIII-D;
- 6e-5 on a10 (mpsi 64);
- 1e-4 on TJ (mpsi 256);
- 7e-4 on LAR (mpsi 128).

A saved step needs no re-integration, so the extra nodes are cheap. The
density arrays grow with them; DIII-D goes from 281 to 1444 nodes.

Test changes:
- The fixture runs at mpsi = 32, since between its own 16 knots the splined
  metric moves the totals by 3 %.
- The success-check tolerances tighten to the new measured values.
- A DIII-D check guards the fix.

The effective-field total still differs from the standard-form one by a
grid-independent amount: −0.4 % on DIII-D and −9.5 % for LAR eigenmode 1.
The docs now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants