Skip to content

FFS - REFACTOR - Publish solves as ForceFreeStatesResult; staged main; solve API (refactor plan interface PR) - #393

Merged
matt-pharr merged 14 commits into
developfrom
refactor/forcefreestates-result
Aug 17, 2026
Merged

FFS - REFACTOR - Publish solves as ForceFreeStatesResult; staged main; solve API (refactor plan interface PR)#393
matt-pharr merged 14 commits into
developfrom
refactor/forcefreestates-result

Conversation

@matt-pharr

@matt-pharr matt-pharr commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Third and final PR of the ForceFreeStates modularization sequence (see REFACTOR_PLAN.md, §5–§7). Stacked on #381/#387; auto-retargets to develop when those merge.

Status: implementation complete — all gates green

Landed:

  • Commit (a) — result struct (§5): every solve publishes an immutable ForceFreeStatesResult; presence-equals-capability optional fields (solution::SolutionProfiles, free_boundary, delta_prime, galerkin, diagnostics); universal closure/bpen; fixed-boundary wp; standalone integrator = "galerkin" (additive gal removed, gal_flag deprecated); consumers (PE, writer, SLAYER, IMAS) re-signatured onto the result with warn-and-skip gates.

  • Commit (b) — staged main (§6): main_from_inputs decomposed into stage functions (resolve_mode_space! → … → run_slayer_stage); pure motion, verified by normalized diff and byte-identical HDF5 output.

  • Reconciliation merge: ALL - IMPROVEMENT - Self-describing HDF5 metadata (issue #226, PR B) #364's literature dataset renames grafted onto the re-signatured writer; metadata calls re-scoped; dVdpsi_spline kwarg threaded through.

  • Commit (b2) — unified Δ′ payload (§6A, D14): one delta_prime type (matrix, raw D′, delta_coil, PEST-3 blocks) produced by riccati AND galerkin on shared canonical SingularSurfaces/ paths; gal-fed SLAYER enabled; found and fixed a pre-existing bug where the gal Delta_prime_raw dataset carried duplicated Delta_coil rows.

  • Commit (c) — scripting API (§7): CommonSolve entry point — EulerLagrangeProblem(eq; nn, …) names the problem, solve(prob, Forward()/Riccati()/Galerkin()) picks the formalism (solve(eq, alg; …) sugar retained); PlasmaEquilibrium(path; …); abstract RMPField with lazy source algebra (+, -, scalar * — weights are linear-combination coefficients, not physical amplitudes); pure materialize_forcing_modes ((ffs, source) -> (spectrum, coil_geometry), forcing state owned solely by compute_perturbed_equilibrium); perturbed_equilibrium(ffs, rmp) with the TOML driver routed through the same single forcing path; Scripting API docs page.

  • Commit (d) — cross-formalism file contract (§7A; gpec.h5 schema: follow-ups from the #364 standardization audit #388 items 1+2): closed ξ profiles written from result.solution into the producing formalism's Solutions group with identical names and (mode, solution, psi) axis order (GalerkinIntegration/{psi,q,xi_psi,dxi_psidpsi,xi_s} joins the untouched Forward layout; Match/xi removed — it was the profiles mislabeled as diagnostics); raw gal outer basis demoted to a debug output (Basis/, gated by DebugSettings.gal_basis_output); Tearing/PerSurface/rational_psi/rational_q so gal-fed SLAYER output identifies its surface subset. Remaining gpec.h5 schema: follow-ups from the #364 standardization audit #388 items stay on the issue.

Verification

  • Full test suite: exit 0, 61 testsets, zero failures (includes 133 result-struct, 73 slayer, 70 solve-API, 17 fullruns, HDF5-schema metadata contract).
  • Docs build: exit 0, no missing-docs / broken-link errors.
  • Byte-identity chain: forward decks bit-identical at every commit boundary (latest: 137/137 datasets vs the pre-(d) tree; TOML PE path 207/207 across the forcing-path rewiring); gal Δ′ values bit-identical under their unified names; gal ξ file datasets equal result.solution exactly.
  • Regression harness, all 13 cases, develop vs this branch: 11 cases fully unchanged (every forward/riccati/SLAYER/EFIT/GGJ/kinetic/Solovev quantity). Two accepted deviations, both by design:
    • gal_resistive_diiid — 3 "changed" rows are a cross-formalism read artifact: the additive develop deck writes the riccati BVP's Δ′ at the canonical names, the branch writes the gal Δ′ there (values verified bit-identical gal-vs-gal in a dedicated dual-run). Re-baselined.
    • gal_resistive_pe — 8 PE quantities missing on the branch: galerkin-fed PerturbedEquilibrium warn-skips until the gal free-boundary δW lands (next cycle); the develop values came from the removed additive-gal path.

Verification so far: 82/82 result-struct tests, 357/357 across six targeted files, forward byte-identity and gal-group equivalence vs the pre-merge base; post-merge smoke 82/82 + 66/66 (slayer). Full suite + docs + regression-harness report will be posted before this leaves draft.

Planned follow-up PRs (deliberately NOT in this PR — full specs in REFACTOR_PLAN.md §7B)

This PR establishes the API surface; two follow-on PRs complete the campaign, in this order:

  1. "main = 20 lines" (next) — close out FFS before touching PE:
    • main() becomes a deck interpreter: parse gpec.toml into the same objects and calls a script makes (EulerLagrangeProblem + integrator → solve); main_from_inputs and the stage functions dissolve. The deck is the API, serialized — one path to maintain.
    • Kinetic profiles become an optional attribute of PlasmaEquilibrium (the two-pass grid refinement needs them at equilibrium formation), unlocking kinetic_factor > 0 through solve. Coordinates with GPEC - IMPROVEMENT - Make all input control structs immutable #367 (struct freeze) — lands after it.
    • SLAYER gets an API entry point.
    • The writer serializes the resolved control structs into every output (TOML and API runs alike), so every gpec.h5 is replayable and h5→toml regeneration falls out; closes the current gap where API-run outputs carry no input blob.
  2. Two-stage PerturbedEquilibrium (after FFS is closed)GeneralPE = perturbed_equilibrium(ffs) builds the source-independent response/coupling operators once; force(GeneralPE, fields) materializes sources, applies P, and computes derived quantities. Enables cheap coil-current scans/optimization over one response operator, and pairs with the Δ_mn resonant-coupling work. Two binding requirements already recorded:
    • Response-method multiplicity (Fortran resp_index): the Julia port implements only the energy method (resp_index=0, wt0-based); the four surface-current methods (kapmats variants) were never ported. A typed ResponseMethod selection restores them — and the surface-current port is what unlocks galerkin-fed PE independently of the gal-δW work (in Fortran, gal_flag forces resp_index=1).
    • Sources stay spectrum-currency (reviewer constraint): at most one more RMPField leaf kind ever — a spectrum-literal — with the Make surface input always at psi=1 or on a specifiable surface, then use method of equivalent surface currents to get the currents on the control surface #377 equivalent-surface-currents solve as a utility, not a type; external codes couple by producing control-surface spectra.

Also queued independently: gal free-boundary δW (fills wp/free_boundary for Galerkin, method-0 parity), Δ_mn from delta_coil asymptotics for Riccati+Galerkin resonant coupling, the remaining #388 items, and the ForceFreeStates directory reorg (#394, after #367 and the formatter PR).

Capability matrix (target, D14 — full details in REFACTOR_PLAN.md §9)

Outputs fall into three physics classes: control-surface quantities (plasma boundary — every integrator), class 1 full in-plasma ξ/ξ′ profiles (spectral, full-volume perturbed equilibria), and class 2 rational-surface resonant data quantifying island-opening drive (bpen, and the future delta_mn — the pitch-resonant derivative jump at each rational surface, yielding perturbed current and shielded resonant flux).

Legend: ✅ implemented · 🔜 pending the named follow-on work · ❌ never · — N/A.

Output Forward Riccati Galerkin
wp (control surface) 🔜 gal δW work
free_boundary energies (control surface) 🔜 gal δW work
solution — full ξ/ξ′ profiles (class 1) :el_axis ❌ (class 2 covers resonant coupling, but worth exploring) :gal_native
closure / bpen (class 2; always present, zeros under :ideal) :ideal :ideal (🔜 :matched with STRIDE matching) :ideal or :matched
delta_mn (class 2; resonant-derivative jump) ❌ not planned 🔜 from delta_coil 🔜 from delta_coil
delta_prime — ONE unified type: Δ′ matrix, raw D′, delta_coil, PEST-3 blocks ✅ (PEST-3 blocks persisted; riccati recovers them via pest3_decompose)
raw integrator odet (diagnostics: crit, nzero, edge scan, ca) — (no radial ODE sweep)
kinetic (kinetic_factor>0) Suggested in #375
SLAYER inputs (surfaces + Δ′ matrix) surfaces only ✅ via unified delta_prime

⚠️ NO MERGE WITHOUT THIRD-PARTY HUMAN REVIEW — NON-NEGOTIABLE ⚠️

This PR must not be merged into develop until a human reviewer has approved it. This requirement is absolute.

🤖 Generated with Claude Code

matt-pharr and others added 4 commits August 15, 2026 14:02
…Galerkin integrator

Interface PR, commit (a) of 3 (REFACTOR_PLAN.md §5). One immutable result struct
is now the only thing that crosses the ForceFreeStates module boundary:

- ForceFreeStatesResult carries the mode space, domain, assembly products, and
  per-formalism products as Union{Nothing,T} capability fields; consumers
  (PerturbedEquilibrium, HDF5 writer, SLAYER, write_imas, KineticForces) gate
  with require/require_solution and warn-and-skip instead of erroring.
- SolutionProfiles is the single ξ interchange type (Ξ, Ξ′, Ξ_s always populated):
  forward publishes its dense axis-basis solution; matched Galerkin publishes its
  native-grid solution directly; Riccati publishes none until resonant
  reconstruction lands. Closure is explicit: result.closure (:ideal|:matched) and
  result.bpen (zeros under ideal closure) are unconditional; result.wp carries the
  fixed-boundary plasma energy matrix even when vac_flag=false.
- integrator="galerkin" becomes a standalone formalism (own vacuum wv via the
  compute_scaled_wv extraction from free_run); additive gal_flag is removed and
  deprecated; the four gal decks retoml to the new integrator.
- Deleted: gal_matched_odestate (OdeState shim) and the driver's gal/odet
  arbitration. _chord_solution_at retained as an uncalled helper pending the
  Riccati eigenfunction-reconstruction decision.
- main returns (; ffs, pe, slayer). HDF5 dataset paths unchanged; forward and
  riccati outputs byte-identical to the stack base, Galerkin-group datasets
  identical between the old additive and new standalone paths.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Base automatically changed from refactor/riccati-unification to develop August 15, 2026 19:38
@matt-pharr
matt-pharr marked this pull request as ready for review August 16, 2026 03:26
@matt-pharr
matt-pharr requested review from d-burg, jhalpern30 and logan-nc and removed request for jhalpern30 August 16, 2026 03:26
…e in the capability matrix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@matt-pharr

Copy link
Copy Markdown
Collaborator Author

@logan-nc This PR is now done and ready for review. It is a hefty PR but the reorganization is worth it IMO. This results in the integrators being abstracted behind a new x = EulerLagrangeProblem(), solve(x, integration method) problem interface that is julia-y (what is the julia equivalent of pythonic?). It is most of the way there to getting the actual main function to be trivially small. The next PR will actually get us all the way there to the main() function being tiny and super readable and to the point where the toml file will be 1:1 with args in the different module functions. Once we get there, I can make a jupyter notebook example of how to use the new syntax in a way that is more usable than main() from toml.

PS I do solemnly swear that my eyes touched every line of code change :)

@matt-pharr

Copy link
Copy Markdown
Collaborator Author

Merged develop (now including #367 + #395). Resolution was mechanical: deprecated-key/hook unions and taking #367's removal of the reform_eq_with_psilim stub inside the staged function that had inherited it. The struct freeze required zero changes here — this branch already builds every control struct exactly once (the solve path was designed construct-once). Verified: 70/70 solve-API, 133/133 result-struct, 17/17 fullruns, 279/279 equil; output files byte-identical to pre-merge except the new Equilibrium/psihigh_resolved dataset that #367 itself introduces.

🤖 Generated with Claude Code

@matt-pharr matt-pharr self-assigned this Aug 17, 2026
@d-burg

d-burg commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

One consequence of the unified delta_prime payload that I think is uncovered.

Galerkin's Δ′ moves from SingularSurfaces/GalerkinDeltaPrime/pest3_Delta onto
SingularSurfaces/Delta_prime_matrix — the path the BVP Δ′ already uses, in the same PEST-3
convention. Nothing in the output then says which formalism produced it. hdf5-conventions.md names
the subgroup as exactly that record ("Provenance is recorded in the subgroup name"), and forbids the
obvious workaround ("Never echo TOML flags or control-struct values into any other group"), so the
writer can't stamp integrator = "galerkin" beside the matrix either.

Concretely: flip a deck's integrator and a BVP-pinned golden value is silently compared against a
Galerkin result. Before this PR that misconfiguration failed loudly as a missing path.

In #397 I pin the deck's declared integrator/gal_flag from Input/gpec_toml_raw, which closes it
harness-side — but only for TOML-driven runs. solve(prob, Galerkin()) writes no input blob at all
today (your follow-up list notes this), so an API-produced gpec.h5 carries a Δ′ with no recoverable
provenance at all.

Cheapest fix I can see: use the backend slot the schema already reserves —
ForceFreeStates/Solutions/RiccatiIntegration/ alongside ForwardIntegration/GalerkinIntegration.
Which subgroup is present is derived output, not an echoed control flag, so it identifies the
formalism without breaking the inputs rule. Alternatives: keep a formalism-named Δ′ subgroup, or pull
the resolved-control serialization forward from the follow-up PR and update the conventions doc.

Happy to be told this is handled somewhere I've missed.

Separately: this merges cleanly into both my branches and isn't a silent semantic break — on
gal_resistive_diiid my diff touches only label = lines and yours only h5path =. I trial-merged
and ran runtests_decomposition_invariance.jl against it: 11 pass, 1 expected @test_broken, 0 fail;
only conflict is an additive test/runtests.jl include list. The et[1] invariance @test_broken is
still broken under this PR, i.e. exact decomposition invariance isn't restored.

@jhalpern30 jhalpern30 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.

Gave this a super quick skim since I see this more along the lines of a hackathon where the goal is just to get this working/intergrated asap to avoid merge conflicts and it can be cleaned up later

Just made a few comments, overall the changes look great

- `wall_settings::Vacuum.WallShapeSettings` - Wall shape settings for vacuum calculations
"""
@kwdef mutable struct ForceFreeStatesInternal
@kwdef mutable struct ForceFreeStatesInternal <: ModeSpace

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.

Just wanted to flag that this is a bandaid on something I didn't get to during the hackathon - the FFSInternal struct is one of the worst remaining in terms of entries getting changed throughout the code and its almost treated like a global. One of Claude's suggestions was to break it up into a ModeGeometry struct (so ModeSpace here) for the first third-ish of the data, SingularSurfs struct for the middle section, and IntegrationLimits for the last bits. No need to do that in this PR, but wanted to mention

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Hmm I do really like this proposal. The ModeSpace thing was the easiest way I found to allow multiple structs that have these mode bounds in them to be accepted by functions that need them. This is probably better. I will do this once you add your commits from your FourFitVars restructure here. Please let me know when that is done on slack @jhalpern30

point clouds the free-boundary result carries to HDF5. `wv` aliases `vac.wv`, which is
scaled in place.
"""
function compute_scaled_wv(ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, intr::ForceFreeStatesInternal)

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.

Was initially going to mention that we should just move the singfac scaling into Vacuum. But upon second look, the wvspline in the edge has a seemingly legitimate reason to want the unscaled matrix so I will recant my request but comment as an FYI

4. Forward DFT back to m-space
"""
function set_perturbation_data!(kf_intr::KineticForcesInternal, pe_state, ffs_intr,
function set_perturbation_data!(kf_intr::KineticForcesInternal, pe_state, ffs,

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 believe these are going to create type-instabilities - can this be avoided?

response_vector::Vector{ComplexF64},
flux_matrix::Matrix{ComplexF64},
ForceFreeStates_results::OdeState,
ForceFreeStates_results::SolutionProfiles,

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.

So we have ffs which is a ForceFreeStatesResult instance, and we have ForceFreeStates_results which is a SolutionProfiles instance? lol...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes this could stand to be cleaned up before merging. Will do that.

@matt-pharr

Copy link
Copy Markdown
Collaborator Author

@d-burg noted on the h5 file reflecting the Delta' provenance. Do you think we should move Delta' into the per-integrator folder?

One of the long term end goals here is to eliminate the need for the h5 file at all and have the h5 only be produced if requested or if running via toml. That way nothing is automatically saved to disk and users can script with GPEC as they please, and only save what they need. There should be no need to run multiple integrators in a single GPEC run at the end, so at that point we could collapse everything in the hdf5 and just have a single field that says what integrator was used.

…s, rename ForceFreeStates_results to solution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@matt-pharr
matt-pharr merged commit bb59565 into develop Aug 17, 2026
4 checks passed
@matt-pharr
matt-pharr deleted the refactor/forcefreestates-result branch August 17, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants