From e3cac1a88f993d950102b8a992dd3434819aa3fc Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Tue, 20 Oct 2026 10:34:18 +0200 Subject: [PATCH 01/60] Test - TEST - Add deck/API parity tests with the known drifts marked broken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run main and the scripting API on the same Solovev deck for Galerkin, Riccati Δ′, hdf5 file forcing, the analytic two-pass grid and gal_match_flag matching. Mark the known drifts @test_broken: file-forcing normalization, the analytic two-pass grid, the cut solution implied under gal_match_flag, and the fixed kinetic source. Co-Authored-By: Claude Opus 5.5 --- test/runtests_solve_api.jl | 99 +++++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 2 deletions(-) diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index 18d4e6082..618ec8cd0 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -1,4 +1,5 @@ using TOML +using HDF5 # The scripting API: `PlasmaEquilibrium` / `solve(eq, alg)` / `perturbed_equilibrium` must run the # same pipeline the TOML driver does. Every assertion is anchored on the coarse Solovev fixture @@ -21,13 +22,14 @@ using TOML ffs_kwargs = Dict(Symbol(k) => v for (k, v) in deck["ForceFreeStates"] if !(k in ("integrator", "nn_low", "nn_high"))) - # Copy the fixture deck into `dir` and apply `[ForceFreeStates]` overrides / extra sections. - function _stage_deck(dir; ffs_overrides=Dict{String,Any}(), extra_sections=Dict{String,Any}()) + # Copy the fixture deck into `dir` and apply `[Equilibrium]` / `[ForceFreeStates]` overrides and extra sections. + function _stage_deck(dir; eq_overrides=Dict{String,Any}(), ffs_overrides=Dict{String,Any}(), extra_sections=Dict{String,Any}()) for name in readdir(template) cp(joinpath(template, name), joinpath(dir, name)) end toml_path = joinpath(dir, "gpec.toml") inputs = TOML.parsefile(toml_path) + merge!(inputs["Equilibrium"], eq_overrides) merge!(inputs["ForceFreeStates"], ffs_overrides) merge!(inputs, extra_sections) open(io -> TOML.print(io, inputs), toml_path, "w") @@ -73,6 +75,12 @@ using TOML # Chunking is a decomposition of the same problem, so Δ′ tracks the TOML run. @test size(reference.delta_prime.matrix) == size(api.delta_prime.matrix) @test api.delta_prime.matrix ≈ reference.delta_prime.matrix rtol = 1e-6 + + # With the deck's own (auto) chunking the two runs are the same computation. + same = solve(prob, Riccati()) + @test same.delta_prime.matrix ≈ reference.delta_prime.matrix rtol = 1e-12 + @test same.delta_prime.raw ≈ reference.delta_prime.raw rtol = 1e-12 + @test same.delta_prime.coil ≈ reference.delta_prime.coil rtol = 1e-12 end end @@ -90,6 +98,11 @@ using TOML @test size(dp.matrix) == (msing, msing) @test dp.matrix ≈ FFS.pest3_decompose(dp.raw).Δ @test dp.A !== nothing # the parity blocks the galerkin solve persists + + reference = GPEC.main([_stage_deck(dir; ffs_overrides=Dict{String,Any}("integrator" => "galerkin", "gal_nx" => 32))]).ffs + @test reference.galerkin.msing == msing + @test dp.matrix ≈ reference.delta_prime.matrix rtol = 1e-12 + @test dp.raw ≈ reference.delta_prime.raw rtol = 1e-12 end end @@ -121,6 +134,10 @@ using TOML @test size(pe.C_delta_prime) == size(reference.C_delta_prime) @test !isempty(pe.resonant_area_weighted_field) + # Known drift: the deck injects the raw file modes, skipping the normalization conversion the API applies. + @test_broken pe.forcing_b ≈ reference.forcing_b rtol = 1e-10 + @test_broken pe.resonant_area_weighted_field ≈ reference.resonant_area_weighted_field rtol = 1e-10 + # The amplitude-linear outputs are compared through the driver's own input path: # the driver hands the stage the modes it snapshotted before the solve, whereas a # fresh RMPField re-reads the file and re-runs the normalization conversion. @@ -282,4 +299,82 @@ using TOML @test matched.solution !== nothing && matched.solution.basis === :gal_native end end + + # Each workflow is run from a deck through `main` and from the same inputs through the API. + # A known drift is `@test_broken`, so the change that removes it surfaces as an unexpected pass. + @testset "deck and API parity" begin + @testset "hdf5 file forcing" begin + pe_kwargs = (compute_response=true, compute_singular_coupling=true, verbose=false, write_outputs_to_HDF5=false) + mktempdir() do dir + modes = GPEC.ForcingTerms.ForcingMode[] + GPEC.ForcingTerms.load_forcing_data!(modes, joinpath(@__DIR__, "..", "examples", "Solovev_ideal_example"), "forcing.dat", "ascii", false) + h5open(f -> GPEC.ForcingTerms.save_forcing_to_h5(modes, f), joinpath(dir, "forcing.h5"), "w") + _stage_deck(dir; + extra_sections=Dict{String,Any}( + "ForcingTerms" => Dict{String,Any}("forcing_data_file" => "forcing.h5", "forcing_data_format" => "hdf5"), + "PerturbedEquilibrium" => Dict{String,Any}(String(k) => v for (k, v) in pairs(pe_kwargs)))) + reference = GPEC.main([dir]) + pe = perturbed_equilibrium(reference.ffs, RMPField(joinpath(dir, "forcing.h5")); pe_kwargs...) + + @test pe.permeability ≈ reference.pe.permeability rtol = 1e-10 + # Known drift: the deck injects the raw file modes, skipping the normalization conversion the API applies. + @test_broken pe.forcing_b ≈ reference.pe.forcing_b rtol = 1e-10 + end + end + + @testset "analytic equilibrium on the two-pass auto grid" begin + # A coarse psi_accuracy and no local-stability scan keep the 1024-interval pass-1 grid cheap. + eq_overrides = Dict{String,Any}("grid_type" => "auto", "mpsi" => 0, "psi_accuracy" => 3e-2) + ffs_overrides = Dict{String,Any}("local_stability_flag" => false, "vac_flag" => false) + mktempdir() do dir + reference = GPEC.main([_stage_deck(dir; eq_overrides=eq_overrides, ffs_overrides=ffs_overrides)]).ffs + pass1 = GPEC.Equilibrium.setup_equilibrium( + GPEC.Equilibrium.EquilibriumConfig(merge(deck["Equilibrium"], eq_overrides), template), + GPEC.Equilibrium.SolovevConfig(deck["SOL_INPUT"]) + ) + @test length(reference.equil.rzphi_xs) < length(pass1.rzphi_xs) # the deck re-formed on a refined grid + + kwargs = merge(ffs_kwargs, Dict(Symbol(k) => v for (k, v) in ffs_overrides)) + api = solve(pass1, Forward(); nn=1, wall=wall, dir_path=dir, kwargs...) + # Known drift: the equilibrium does not store its analytic config, so `solve` cannot re-form it. + @test_broken api.equil.rzphi_xs == reference.equil.rzphi_xs + end + end + + @testset "deck matching under gal_match_flag" begin + layer = (eta=1e-7, rho=1e-7, rotation=1.0) + mktempdir() do dir + gal = solve(equil, Galerkin(; nx=32, rpec_flag=true, cut_solution=true); nn=1, wall=wall, dir_path=dir, ffs_kwargs...) + api = solve(MatchProblem(gal; layer...), GGJModel(; solver=:ray)) + n = length(gal.surfaces) + reference = GPEC.main([_stage_deck(dir; + ffs_overrides=Dict{String,Any}( + "integrator" => "galerkin", "gal_nx" => 32, "gal_rpec_flag" => true, "gal_match_flag" => true, + "gal_inner_solver" => "ray", "gal_eta" => fill(layer.eta, n), "gal_rho" => fill(layer.rho, n), + "gal_rotation" => fill(layer.rotation, n)))]).ffs + + @test reference.closure === api.closure === :matched + @test api.bpen ≈ reference.bpen rtol = 1e-12 + @test api.match.xi ≈ reference.match.xi rtol = 1e-12 + @test length(api.match.inner_xi) == length(reference.match.inner_xi) == n + @test all(isapprox.(api.match.inner_xi, reference.match.inner_xi; rtol=1e-12)) + + uncut = solve(equil, Galerkin(; nx=32, rpec_flag=true); nn=1, wall=wall, dir_path=dir, ffs_kwargs...) + matched = solve(MatchProblem(uncut; layer...), GGJModel(; solver=:ray)) + # Known drift: gal_match_flag implies the cut solution on the deck; the API keeps it only when asked. + @test_broken length(matched.match.inner_xi) == length(reference.match.inner_xi) + end + end + + @testset "fixed kinetic source" begin + mktempdir() do dir + reference = GPEC.main([_stage_deck(dir; ffs_overrides=Dict{String,Any}("kinetic_factor" => 1e-9))]).ffs + @test reference.free_boundary !== nothing + + kwargs = merge(ffs_kwargs, Dict(:kinetic_factor => 1e-9)) + # Known drift: `solve` rejects every kinetic run, although the fixed source needs no profiles. + @test_broken solve(equil, Forward(); nn=1, wall=wall, dir_path=dir, kwargs...).free_boundary.et[1] ≈ reference.free_boundary.et[1] rtol = 1e-12 + end + end + end end From 39ce66381abeff552902aa3e6aa29630672e8d76 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Tue, 20 Oct 2026 14:48:05 +0200 Subject: [PATCH 02/60] Test - TEST - Check the two-pass re-form by grid identity, not knot count Co-Authored-By: Claude Opus 5.5 --- test/runtests_solve_api.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index 618ec8cd0..c61d86089 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -332,7 +332,7 @@ using HDF5 GPEC.Equilibrium.EquilibriumConfig(merge(deck["Equilibrium"], eq_overrides), template), GPEC.Equilibrium.SolovevConfig(deck["SOL_INPUT"]) ) - @test length(reference.equil.rzphi_xs) < length(pass1.rzphi_xs) # the deck re-formed on a refined grid + @test reference.equil.rzphi_xs != pass1.rzphi_xs # the deck re-formed on a refined grid kwargs = merge(ffs_kwargs, Dict(Symbol(k) => v for (k, v) in ffs_overrides)) api = solve(pass1, Forward(); nn=1, wall=wall, dir_path=dir, kwargs...) From b8ff18a7447d11dd96a12002dbf9d9ffe5fef0fd Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Wed, 21 Oct 2026 11:07:16 +0200 Subject: [PATCH 03/60] Repo - API! - Time stages in main and drop the runtimes keyword Remove the runtimes keyword from run_force_free_states, perturbed_equilibrium, compute_perturbed_equilibrium, run_perturbed_equilibrium and run_slayer_stage. main times every stage from outside: PerturbedEquilibrium, KineticForces and ErrorFields each get one banner and one timer through _timed_stage, so they are no longer timed twice. Info/Runtimes drops the galerkin and forcing_terms sub-stage entries, which only a stage could measure from inside. Co-Authored-By: Claude Opus 5.5 --- src/GeneralizedPerturbedEquilibrium.jl | 123 ++++++++---------- src/HDF5Schema.jl | 8 +- .../PerturbedEquilibrium.jl | 9 +- test/runtests_error_fields.jl | 2 +- 4 files changed, 55 insertions(+), 87 deletions(-) diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 7fc113af6..e8cf792ed 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -259,7 +259,7 @@ function main_from_inputs( metric, mats, layer_overlap = prepare_force_free_states!(intr, ctrl, equil, kf_ctrl, kinetic_profiles; species=kf_species, overlap_inputs=_overlap_slayer_inputs(inputs, intr.dir_path)) - ffs_result = run_force_free_states(ctrl, equil, mats, intr, metric; runtimes=runtimes) + ffs_result = run_force_free_states(ctrl, equil, mats, intr, metric) if ctrl.write_outputs_to_HDF5 write_outputs_to_HDF5( @@ -279,56 +279,44 @@ function main_from_inputs( push!(runtimes, "force_free_states" => ffs_dt) @info "Force-Free States completed in $(@sprintf("%.3f", ffs_dt)) s" - # Early exit if user only requested force-free states (SLAYER still runs). - if ctrl.force_termination - slayer_result = run_slayer_stage(ffs_result, inputs, nothing; runtimes=runtimes) - # A non-nothing result means the Tearing/ group was appended inside the guarded stage. - slayer_result === nothing || (written_h5 = ctrl.HDF5_filename) - total_dt = time() - total_start - push!(runtimes, "total" => total_dt) - if written_h5 !== nothing - _write_runtimes!(joinpath(intr.dir_path, written_h5), runtimes) - end - @info "\n$_BANNER\n GPEC completed successfully in $(@sprintf("%.3f", total_dt)) s\n$_BANNER" - return (; ffs=ffs_result, pe=nothing, slayer=slayer_result, coil_sensitivities=nothing, monte_carlo=nothing, locking_risk=nothing, efc_couplings=nothing) - end + # A force_termination run stops after ForceFreeStates; only SLAYER still runs off the result. + run_response = !ctrl.force_termination # PE (and SLAYER, which appends after it) writes to PE's own output filename when set, # falling back to the ForceFreeStates file. - pe_file = if "PerturbedEquilibrium" in keys(inputs) + pe_file = if run_response && "PerturbedEquilibrium" in keys(inputs) pe_out = get(inputs["PerturbedEquilibrium"], "output_filename", "") isempty(pe_out) ? ctrl.HDF5_filename : pe_out else ctrl.HDF5_filename end - pe_start = time() - pe_state = run_perturbed_equilibrium(ffs_result, inputs, forcing_modes_snapshot, preloaded_coil_sets; runtimes=runtimes) - # Record only when the stage ran; an absent [PerturbedEquilibrium] section would otherwise - # stamp a ~0 s entry for work that never happened. - if "PerturbedEquilibrium" in keys(inputs) - push!(runtimes, "perturbed_equilibrium" => time() - pe_start) + pe_state = nothing + if run_response && "PerturbedEquilibrium" in keys(inputs) + pe_state = _timed_stage(runtimes, "perturbed_equilibrium", "Perturbed Equilibrium") do + run_perturbed_equilibrium(ffs_result, inputs, forcing_modes_snapshot, preloaded_coil_sets) + end # Mirrors the write gate inside `perturbed_equilibrium` (write flag defaults to true). if get(inputs["PerturbedEquilibrium"], "write_outputs_to_HDF5", true) written_h5 = pe_file end end - kf_start = time() - run_kinetic_forces(inputs, ffs_result, pe_state, kf_ctrl, kinetic_profiles, kf_species) - if "KineticForces" in keys(inputs) - push!(runtimes, "kinetic_forces" => time() - kf_start) + if run_response && "KineticForces" in keys(inputs) + _timed_stage(runtimes, "kinetic_forces", "KineticForces") do + run_kinetic_forces(inputs, ffs_result, pe_state, kf_ctrl, kinetic_profiles, kf_species) + end # Mirrors the write gate inside `run_kinetic_forces` (needs a PE state to contract against). if pe_state !== nothing && kf_ctrl.write_outputs_to_HDF5 written_h5 = kf_ctrl.HDF5_filename end end - ef_start = time() - error_fields = run_error_fields(inputs, ffs_result, pe_state, preloaded_coil_sets, kf_ctrl, kinetic_profiles) - coil_sensitivities, monte_carlo, locking_risk, efc_couplings = error_fields === nothing ? (nothing, nothing, nothing, nothing) : error_fields - if "ErrorFields" in keys(inputs) - push!(runtimes, "error_fields" => time() - ef_start) + coil_sensitivities, monte_carlo, locking_risk, efc_couplings = nothing, nothing, nothing, nothing + if run_response && "ErrorFields" in keys(inputs) + coil_sensitivities, monte_carlo, locking_risk, efc_couplings = _timed_stage(runtimes, "error_fields", "ErrorFields") do + run_error_fields(inputs, ffs_result, pe_state, preloaded_coil_sets, kf_ctrl, kinetic_profiles) + end # Mirrors the write gate inside `run_error_fields` (write flag defaults to true, filename # falls back to the ForceFreeStates file since the result carries this same control). ef_raw = inputs["ErrorFields"] @@ -338,9 +326,15 @@ function main_from_inputs( end end - slayer_result = run_slayer_stage(ffs_result, inputs, pe_file; runtimes=runtimes) + tearing_start = time() + slayer_result = run_slayer_stage(ffs_result, inputs, pe_file) + tearing_dt = time() - tearing_start # A non-nothing result means the Tearing/ group was appended inside the guarded stage. - slayer_result === nothing || (written_h5 = pe_file) + if slayer_result !== nothing + written_h5 = pe_file + push!(runtimes, "tearing" => tearing_dt) + @info "SLAYER completed in $(@sprintf("%.3f", tearing_dt)) s" + end # ---------------------------------------------------------------- # Done @@ -365,6 +359,22 @@ Compact label for the resolved toroidal mode range, `"1"` for a single `n` and ` """ _mode_range_label(intr::ForceFreeStatesInternal) = intr.npert == 1 ? "$(intr.nlow)" : "$(intr.nlow):$(intr.nhigh)" +""" + _timed_stage(f, runtimes, key, label) -> f() + +Run the pipeline stage `f` under a `label` section banner, log its wall-clock time and record +it in `runtimes` as `key => seconds` for the `Info/Runtimes` record. +""" +function _timed_stage(f, runtimes::Vector{Pair{String,Float64}}, key::String, label::String) + @info "\n $label\n$_SECTION" + start = time() + out = f() + dt = time() - start + push!(runtimes, key => dt) + @info "$label completed in $(@sprintf("%.3f", dt)) s" + return out +end + """ resolve_mode_space!(intr, ctrl) -> intr @@ -738,16 +748,14 @@ end Run the formalism selected by `ctrl.integrator` — the standalone Galerkin solve, or the Euler-Lagrange sweep with its free-boundary energies and Δ′ BVP — and publish its products as a -`ForceFreeStatesResult`. A `runtimes` collector, when given, receives the Galerkin solve's -wall-clock seconds as a `"galerkin" => dt` pair for the `Info/Runtimes` record. +`ForceFreeStatesResult`. """ function run_force_free_states( ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, mats, intr::ForceFreeStatesInternal, - metric; - runtimes::Union{Nothing,Vector{Pair{String,Float64}}}=nothing + metric ) nstring = _mode_range_label(intr) @@ -765,7 +773,6 @@ function run_force_free_states( wv = ctrl.vac_flag ? first(ForceFreeStates.compute_scaled_wv(ctrl, equil, intr)) : nothing gal_data, gal_dp = galerkin_solve(ctrl, equil, mats, intr; wv=wv) gal_dt = time() - gal_start - runtimes === nothing || push!(runtimes, "galerkin" => gal_dt) @info "Galerkin solve completed in $(@sprintf("%.3f", gal_dt)) s" else # Integrate Euler-Lagrange Equation @@ -972,15 +979,8 @@ function run_perturbed_equilibrium( result::ForceFreeStatesResult, inputs::Dict{String,Any}, forcing_modes_snapshot::Union{Nothing,Vector{ForcingTerms.ForcingMode}}, - preloaded_coil_sets::Union{Nothing,Vector{ForcingTerms.CoilSet}}; - runtimes::Union{Nothing,Vector{Pair{String,Float64}}}=nothing + preloaded_coil_sets::Union{Nothing,Vector{ForcingTerms.CoilSet}} ) - # ---------------------------------------------------------------- - # Perturbed Equilibrium - # ---------------------------------------------------------------- - @info "\n Perturbed Equilibrium\n$_SECTION" - pe_start = time() - # Check for PerturbedEquilibrium section and run if present pe_state = nothing if "PerturbedEquilibrium" in keys(inputs) @@ -989,17 +989,15 @@ function run_perturbed_equilibrium( # The deck's forcing block is an unscaled RMPField, so the TOML path and the # scripting API share one stage. pe_state = perturbed_equilibrium(result, ForcingTerms.RMPField(ft_ctrl); - forcing_modes=forcing_modes_snapshot, coil_sets=preloaded_coil_sets, runtimes=runtimes, + forcing_modes=forcing_modes_snapshot, coil_sets=preloaded_coil_sets, (Symbol(k) => v for (k, v) in inputs["PerturbedEquilibrium"])...) end - @info "Perturbed Equilibrium completed in $(@sprintf("%.3f", time() - pe_start)) s" - return pe_state end """ - perturbed_equilibrium(ffs, rmp; forcing_modes=nothing, coil_sets=nothing, runtimes=nothing, kwargs...) -> PerturbedEquilibriumState + perturbed_equilibrium(ffs, rmp; forcing_modes=nothing, coil_sets=nothing, kwargs...) -> PerturbedEquilibriumState Compute the plasma response to the external field `rmp` on top of a force-free-states solve `ffs`, and write the perturbed-equilibrium outputs. `rmp` is an [`RMPField`](@ref); keyword @@ -1010,8 +1008,7 @@ step warns and is skipped rather than erroring, so a Riccati- or Galerkin-fed re flows through. `forcing_modes` injects already-loaded modes (the gpec.h5 replay path) and `coil_sets` -already-built coil geometry, both bypassing the corresponding read. A `runtimes` collector, -when given, receives the forcing-mode materialization's wall-clock seconds as `"forcing_terms" => dt`. +already-built coil geometry, both bypassing the corresponding read. ```julia pe = perturbed_equilibrium(ffs, RMPField("forcing.dat")) @@ -1022,7 +1019,6 @@ function perturbed_equilibrium( rmp::ForcingTerms.RMPField; forcing_modes::Union{Nothing,Vector{ForcingTerms.ForcingMode}}=nothing, coil_sets::Union{Nothing,Vector{ForcingTerms.CoilSet}}=nothing, - runtimes::Union{Nothing,Vector{Pair{String,Float64}}}=nothing, kwargs... ) ctrl = ffs.control @@ -1044,7 +1040,7 @@ function perturbed_equilibrium( pe_intr.coil_sets = copy(coil_sets) end - pe_state = PerturbedEquilibrium.compute_perturbed_equilibrium(ffs, rmp, pe_ctrl, pe_intr; runtimes=runtimes) + pe_state = PerturbedEquilibrium.compute_perturbed_equilibrium(ffs, rmp, pe_ctrl, pe_intr) # Write perturbed equilibrium outputs to same HDF5 file if pe_ctrl.write_outputs_to_HDF5 @@ -1084,9 +1080,6 @@ function run_kinetic_forces( # ---------------------------------------------------------------- ("KineticForces" in keys(inputs)) || return nothing - @info "\n KineticForces\n$_SECTION" - kf_start = time() - # Standalone NTV torque diagnostics need a PE state (they contract kinetic operators # against ξ). The self-consistent kinetic_source="calculated" path produces none — skip. if pe_state === nothing @@ -1133,8 +1126,6 @@ function run_kinetic_forces( end end - @info "KineticForces completed in $(@sprintf("%.3f", time() - kf_start)) s" - return nothing end @@ -1179,9 +1170,6 @@ function run_error_fields( ) ("ErrorFields" in keys(inputs)) || return nothing - @info "\n ErrorFields\n$_SECTION" - ef_start = time() - # [ErrorFields.MonteCarlo], [ErrorFields.Risk] and [ErrorFields.scenario] are nested tables, # excluded from the control-struct splat. ef_raw = inputs["ErrorFields"] @@ -1274,8 +1262,6 @@ function run_error_fields( @info "Results written to $output_file" end - @info "ErrorFields completed in $(@sprintf("%.3f", time() - ef_start)) s" - return sens, monte_carlo, risk, couplings end @@ -1431,12 +1417,9 @@ end Run the SLAYER tearing-mode analysis off the force-free-states `result`, appending its group to `pe_file` (or the force-free-states output when PE did not run). Needs only the result, so it -runs in both the `force_termination = true` path and the full pipeline. A `runtimes` collector, -when given, receives the solver's wall-clock seconds as a `"tearing" => dt` pair for the -`Info/Runtimes` record. +runs in both the `force_termination = true` path and the full pipeline. """ -function run_slayer_stage(result::ForceFreeStatesResult, inputs::Dict{String,Any}, pe_file::Union{String,Nothing}; - runtimes::Union{Nothing,Vector{Pair{String,Float64}}}=nothing) +function run_slayer_stage(result::ForceFreeStatesResult, inputs::Dict{String,Any}, pe_file::Union{String,Nothing}) ("SLAYER" in keys(inputs)) || return nothing # SLAYER is a post-processing diagnostic. A failure here must not # discard the equilibrium / stability / PE results already computed, @@ -1446,12 +1429,8 @@ function run_slayer_stage(result::ForceFreeStatesResult, inputs::Dict{String,Any slayer_ctrl = Runner.slayer_control_from_toml(inputs["SLAYER"]) slayer_ctrl.enabled || return nothing @info "\n SLAYER\n$_SECTION" - slayer_start = time() model = Runner._build_inner_model(slayer_ctrl.inner_model) slayer_result = solve(Runner.TearingProblem(result, slayer_ctrl), model) - slayer_dt = time() - slayer_start - runtimes === nothing || push!(runtimes, "tearing" => slayer_dt) - @info "SLAYER completed in $(@sprintf("%.3f", slayer_dt)) s" h5_filename = pe_file === nothing ? result.control.HDF5_filename : pe_file h5_path = joinpath(result.dir_path, h5_filename) # Append the Tearing/ group; create the file if no prior stage wrote diff --git a/src/HDF5Schema.jl b/src/HDF5Schema.jl index 7b0f6b5c9..e32b94b9a 100644 --- a/src/HDF5Schema.jl +++ b/src/HDF5Schema.jl @@ -281,18 +281,12 @@ const MAIN_H5_ANNOTATIONS = [ # Stages that did not run leave their path absent and are skipped by `annotate!`. const RUNTIME_H5_ANNOTATIONS = [ "Info/Runtimes/equilibrium" => (; long_name="wall-clock time of the Equilibrium construction stage", units="s"), - "Info/Runtimes/galerkin" => (; long_name="wall-clock time of the Galerkin outer-region solve (measured inside force_free_states, not additional to it)", units="s"), "Info/Runtimes/force_free_states" => (; long_name="wall-clock time of the ForceFreeStates stability stage", units="s"), "Info/Runtimes/tearing" => (; long_name="wall-clock time of the Tearing stage (SLAYER inner-layer solve)", units="s"), - "Info/Runtimes/forcing_terms" => (; - long_name="wall-clock time of the ForcingTerms forcing-mode materialization, incl. coil Biot-Savart onto the plasma surface (measured inside perturbed_equilibrium, not additional to it)", - units="s" - ), "Info/Runtimes/perturbed_equilibrium" => (; long_name="wall-clock time of the PerturbedEquilibrium stage", units="s"), "Info/Runtimes/kinetic_forces" => (; long_name="wall-clock time of the KineticForces (NTV) stage", units="s"), "Info/Runtimes/error_fields" => (; long_name="wall-clock time of the ErrorFields stage (coil sensitivities, Monte Carlo, locking risk)", units="s"), - "Info/Runtimes/total" => - (; long_name="wall-clock time of the full GPEC run (the per-stage values nest rather than partition it, so they do not sum to this)", units="s") + "Info/Runtimes/total" => (; long_name="wall-clock time of the full GPEC run, including the work between stages", units="s") ] # Euler-Lagrange operator matrices: same wording per letter, Ideal/ and Kinetic/ variants. diff --git a/src/PerturbedEquilibrium/PerturbedEquilibrium.jl b/src/PerturbedEquilibrium/PerturbedEquilibrium.jl index fa84db745..e29b80bb4 100644 --- a/src/PerturbedEquilibrium/PerturbedEquilibrium.jl +++ b/src/PerturbedEquilibrium/PerturbedEquilibrium.jl @@ -40,7 +40,7 @@ export write_outputs_to_HDF5 export ResonantCoupling, DominantCoupling, dominant_coupling, rootarea_field, coupling_overlap, check_mode_basis, CORE_PSI_LOW, CORE_PSI_HIGH """ - compute_perturbed_equilibrium(ffs, forcing, ctrl, intr; runtimes=nothing)::PerturbedEquilibriumState + compute_perturbed_equilibrium(ffs, forcing, ctrl, intr)::PerturbedEquilibriumState Main entry point for perturbed equilibrium calculations. @@ -56,8 +56,6 @@ the step warns and is skipped instead of erroring. - `forcing`: the external-field description — a `ForcingTermsControl` (TOML path) or any [`ForcingTerms.RMPField`](@ref) - `ctrl`: Control parameters from [PerturbedEquilibrium] section - `intr`: Internal state variables - - `runtimes`: optional collector; receives `"forcing_terms" => dt` for the forcing-mode - materialization (coil Biot-Savart onto the plasma surface), when that step runs ## Returns @@ -67,8 +65,7 @@ function compute_perturbed_equilibrium( ffs::ForceFreeStatesResult, forcing::Union{ForcingTerms.ForcingTermsControl,ForcingTerms.RMPField}, ctrl::PerturbedEquilibriumControl, - intr::PerturbedEquilibriumInternal; - runtimes::Union{Nothing,Vector{Pair{String,Float64}}}=nothing + intr::PerturbedEquilibriumInternal )::PerturbedEquilibriumState state = PerturbedEquilibriumState() @@ -87,10 +84,8 @@ function compute_perturbed_equilibrium( # The one place forcing state lands on `intr`: injected (replay) modes short-circuit the # materialization entirely, so they are never re-converted or re-weighted. if isempty(intr.forcing_modes) - ft_start = time() modes, coil_sets = materialize_forcing_modes(ffs, forcing; dir_path=intr.dir_path, preloaded_coil_sets=intr.coil_sets, verbose=ctrl.verbose) - runtimes === nothing || push!(runtimes, "forcing_terms" => time() - ft_start) intr.forcing_modes = modes isempty(coil_sets) || (intr.coil_sets = coil_sets) end diff --git a/test/runtests_error_fields.jl b/test/runtests_error_fields.jl index ccd9e098e..b9b143d2a 100644 --- a/test/runtests_error_fields.jl +++ b/test/runtests_error_fields.jl @@ -99,7 +99,7 @@ include("h5_metadata_check.jl") h5open(h5path, "r") do h5 @test haskey(h5, "Info/Runtimes/error_fields") @test read(h5["Info/Runtimes/error_fields"]) > 0 - @test haskey(h5, "Info/Runtimes/forcing_terms") + @test !haskey(h5, "Info/Runtimes/forcing_terms") # timed from main, so only whole stages appear @test haskey(h5, "Info/Runtimes/perturbed_equilibrium") @test haskey(h5, "Info/Runtimes/kinetic_forces") @test haskey(h5, "Info/Runtimes/total") From e808dd26f795df5d8a0b8380b32d204c2553fc8b Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Wed, 21 Oct 2026 16:51:01 +0200 Subject: [PATCH 04/60] ForceFreeStates - REFACTOR! - Run deck matching through a MatchProblem clause in main --- docs/src/galerkin.md | 4 +- src/ForceFreeStates/CoreTypes.jl | 4 +- src/ForceFreeStates/Galerkin/GalerkinSolve.jl | 2 +- src/GeneralizedPerturbedEquilibrium.jl | 63 ++++++++++--------- test/runtests_solve_api.jl | 40 ++++++++---- 5 files changed, 69 insertions(+), 44 deletions(-) diff --git a/docs/src/galerkin.md b/docs/src/galerkin.md index 413628f77..d1e657154 100644 --- a/docs/src/galerkin.md +++ b/docs/src/galerkin.md @@ -17,7 +17,9 @@ whichever formalism produced it. These are the outer-region inputs to resistive Select it with `integrator = "galerkin"` in `[ForceFreeStates]`. It replaces the radial ODE integration rather than supplementing it: the run computes its own vacuum response at the control surface (when `vac_flag`) and produces no free-boundary energies or ODE trace. -Setting `gal_match_flag` additionally matches the inner layer, giving a driven ξ solution. +Setting `gal_match_flag` additionally matches the inner layer, giving a driven ξ solution. The +composite inner-region profiles are a scripting diagnostic: they need `Galerkin(; cut_solution=true)` +followed by `solve(MatchProblem(...))`, and a deck run does not write them. The implementation lives in `src/ForceFreeStates/Galerkin/`: diff --git a/src/ForceFreeStates/CoreTypes.jl b/src/ForceFreeStates/CoreTypes.jl index c321fa98f..282301688 100644 --- a/src/ForceFreeStates/CoreTypes.jl +++ b/src/ForceFreeStates/CoreTypes.jl @@ -201,9 +201,9 @@ gpec.toml. gal_sing_order_ceiling::Bool = true # auto-raise order by ceil(2·Re(α)) per surface (high Mercier index) gal_rpec_flag::Bool = false # append mpert coil-response columns to the Δ′ solve (RDCON rpec_flag): unit boundary sources whose plasma response is recorded; needed for the driven (resistive perturbed-equilibrium) Δ_gw gal_edge_onesided::Bool = false # pack the two end intervals one-sided toward their single rational end (vs the Fortran symmetric "both" pack); avoids the fine edge cell that inflates cond(A). Default false = faithful to gal.f. - gal_cut_solution::Bool = false # also keep the cut solution for a later match's composite inner profiles; implied by gal_match_flag + gal_cut_solution::Bool = false # also keep the cut solution for a later match's composite inner profiles; set by Galerkin(; cut_solution=true), not a deck key # --- DRIVEN (RPEC) outer↔inner asymptotic matching (rmatch match_rpec port) --- - gal_match_flag::Bool = false # enable the RPEC inner-layer matching: solve the coil-driven matched ξ(ψ) from the gal Δ′ + the inner-layer Δ(Q). Requires gal_rpec_flag=true. + gal_match_flag::Bool = false # match the inner layer on the finished solve (coil-driven ξ from Δ′ + inner Δ(Q)); needs gal_rpec_flag=true, or riccati with vac_flag=true gal_ideal_flag::Bool = false # within the match, build the IDEAL solution: skip the inner-layer Δ, use bare coil columns (cout=0). Mirrors Fortran rmatch coil%ideal_flag (the EL reference). eta/rho/rotation ignored. gal_inner_solver::String = "ray" # inner-layer Δ backend for the match: "ray" (rotated-contour collocation, certified Δ at the optimal θ = arg(Q)/4; robust for |Q| ≳ 1) or "galerkin" (Hermite-cubic inps; drifts for |Q| ≳ 1) # --- Inner-layer "galerkin" backend overrides (gal_inner_solver = "galerkin"; unset keeps the backend default) --- diff --git a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl index 881361095..5ab2d1569 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl @@ -183,7 +183,7 @@ function galerkin_solve(ctrl::ForceFreeStatesControl, equil, mats::MatrixSplines # solution only when a later match needs the composite inner profiles. ctrl.verbose && @info "Reconstructing outer-region ξ and analytic ξ′ on the gal grid" solution = gal_output_solution(ws, asymps, sings, intr, equil.profiles, psihigh; - delta=((ctrl.gal_cut_solution || ctrl.gal_match_flag) ? delta : nothing)) + delta=(ctrl.gal_cut_solution ? delta : nothing)) sing_psi = [s.psifac for s in sings] sing_q = [s.q for s in sings] diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index e8cf792ed..cbdfa6e17 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -94,7 +94,7 @@ using .ForcingTerms: RMPField const _DEPRECATED_FFS_KEYS = ("mer_flag", "force_wv_symmetry", "ode_flag", "cyl_flag", "mat_flag", "reform_eq_with_psilim", "use_riccati", "use_parallel", "parallel_threads", "populate_dense_xi", - "gal_flag", "nstep", "diagnose_ca", "diagnose", "ksing") + "gal_flag", "nstep", "diagnose_ca", "diagnose", "ksing", "gal_cut_solution") const _DEPRECATED_EQUIL_KEYS = ("power_bp", "power_b", "power_r", "power_rc", "use_galgrid") # Drop deprecated keys from a parsed gpec.toml section so legacy files keep parsing @@ -259,7 +259,7 @@ function main_from_inputs( metric, mats, layer_overlap = prepare_force_free_states!(intr, ctrl, equil, kf_ctrl, kinetic_profiles; species=kf_species, overlap_inputs=_overlap_slayer_inputs(inputs, intr.dir_path)) - ffs_result = run_force_free_states(ctrl, equil, mats, intr, metric) + ffs_result = run_deck_match(run_force_free_states(ctrl, equil, mats, intr, metric), ctrl) if ctrl.write_outputs_to_HDF5 write_outputs_to_HDF5( @@ -819,35 +819,40 @@ function run_force_free_states( # Publish the solve: from here on the downstream stages read the result, never `intr`. result = build_result(Symbol(ctrl.integrator), ctrl, equil, intr, metric, mats, odet, free_energies, gal_data, gal_dp) - # Deck-driven matching, through the same MatchProblem as the API. - if ctrl.integrator == "galerkin" && ctrl.gal_match_flag - ctrl.gal_rpec_flag || error("gal_match_flag=true requires gal_rpec_flag=true") - ctrl.gal_ideal_flag || !(isempty(ctrl.gal_eta) || isempty(ctrl.gal_rho) || isempty(ctrl.gal_rotation)) || - error("gal_match_flag needs gal_eta, gal_rho and gal_rotation (one value per matched surface, core to edge)") - ctrl.gal_inner_solver in ("ray", "galerkin") || - error("gal_inner_solver = \"$(ctrl.gal_inner_solver)\" (expected \"ray\" or \"galerkin\")") - ctrl.verbose && @info( - ctrl.gal_ideal_flag ? - "RPEC matching: IDEAL solution (inner layer skipped, bare coil columns)" : - "RPEC matching: inner-layer Δ(Q) + outer↔inner solve for the coil-driven ξ" - ) - # The gal_inner_* knobs override the "galerkin" backend's defaults; unset keys keep them. - model = if ctrl.gal_inner_solver == "galerkin" - knobs = (xfac=ctrl.gal_inner_xfac, nx=ctrl.gal_inner_nx, nq=ctrl.gal_inner_nq, - cutoff=ctrl.gal_inner_cutoff, kmax=ctrl.gal_inner_kmax) - InnerLayer.GGJModel(; solver=:galerkin, (k => v for (k, v) in pairs(knobs) if v !== nothing)...) - else - InnerLayer.GGJModel(; solver=:ray) - end - prob = ForceFreeStates.MatchProblem(result; eta=ctrl.gal_eta, rho=ctrl.gal_rho, rotation=ctrl.gal_rotation, - gamma=ctrl.gal_gamma, ideal=ctrl.gal_ideal_flag) - result = solve(prob, model) - ctrl.gal_ideal_flag || (ctrl.verbose && @info "RPEC matching: linear-solve residual = $(result.match.residual)") - end - return result end +""" + run_deck_match(result, ctrl) -> ForceFreeStatesResult + +Solve the deck's inner-layer match (`gal_match_flag`) on a finished solve, posing the same +`MatchProblem` a script would from the `gal_*` keys. Returns `result` unchanged when the deck +requests no match. +""" +function run_deck_match(result::ForceFreeStatesResult, ctrl::ForceFreeStatesControl) + ctrl.gal_match_flag || return result + ctrl.gal_inner_solver in ("ray", "galerkin") || + error("gal_inner_solver = \"$(ctrl.gal_inner_solver)\" (expected \"ray\" or \"galerkin\")") + ctrl.verbose && @info( + ctrl.gal_ideal_flag ? + "RPEC matching: IDEAL solution (inner layer skipped, bare coil columns)" : + "RPEC matching: inner-layer Δ(Q) + outer↔inner solve for the coil-driven ξ" + ) + # The gal_inner_* knobs override the "galerkin" backend's defaults; unset keys keep them. + model = if ctrl.gal_inner_solver == "galerkin" + knobs = (xfac=ctrl.gal_inner_xfac, nx=ctrl.gal_inner_nx, nq=ctrl.gal_inner_nq, + cutoff=ctrl.gal_inner_cutoff, kmax=ctrl.gal_inner_kmax) + InnerLayer.GGJModel(; solver=:galerkin, (k => v for (k, v) in pairs(knobs) if v !== nothing)...) + else + InnerLayer.GGJModel(; solver=:ray) + end + prob = ForceFreeStates.MatchProblem(result; eta=ctrl.gal_eta, rho=ctrl.gal_rho, rotation=ctrl.gal_rotation, + gamma=ctrl.gal_gamma, ideal=ctrl.gal_ideal_flag) + matched = solve(prob, model) + ctrl.gal_ideal_flag || (ctrl.verbose && @info "RPEC matching: linear-solve residual = $(matched.match.residual)") + return matched +end + """ EulerLagrangeProblem(equil; nn, wall=Vacuum.WallShapeSettings(), dir_path=".", debug=DebugSettings(), kwargs...) @@ -934,6 +939,8 @@ function solve(prob::EulerLagrangeProblem, alg::ForceFreeStates.AbstractIntegrat intr.debug_settings = prob.debug resolve_mode_space!(intr, ctrl) + ctrl.gal_match_flag && + error("gal_match_flag is the deck's match request; match the result instead: solve(MatchProblem(ffs; eta, rho, rotation), GGJModel())") # The API path never reads kinetic profiles, so the KineticForces control is only the # placeholder `prepare_force_free_states!` threads into its (unused) callback. diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index c61d86089..e94cc494f 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -209,6 +209,7 @@ using HDF5 @test_throws ErrorException solve(equil, Forward(); nn=1, dir_path=".", ffs_kwargs..., integrator="riccati") @test_throws ErrorException solve(equil, Riccati(); nn=1, dir_path=".", ffs_kwargs..., nchunks=8) @test_throws ErrorException solve(equil, Forward(); nn=1, dir_path=".", ffs_kwargs..., nn_low=2) + @test_throws ErrorException solve(equil, Galerkin(; nx=32, rpec_flag=true); nn=1, dir_path=".", ffs_kwargs..., gal_match_flag=true) end # Path of the first field where `a` and `b` differ (recursing into structs, arrays and @@ -343,26 +344,41 @@ using HDF5 @testset "deck matching under gal_match_flag" begin layer = (eta=1e-7, rho=1e-7, rotation=1.0) + # The deck's match keys for `n` surfaces on top of the integrator's own. + match_keys(n) = Dict{String,Any}("gal_match_flag" => true, "gal_inner_solver" => "ray", + "gal_eta" => fill(layer.eta, n), "gal_rho" => fill(layer.rho, n), "gal_rotation" => fill(layer.rotation, n)) + mktempdir() do dir - gal = solve(equil, Galerkin(; nx=32, rpec_flag=true, cut_solution=true); nn=1, wall=wall, dir_path=dir, ffs_kwargs...) + gal = solve(equil, Galerkin(; nx=32, rpec_flag=true); nn=1, wall=wall, dir_path=dir, ffs_kwargs...) api = solve(MatchProblem(gal; layer...), GGJModel(; solver=:ray)) n = length(gal.surfaces) - reference = GPEC.main([_stage_deck(dir; - ffs_overrides=Dict{String,Any}( - "integrator" => "galerkin", "gal_nx" => 32, "gal_rpec_flag" => true, "gal_match_flag" => true, - "gal_inner_solver" => "ray", "gal_eta" => fill(layer.eta, n), "gal_rho" => fill(layer.rho, n), - "gal_rotation" => fill(layer.rotation, n)))]).ffs + overrides = merge(Dict{String,Any}("integrator" => "galerkin", "gal_nx" => 32, "gal_rpec_flag" => true), match_keys(n)) + reference = GPEC.main([_stage_deck(dir; ffs_overrides=overrides)]).ffs @test reference.closure === api.closure === :matched @test api.bpen ≈ reference.bpen rtol = 1e-12 @test api.match.xi ≈ reference.match.xi rtol = 1e-12 - @test length(api.match.inner_xi) == length(reference.match.inner_xi) == n - @test all(isapprox.(api.match.inner_xi, reference.match.inner_xi; rtol=1e-12)) + # The composite inner profiles are API-only: a deck never keeps the cut solution. + @test isempty(reference.match.inner_xi) && isempty(api.match.inner_xi) + cut = solve(equil, Galerkin(; nx=32, rpec_flag=true, cut_solution=true); nn=1, wall=wall, dir_path=dir, ffs_kwargs...) + @test length(solve(MatchProblem(cut; layer...), GGJModel(; solver=:ray)).match.inner_xi) == n + end + + mktempdir() do dir + ric = solve(equil, Riccati(); nn=1, wall=wall, dir_path=dir, ffs_kwargs...) + api = solve(MatchProblem(ric; layer...), GGJModel(; solver=:ray)) + overrides = merge(Dict{String,Any}("integrator" => "riccati"), match_keys(length(ric.surfaces))) + reference = GPEC.main([_stage_deck(dir; ffs_overrides=overrides)]).ffs - uncut = solve(equil, Galerkin(; nx=32, rpec_flag=true); nn=1, wall=wall, dir_path=dir, ffs_kwargs...) - matched = solve(MatchProblem(uncut; layer...), GGJModel(; solver=:ray)) - # Known drift: gal_match_flag implies the cut solution on the deck; the API keeps it only when asked. - @test_broken length(matched.match.inner_xi) == length(reference.match.inner_xi) + @test reference.closure === :matched + @test api.bpen ≈ reference.bpen rtol = 1e-12 + @test api.match.cout ≈ reference.match.cout rtol = 1e-12 + end + + # A forward solve has no Δ′ to match against, so the deck errors instead of ignoring the flag. + mktempdir() do dir + @test_throws ErrorException GPEC.main([_stage_deck(dir; + ffs_overrides=Dict{String,Any}("gal_match_flag" => true, "gal_ideal_flag" => true))]) end end From 0ad81a2d4daba4f04dc8d1149e997e2ba6e20440 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Fri, 23 Oct 2026 01:15:48 +0200 Subject: [PATCH 05/60] Test - TEST - Compare deck and API matching on the galerkin inner model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The :ray model returns no inner profiles by default since #536, so the cut-solution drift only shows with real-ψ profiles. Co-Authored-By: Claude Opus 5.5 --- test/runtests_solve_api.jl | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index 584b1ee4a..9e57a17ff 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -368,14 +368,15 @@ using HDF5 @testset "deck matching under gal_match_flag" begin layer = (eta=1e-7, rho=1e-7, rotation=1.0) + # The galerkin inner model returns real-ψ profiles, so the composite inner profiles are compared too. mktempdir() do dir gal = solve(equil, Galerkin(; nx=32, rpec_flag=true, cut_solution=true); nn=1, wall=wall, dir_path=dir, ffs_kwargs...) - api = solve(MatchProblem(gal; layer...), GGJModel(; solver=:ray)) + api = solve(MatchProblem(gal; layer...), GGJModel(; solver=:galerkin)) n = length(gal.surfaces) reference = GPEC.main([_stage_deck(dir; ffs_overrides=Dict{String,Any}( "integrator" => "galerkin", "gal_nx" => 32, "gal_rpec_flag" => true, "gal_match_flag" => true, - "gal_inner_solver" => "ray", "gal_eta" => fill(layer.eta, n), "gal_rho" => fill(layer.rho, n), + "gal_inner_solver" => "galerkin", "gal_eta" => fill(layer.eta, n), "gal_rho" => fill(layer.rho, n), "gal_rotation" => fill(layer.rotation, n)))]).ffs @test reference.closure === api.closure === :matched @@ -385,7 +386,7 @@ using HDF5 @test all(isapprox.(api.match.inner_xi, reference.match.inner_xi; rtol=1e-12)) uncut = solve(equil, Galerkin(; nx=32, rpec_flag=true); nn=1, wall=wall, dir_path=dir, ffs_kwargs...) - matched = solve(MatchProblem(uncut; layer...), GGJModel(; solver=:ray)) + matched = solve(MatchProblem(uncut; layer...), GGJModel(; solver=:galerkin)) # Known drift: gal_match_flag implies the cut solution on the deck; the API keeps it only when asked. @test_broken length(matched.match.inner_xi) == length(reference.match.inner_xi) end From 1e71d80f0e4630080f58d755aa25c246496cf9bf Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Fri, 23 Oct 2026 14:40:33 +0200 Subject: [PATCH 06/60] LocalStability - API - Add LocalStabilityProblem, Ballooning and LocalStabilityResult --- docs/src/api.md | 14 ++++ src/GeneralizedPerturbedEquilibrium.jl | 2 + src/LocalStability/LocalStability.jl | 3 + src/LocalStability/Problem.jl | 90 ++++++++++++++++++++++++++ test/runtests.jl | 1 + test/runtests_localstability.jl | 54 ++++++++++++++++ 6 files changed, 164 insertions(+) create mode 100644 src/LocalStability/Problem.jl create mode 100644 test/runtests_localstability.jl diff --git a/docs/src/api.md b/docs/src/api.md index bf734f762..8cc661384 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -125,6 +125,20 @@ kinetic profiles come from `profile_file`, relative to the run directory. Kinetic runs (`kinetic_factor > 0`) need the `[KineticForces]` profiles and remain TOML-driven. +## Local stability + +The Mercier, resistive interchange and ballooning profiles are their own stage, posed on the +equilibrium alone. A [`LocalStabilityProblem`](@ref) holds the equilibrium, the +[`Ballooning`](@ref) algorithm holds the scan knobs, and `solve` returns a +[`LocalStabilityResult`](@ref) with the profiles as named fields. A deck runs the same solve +through its `[LocalStability]` section, whose keys are the `Ballooning` keywords. + +```julia +ls = solve(LocalStabilityProblem(eq), Ballooning(; n_scan=64)) +ls.psi, ls.D_I, ls.D_R, ls.ballooning_Delta_prime # profiles on the equilibrium ψ grid +ls.ballooning_psi, ls.alpha, ls.alpha_critical # first ballooning boundary, NaN outside its scan window +``` + ## Entry points ```@docs diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 251e6da84..022510855 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -89,6 +89,7 @@ using .ForceFreeStates: AbstractIntegrator, Forward, Riccati, Galerkin using .ForceFreeStates: MatchProblem using .InnerLayer: GGJModel, SLAYERModel using .Tearing.Runner: TearingProblem +using .LocalStability: LocalStabilityProblem, Ballooning, LocalStabilityResult using .Equilibrium: PlasmaEquilibrium using .ForcingTerms: RMPField @@ -1935,6 +1936,7 @@ export main, write_imas export solve, perturbed_equilibrium export PlasmaEquilibrium, EulerLagrangeProblem, Forward, Riccati, Galerkin, ForceFreeStatesResult, RMPField export MatchProblem, TearingProblem, GGJModel, SLAYERModel +export LocalStabilityProblem, Ballooning, LocalStabilityResult include("Precompile.jl") diff --git a/src/LocalStability/LocalStability.jl b/src/LocalStability/LocalStability.jl index eb96780e2..e68c8029a 100644 --- a/src/LocalStability/LocalStability.jl +++ b/src/LocalStability/LocalStability.jl @@ -7,11 +7,14 @@ using FFTW using OrdinaryDiffEq using FastInterpolations using StaticArrays: SVector +import CommonSolve import ..Equilibrium include("Ballooning.jl") +include("Problem.jl") +export LocalStabilityProblem, Ballooning, LocalStabilityResult, mercier_D_I export compute_local_stability, compute_ballooning_stability!, ballooning_alpha_boundary, ballooning_alpha_boundaries end diff --git a/src/LocalStability/Problem.jl b/src/LocalStability/Problem.jl new file mode 100644 index 000000000..6ee45fa3d --- /dev/null +++ b/src/LocalStability/Problem.jl @@ -0,0 +1,90 @@ +# Problem.jl +# +# The LocalStability stage posed as solve(problem, algorithm): Mercier D_I, resistive D_R, +# ballooning Δ' and the first ballooning α boundary over the equilibrium ψ grid. + +""" + LocalStabilityProblem(equil) + +Local high-n stability of an equilibrium: the Mercier and resistive interchange criteria and +the ballooning Δ' on every flux surface, and the first ballooning α boundary. Solve with +`solve(prob, Ballooning())` to get a [`LocalStabilityResult`](@ref). A deck poses the same +problem through its `[LocalStability]` section. + +## Fields + + - `equil::Equilibrium.PlasmaEquilibrium` - The equilibrium the profiles are computed on. +""" +struct LocalStabilityProblem + equil::Equilibrium.PlasmaEquilibrium +end + +""" + Ballooning(; theta_k=0.0, n_scan=24, alpha_boundary_scan=true, verbose=true) + +The local-stability algorithm: the ballooning coefficient system per flux surface, plus the +BALOO-style α-boundary scan. Its keywords are the `[LocalStability]` deck keys. + +## Fields + + - `theta_k::Float64` - Ballooning angle θ_k, used by both the profile scan and the α-boundary scan. + - `n_scan::Int` - Resolution of the α scan for the first boundary (see [`ballooning_alpha_boundary`](@ref)). + - `alpha_boundary_scan::Bool` - Run the α-boundary scan, a root find per ψ surface; `false` leaves the α fields empty. + - `verbose::Bool` - Print progress and per-surface α-scan failures. +""" +@kwdef struct Ballooning + theta_k::Float64 = 0.0 + n_scan::Int = 24 + alpha_boundary_scan::Bool = true + verbose::Bool = true +end + +""" + LocalStabilityResult + +The local-stability profiles returned by `solve(::LocalStabilityProblem, ::Ballooning)`. + +## Fields + + - `psi::Vector{Float64}` - Normalized flux grid of the profiles (the equilibrium `profiles.xs`). + - `D_I::Vector{Float64}` - Mercier interchange criterion `det(d0bar)`; stable where negative. + - `D_R::Vector{Float64}` - Resistive interchange criterion `D_I + (H - 1/2)²` (see [`resistive_interchange_h`](@ref)). + - `ballooning_Delta_prime::Vector{Float64}` - High-n ballooning Δ', zero for ψ > 1. + - `ballooning_psi::Vector{Float64}` - Grid of the α-boundary scan; empty when the scan is off. + - `alpha::Vector{Float64}` - Experimental α on `ballooning_psi`, NaN outside the scan window. + - `alpha_critical::Vector{Float64}` - First-boundary critical α on `ballooning_psi`, NaN outside the scan window or with no boundary. + - `di_psi` - Cubic interpolant of `D_I·ψ` over `psi`, for evaluating `D_I` off the grid (see [`mercier_D_I`](@ref)). +""" +struct LocalStabilityResult{S} + psi::Vector{Float64} + D_I::Vector{Float64} + D_R::Vector{Float64} + ballooning_Delta_prime::Vector{Float64} + ballooning_psi::Vector{Float64} + alpha::Vector{Float64} + alpha_critical::Vector{Float64} + di_psi::S +end + +""" + mercier_D_I(result::LocalStabilityResult, psi) -> Float64 + +Mercier `D_I` at `psi`, interpolated through the smooth `D_I·ψ` profile rather than `D_I` itself. +""" +mercier_D_I(result::LocalStabilityResult, psi::Real) = result.di_psi(psi)[1] / psi + +function CommonSolve.solve(prob::LocalStabilityProblem, alg::Ballooning) + equil = prob.equil + xs = equil.profiles.xs + locstab_fs = zeros(Float64, length(xs), 5) + compute_ballooning_stability!(locstab_fs, equil; theta_k=alg.theta_k, verbose=alg.verbose) + boundary = if alg.alpha_boundary_scan + ballooning_alpha_boundary(equil; theta_k=alg.theta_k, n_scan=alg.n_scan, verbose=alg.verbose) + else + (psi=Float64[], alpha=Float64[], alpha_critical=Float64[]) + end + # A Series spline, not a plain-vector one: the two differ in the last bits off the knots. + di_psi = cubic_interp(xs, Series(locstab_fs[:, 1:1]); extrap=ExtendExtrap()) + return LocalStabilityResult(collect(xs), locstab_fs[:, 1] ./ xs, locstab_fs[:, 2] ./ xs, locstab_fs[:, 4], + boundary.psi, boundary.alpha, boundary.alpha_critical, di_psi) +end diff --git a/test/runtests.jl b/test/runtests.jl index 005d65ff1..7a7f17e4c 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -49,6 +49,7 @@ else include("./runtests_tj_analytic.jl") include("./runtests_kinetic_profiles.jl") include("./runtests_resist_eval.jl") + include("./runtests_localstability.jl") include("./runtests_slayer_params.jl") include("./runtests_slayer_riccati.jl") include("./runtests_slayer_inputs.jl") diff --git a/test/runtests_localstability.jl b/test/runtests_localstability.jl new file mode 100644 index 000000000..ef937cbe9 --- /dev/null +++ b/test/runtests_localstability.jl @@ -0,0 +1,54 @@ +using TOML + +# The LocalStability stage on the coarse Solovev fixture deck (mpsi=16): the named profiles of +# solve(LocalStabilityProblem, Ballooning()) against their kernels. +@testset "LocalStability" begin + GPEC = GeneralizedPerturbedEquilibrium + LS = GPEC.LocalStability + template = joinpath(@__DIR__, "test_data", "regression_solovev_ideal_example") + deck = TOML.parsefile(joinpath(template, "gpec.toml")) + equil = GPEC.Equilibrium.setup_equilibrium( + GPEC.Equilibrium.EquilibriumConfig(deck["Equilibrium"], template), + GPEC.Equilibrium.SolovevConfig(deck["SOL_INPUT"]) + ) + xs = equil.profiles.xs + res = solve(LocalStabilityProblem(equil), Ballooning(; verbose=false)) + + @testset "profiles match their kernels" begin + @test res isa LocalStabilityResult + @test res.psi == xs + for i in eachindex(xs) + di = LS.prepare_ballooning_coefficients(i, equil).di + h = LS.resistive_interchange_h(i, equil) + @test res.D_I[i] ≈ di rtol = 1e-14 + @test res.D_R[i] ≈ di + (h - 0.5)^2 rtol = 1e-12 + @test LS.mercier_D_I(res, xs[i]) ≈ res.D_I[i] rtol = 1e-12 + end + @test all(isfinite, res.ballooning_Delta_prime) + end + + @testset "α boundary is NaN outside its scan window" begin + @test res.ballooning_psi == xs + inside = [LS._in_ballooning_scan_window(p, xs[end]) for p in xs] + @test any(inside) && !all(inside) + @test all(isfinite, res.alpha[inside]) + @test all(isnan, res.alpha[.!inside]) + @test all(isnan, res.alpha_critical[.!inside]) + end + + @testset "the α scan can be switched off" begin + quick = solve(LocalStabilityProblem(equil), Ballooning(; alpha_boundary_scan=false, verbose=false)) + @test isempty(quick.ballooning_psi) && isempty(quick.alpha) && isempty(quick.alpha_critical) + @test quick.D_I == res.D_I + @test quick.ballooning_Delta_prime == res.ballooning_Delta_prime + end + + @testset "solve writes no file" begin + mktempdir() do dir + cd(dir) do + solve(LocalStabilityProblem(equil), Ballooning(; alpha_boundary_scan=false, verbose=false)) + end + @test isempty(readdir(dir)) + end + end +end From b870ee8e3061baffb2293cc55310dd03f16caf57 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Fri, 23 Oct 2026 15:06:38 +0200 Subject: [PATCH 07/60] =?UTF-8?q?LocalStability=20-=20FEATURE=20-=20Expose?= =?UTF-8?q?=20the=20=CE=B1-scan=20window,=20search=20bound,=20bisection=20?= =?UTF-8?q?and=20ODE=20tolerances=20on=20Ballooning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/LocalStability/Ballooning.jl | 40 +++++++++++++++++++------------- src/LocalStability/Problem.jl | 20 ++++++++++++---- test/runtests_localstability.jl | 13 +++++++++++ 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/src/LocalStability/Ballooning.jl b/src/LocalStability/Ballooning.jl index 83ad76192..9979b913b 100644 --- a/src/LocalStability/Ballooning.jl +++ b/src/LocalStability/Ballooning.jl @@ -10,9 +10,9 @@ const MATCHING_POINT = 1e-3 # mid-radius and pedestal; the packed axis and far-edge surfaces only add scan cost. const BALLOONING_SCAN_PSI_WINDOW = (0.1, 0.99) -# Effective window [0.1, min(0.99, ψ_edge)]; the grid never extends past psihigh/psi_edge. -_in_ballooning_scan_window(psi::Float64, psi_edge::Float64) = - BALLOONING_SCAN_PSI_WINDOW[1] <= psi <= min(BALLOONING_SCAN_PSI_WINDOW[2], psi_edge) +# Effective window [lo, min(hi, ψ_edge)]; the grid never extends past psihigh/psi_edge. +_in_ballooning_scan_window(psi::Float64, psi_edge::Float64, window=BALLOONING_SCAN_PSI_WINDOW) = + window[1] <= psi <= min(window[2], psi_edge) """ compute_ballooning_stability!(locstab_fs, plasma_eq) @@ -26,6 +26,7 @@ to compute Delta Prime. - `locstab_fs::Matrix{Float64}`: Local stability matrix to store results (modified in place). - `plasma_eq::Equilibrium.PlasmaEquilibrium`: Plasma equilibrium data. + - `ode_tol::Float64`: Relative and absolute tolerance of the ballooning ODE integration. - `verbose::Bool`: Print progress messages. This function modifies `locstab_fs` in place with: @@ -40,6 +41,7 @@ function compute_ballooning_stability!( plasma_eq::Equilibrium.PlasmaEquilibrium; theta_k::Float64=0.0, compute_delta_prime::Bool=true, + ode_tol::Float64=1e-8, verbose::Bool=false ) @@ -64,7 +66,8 @@ function compute_ballooning_stability!( if compute_delta_prime && plasma_eq.profiles.xs[flux_surface_index] <= 1.0 result = integrate_ballooning_ode( coeff_data.ode_coefficient_spline; - theta_k=theta_k + theta_k=theta_k, + tol=ode_tol ) locstab_fs[flux_surface_index, 4] = result.value end @@ -839,7 +842,8 @@ function critical_ballooning_alpha( end """ - ballooning_alpha_boundary(plasma_eq; theta_k=0.0, n_scan=24, verbose=false) + ballooning_alpha_boundary(plasma_eq; theta_k=0.0, n_scan=24, max_alpha_scale=8.0, tol=1e-3, + psi_window=BALLOONING_SCAN_PSI_WINDOW, verbose=false) Profile driver for the BALOO-style ballooning stability diagram. Loops over flux surfaces returning the experimental pressure gradient `alpha` (from @@ -852,8 +856,9 @@ first crossing, so cost scales with the boundary location rather than the full Use this (not [`ballooning_alpha_boundaries`](@ref)) when only the 1st boundary is needed, e.g. a pedestal-stability constraint. `n_scan` sets the scan resolution; raise it (e.g. 64) on edge surfaces where Δ' poles can shift a coarse first crossing. +`max_alpha_scale` and `tol` bound and resolve the search, as in [`critical_ballooning_alpha`](@ref). -The scan is evaluated only inside `BALLOONING_SCAN_PSI_WINDOW` — ballooning boundaries +The scan is evaluated only inside `psi_window` (default `BALLOONING_SCAN_PSI_WINDOW`) — ballooning boundaries are physically relevant in the mid-radius and pedestal, and the tightly packed axis and far-edge surfaces dominate the scan cost. Per-surface failures, skipped surfaces, and surfaces with no boundary within range are returned as `NaN`. @@ -862,6 +867,9 @@ function ballooning_alpha_boundary( plasma_eq::Equilibrium.PlasmaEquilibrium; theta_k::Float64=0.0, n_scan::Int=24, + max_alpha_scale::Float64=8.0, + tol::Float64=1e-3, + psi_window::Tuple{Float64,Float64}=BALLOONING_SCAN_PSI_WINDOW, verbose::Bool=false ) xs = plasma_eq.profiles.xs @@ -871,10 +879,11 @@ function ballooning_alpha_boundary( alpha_critical = fill(NaN, npsi) Threads.@threads :greedy for i in 1:npsi - _in_ballooning_scan_window(xs[i], xs[end]) || continue + _in_ballooning_scan_window(xs[i], xs[end], psi_window) || continue try alpha[i] = salpha_reference(i, plasma_eq).alpha_ref - alpha_critical[i] = critical_ballooning_alpha(i, plasma_eq; theta_k=theta_k, n_scan=n_scan).alpha_crit + alpha_critical[i] = critical_ballooning_alpha(i, plasma_eq; + theta_k=theta_k, n_scan=n_scan, max_alpha_scale=max_alpha_scale, tol=tol).alpha_crit catch err if verbose @warn "ballooning alpha boundary failed" psi_idx = i exception = err @@ -1140,17 +1149,16 @@ end # Integrates the ideal marginal ballooning equations # ====================================================================== """ - integrate_ballooning_ode(ode_coefficient_spline; theta_k=0.0) + integrate_ballooning_ode(ode_coefficient_spline; theta_k=0.0, tol=1e-8) Integrates the ballooning ODE from `-Infinity` to `-1e-3` and `+Infinity` to `+1e-3`. -Computes Delta' = (y'/y)_right - (y'/y)_left. +Computes Delta' = (y'/y)_right - (y'/y)_left. `tol` is the relative and absolute ODE tolerance. ## Returns - `delta_prime`: The stability index Δ'. """ -function integrate_ballooning_ode(ode_coefficient_spline; theta_k::Float64=0.0) - TOLERANCE = 1e-8 +function integrate_ballooning_ode(ode_coefficient_spline; theta_k::Float64=0.0, tol::Float64=1e-8) MINIMUM_STEP = 1e-10 theta_max = max(ode_coefficient_spline.theta_max, 10.0 * MATCHING_POINT) @@ -1179,8 +1187,8 @@ function integrate_ballooning_ode(ode_coefficient_spline; theta_k::Float64=0.0) sol_left = solve( problem_left, DP5(); - reltol=TOLERANCE, - abstol=TOLERANCE, + reltol=tol, + abstol=tol, dtmin=MINIMUM_STEP, adaptive=true, save_everystep=false, @@ -1200,8 +1208,8 @@ function integrate_ballooning_ode(ode_coefficient_spline; theta_k::Float64=0.0) sol_right = solve( problem_right, DP5(); - reltol=TOLERANCE, - abstol=TOLERANCE, + reltol=tol, + abstol=tol, dtmin=MINIMUM_STEP, adaptive=true, save_everystep=false, diff --git a/src/LocalStability/Problem.jl b/src/LocalStability/Problem.jl index 6ee45fa3d..5c9d97516 100644 --- a/src/LocalStability/Problem.jl +++ b/src/LocalStability/Problem.jl @@ -20,7 +20,8 @@ struct LocalStabilityProblem end """ - Ballooning(; theta_k=0.0, n_scan=24, alpha_boundary_scan=true, verbose=true) + Ballooning(; theta_k=0.0, ode_tol=1e-8, alpha_boundary_scan=true, n_scan=24, max_alpha_scale=8.0, + alpha_tol=1e-3, psi_window=(0.1, 0.99), verbose=true) The local-stability algorithm: the ballooning coefficient system per flux surface, plus the BALOO-style α-boundary scan. Its keywords are the `[LocalStability]` deck keys. @@ -28,14 +29,22 @@ BALOO-style α-boundary scan. Its keywords are the `[LocalStability]` deck keys. ## Fields - `theta_k::Float64` - Ballooning angle θ_k, used by both the profile scan and the α-boundary scan. - - `n_scan::Int` - Resolution of the α scan for the first boundary (see [`ballooning_alpha_boundary`](@ref)). + - `ode_tol::Float64` - Relative and absolute tolerance of the ballooning ODE integration behind Δ'. - `alpha_boundary_scan::Bool` - Run the α-boundary scan, a root find per ψ surface; `false` leaves the α fields empty. + - `n_scan::Int` - Resolution of the α scan for the first boundary (see [`ballooning_alpha_boundary`](@ref)). + - `max_alpha_scale::Float64` - Upper end of the α search, as a multiple of the experimental α; no crossing below it gives NaN. + - `alpha_tol::Float64` - Bisection tolerance of the critical α, in the same multiples. + - `psi_window::Tuple{Float64,Float64}` - ψ_N range of the α scan; surfaces outside it are NaN. - `verbose::Bool` - Print progress and per-surface α-scan failures. """ @kwdef struct Ballooning theta_k::Float64 = 0.0 - n_scan::Int = 24 + ode_tol::Float64 = 1e-8 alpha_boundary_scan::Bool = true + n_scan::Int = 24 + max_alpha_scale::Float64 = 8.0 + alpha_tol::Float64 = 1e-3 + psi_window::Tuple{Float64,Float64} = BALLOONING_SCAN_PSI_WINDOW verbose::Bool = true end @@ -77,9 +86,10 @@ function CommonSolve.solve(prob::LocalStabilityProblem, alg::Ballooning) equil = prob.equil xs = equil.profiles.xs locstab_fs = zeros(Float64, length(xs), 5) - compute_ballooning_stability!(locstab_fs, equil; theta_k=alg.theta_k, verbose=alg.verbose) + compute_ballooning_stability!(locstab_fs, equil; theta_k=alg.theta_k, ode_tol=alg.ode_tol, verbose=alg.verbose) boundary = if alg.alpha_boundary_scan - ballooning_alpha_boundary(equil; theta_k=alg.theta_k, n_scan=alg.n_scan, verbose=alg.verbose) + ballooning_alpha_boundary(equil; theta_k=alg.theta_k, n_scan=alg.n_scan, max_alpha_scale=alg.max_alpha_scale, + tol=alg.alpha_tol, psi_window=alg.psi_window, verbose=alg.verbose) else (psi=Float64[], alpha=Float64[], alpha_critical=Float64[]) end diff --git a/test/runtests_localstability.jl b/test/runtests_localstability.jl index ef937cbe9..c52769b92 100644 --- a/test/runtests_localstability.jl +++ b/test/runtests_localstability.jl @@ -43,6 +43,19 @@ using TOML @test quick.ballooning_Delta_prime == res.ballooning_Delta_prime end + @testset "numerical knobs reach the kernels" begin + narrow = solve(LocalStabilityProblem(equil), Ballooning(; psi_window=(0.3, 0.7), verbose=false)) + inside = [0.3 <= p <= 0.7 for p in xs] + @test all(isfinite, narrow.alpha[inside]) + @test all(isnan, narrow.alpha[.!inside]) + @test narrow.alpha[inside] == res.alpha[inside] + + loose = solve(LocalStabilityProblem(equil), Ballooning(; ode_tol=1e-6, alpha_boundary_scan=false, verbose=false)) + @test loose.D_I == res.D_I + @test loose.ballooning_Delta_prime ≈ res.ballooning_Delta_prime rtol = 1e-3 + @test loose.ballooning_Delta_prime != res.ballooning_Delta_prime + end + @testset "solve writes no file" begin mktempdir() do dir cd(dir) do From 70b78473df01345c32f56af0006c862bbdc08e9a Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Fri, 23 Oct 2026 16:03:49 +0200 Subject: [PATCH 08/60] LocalStability - API! - Run LocalStability from its own [LocalStability] deck section --- .pre-commit-config.yaml | 2 +- src/Analysis/ForceFreeStates.jl | 5 +- src/ForceFreeStates/CoreTypes.jl | 2 - src/GeneralizedPerturbedEquilibrium.jl | 103 +++++++++--------- src/HDF5Schema.jl | 1 + src/LocalStability/Ballooning.jl | 13 --- src/LocalStability/LocalStability.jl | 2 +- test/runtests_localstability.jl | 57 ++++++++++ test/runtests_rerun_from_h5.jl | 8 ++ .../gpec.toml | 5 +- .../gpec.toml | 5 +- .../gpec.toml | 5 +- .../gpec.toml | 5 +- .../gpec.toml | 5 +- .../gpec.toml | 5 +- 15 files changed, 146 insertions(+), 77 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5efde01b0..0fd39daad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -77,5 +77,5 @@ repos: - id: toml-no-deprecated-keys name: 'TOML conventions: no deprecated config keys' language: pygrep - entry: '^(mer_flag|force_wv_symmetry|ode_flag|cyl_flag|mat_flag|reform_eq_with_psilim|use_riccati|use_parallel|parallel_threads|populate_dense_xi|gal_flag|nstep|diagnose_ca|diagnose|ksing|power_bp|power_b|power_r|power_rc|use_galgrid)\s*=' + entry: '^(mer_flag|force_wv_symmetry|ode_flag|cyl_flag|mat_flag|reform_eq_with_psilim|use_riccati|use_parallel|parallel_threads|populate_dense_xi|gal_flag|nstep|diagnose_ca|diagnose|ksing|power_bp|power_b|power_r|power_rc|use_galgrid|local_stability_flag)\s*=' files: ^(examples/.*\.toml|test/test_data/.*\.toml)$ diff --git a/src/Analysis/ForceFreeStates.jl b/src/Analysis/ForceFreeStates.jl index 03462531f..304b9ba54 100644 --- a/src/Analysis/ForceFreeStates.jl +++ b/src/Analysis/ForceFreeStates.jl @@ -377,8 +377,7 @@ Plot the BALOO-style infinite-n ballooning stability diagram: the experimental pressure gradient α (solid) and the first stability boundary α_crit (dashed) versus normalized poloidal flux ψ_N. Surfaces where the experimental α lies above the boundary are ballooning-unstable. Reads `LocalStability/ballooning_psi`, `LocalStability/alpha`, and -`LocalStability/alpha_critical` (populated when ForceFreeStates runs with -`local_stability_flag = true`). +`LocalStability/alpha_critical` (populated when the deck has a `[LocalStability]` section). ### Arguments @@ -400,7 +399,7 @@ function plot_ballooning_alpha_boundary(h5path; save_path=nothing, psi_min=0.0) read(fid["LocalStability/ballooning_psi"]), read(fid["LocalStability/alpha"]), read(fid["LocalStability/alpha_critical"]) end - isempty(alpha) && return plot(; title="No local stability data (set local_stability_flag)", legend=false) + isempty(alpha) && return plot(; title="No local stability data (add a [LocalStability] section)", legend=false) p = plot(; xlims=(psi_min, 1), diff --git a/src/ForceFreeStates/CoreTypes.jl b/src/ForceFreeStates/CoreTypes.jl index ef384af7b..95835d5e8 100644 --- a/src/ForceFreeStates/CoreTypes.jl +++ b/src/ForceFreeStates/CoreTypes.jl @@ -118,7 +118,6 @@ gpec.toml. ## Fields - `verbose::Bool` - Enable verbose output - - `local_stability_flag::Bool` - Enable local stability analysis (`D_I` and ballooning) - `vac_flag::Bool` - Enable vacuum region calculation - `mtheta_vacuum::Int` - Number of vacuum poloidal grid points (corresponds to `mtheta` in VacuumInput) - `nzeta_vacuum::Int` - Number of vacuum toroidal grid points (corresponds to `nzeta` in VacuumInput3D) @@ -153,7 +152,6 @@ gpec.toml. """ @kwdef struct ForceFreeStatesControl verbose::Bool = true - local_stability_flag::Bool = false vac_flag::Bool = false mtheta_vacuum::Int = 480 nzeta_vacuum::Int = 1 diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 022510855..a10dfa379 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -110,6 +110,24 @@ function _drop_deprecated_keys!(table, deprecated_keys, section::String) return table end +""" + local_stability_algorithm(inputs) -> Union{LocalStability.Ballooning,Nothing} + +The [`Ballooning`](@ref) algorithm a deck's `[LocalStability]` section poses, its keys being the +`Ballooning` keywords, or `nothing` when the deck has no such section. Unknown keys are an error. +""" +function local_stability_algorithm(inputs) + haskey(inputs, "LocalStability") || return nothing + table = inputs["LocalStability"] + allowed = string.(fieldnames(LocalStability.Ballooning)) + for k in keys(table) + k in allowed || throw(ArgumentError("unknown key \"$k\" in [LocalStability] (allowed: $(join(allowed, ", ")))")) + end + kwargs = Dict{Symbol,Any}(Symbol(k) => v for (k, v) in table) + haskey(kwargs, :psi_window) && (kwargs[:psi_window] = Tuple{Float64,Float64}(kwargs[:psi_window])) + return LocalStability.Ballooning(; kwargs...) +end + function main(args::Vector{String}=String[]; dd::Union{IMASdd.dd,Nothing}=nothing) # Every input source builds a ready `(inputs, eq_config, additional_input)` and hands it to # `main_from_inputs`: a gpec.toml working directory, an IMAS `dd`, or a gpec.h5 snapshot. @@ -186,9 +204,10 @@ is enabled) so it still ends up in `Input/RawInputs/ForcingTerms/`. against the current equilibrium) without the original `.dat`/`.h5` files. The coil geometry actually used by the run is always written back into `Input/RawInputs/Coils/`. -Returns `(; ffs, pe, slayer)`: the `ForceFreeStates.ForceFreeStatesResult`, the -`PerturbedEquilibriumState` (`nothing` when that stage did not run) and the SLAYER result -(`nothing` when that stage did not run or failed). An equilibrium-only run +Returns `(; ffs, pe, slayer, local_stability, ...)`: the `ForceFreeStates.ForceFreeStatesResult`, the +`PerturbedEquilibriumState` (`nothing` when that stage did not run), the SLAYER result +(`nothing` when that stage did not run or failed) and the `LocalStabilityResult` (`nothing` +without a `[LocalStability]` section). An equilibrium-only run (`force_termination` in `[Equilibrium]`) returns `nothing` — it never reaches the solve. """ function main_from_inputs( @@ -218,6 +237,7 @@ function main_from_inputs( ffs_table = inputs["ForceFreeStates"] _drop_deprecated_keys!(ffs_table, _DEPRECATED_FFS_KEYS, "ForceFreeStates") ctrl = ForceFreeStatesControl(; (Symbol(k) => v for (k, v) in ffs_table)...) + local_stability_alg = local_stability_algorithm(inputs) resolve_mode_space!(intr, ctrl) @@ -251,13 +271,25 @@ function main_from_inputs( forcing_modes_snapshot = snapshot_forcing_modes(inputs, path, ctrl, preloaded_forcing_modes) + # ---------------------------------------------------------------- + # Local Stability + # ---------------------------------------------------------------- + local_stability = nothing + if local_stability_alg !== nothing + @info "\n Local Stability\n$_SECTION" + ls_start = time() + local_stability = solve(LocalStabilityProblem(equil), local_stability_alg) + ls_dt = time() - ls_start + push!(runtimes, "local_stability" => ls_dt) + @info "Local Stability completed in $(@sprintf("%.3f", ls_dt)) s" + end + # ---------------------------------------------------------------- # Force-Free States # ---------------------------------------------------------------- @info "\n Force-Free States\n$_SECTION" ffs_start = time() - locstab, ballooning_boundary = run_local_stability(ctrl, equil) metric, mats, layer_overlap = prepare_force_free_states!(intr, ctrl, equil, kf_ctrl, kinetic_profiles; species=kf_species, overlap_inputs=_overlap_slayer_inputs(inputs, intr.dir_path)) @@ -269,8 +301,7 @@ function main_from_inputs( git_version=git_version, inputs=inputs, forcing_modes=forcing_modes_snapshot, - locstab=locstab, - ballooning_boundary=ballooning_boundary, + local_stability=local_stability, layer_overlap=layer_overlap ) written_h5 = ctrl.HDF5_filename @@ -301,7 +332,7 @@ function main_from_inputs( _write_runtimes!(joinpath(intr.dir_path, written_h5), runtimes) end @info "\n$_BANNER\n GPEC completed successfully in $(@sprintf("%.3f", total_dt)) s\n$_BANNER" - return (; ffs=ffs_result, pe=nothing, slayer=slayer_result, coil_sensitivities=nothing, monte_carlo=nothing, locking_risk=nothing, efc_couplings=nothing) + return (; ffs=ffs_result, pe=nothing, slayer=slayer_result, local_stability, coil_sensitivities=nothing, monte_carlo=nothing, locking_risk=nothing, efc_couplings=nothing) end # PE (and SLAYER, which appends after it) writes to PE's own output filename when set, @@ -365,7 +396,7 @@ function main_from_inputs( # TODO: Do not allow perturbed equilibrium calculations if zero crossings are found - return (; ffs=ffs_result, pe=pe_state, slayer=slayer_result, coil_sensitivities, monte_carlo, locking_risk, efc_couplings) + return (; ffs=ffs_result, pe=pe_state, slayer=slayer_result, local_stability, coil_sensitivities, monte_carlo, locking_risk, efc_couplings) end @@ -549,24 +580,6 @@ function snapshot_forcing_modes( return forcing_modes_snapshot end -""" - run_local_stability(ctrl, equil) -> (locstab, ballooning_boundary) - -Run the LocalStability stage when `local_stability_flag` is set. `locstab` holds `D_I` from the -ballooning coefficient system and the local ballooning result, `ballooning_boundary` the first -α-vs-ψ_N stability boundary; both are empty placeholders when the stage is off. -""" -function run_local_stability(ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium) - locstab = nothing - ballooning_boundary = (psi=Float64[], alpha=Float64[], alpha_critical=Float64[]) - if ctrl.local_stability_flag - locstab = LocalStability.compute_local_stability(equil; verbose=ctrl.verbose) - # First ballooning stability boundary (α vs ψ_N) for BALOO-style diagnostics. - ballooning_boundary = LocalStability.ballooning_alpha_boundary(equil; verbose=ctrl.verbose) - end - return locstab, ballooning_boundary -end - # Kinetic profiles for the overlap scan come from the [SLAYER] section, through the same control # and loader the SLAYER analysis itself uses, so the scan sees the same profiles, the same HDF5 # group and the same chi(psi). Returns `nothing` when there is no usable [SLAYER] profile_file. @@ -909,6 +922,8 @@ function EulerLagrangeProblem( kwargs... ) ctrl_kwargs = Dict{Symbol,Any}(kwargs) + haskey(ctrl_kwargs, :local_stability_flag) && + error("local stability is its own stage now; drop local_stability_flag and call solve(LocalStabilityProblem(equil), Ballooning())") (haskey(ctrl_kwargs, :nn_low) || haskey(ctrl_kwargs, :nn_high)) && error("the toroidal mode range comes from the `nn` keyword; drop nn_low/nn_high") ctrl_kwargs[:nn_low] = first(nn) @@ -965,12 +980,11 @@ function solve(prob::EulerLagrangeProblem, alg::ForceFreeStates.AbstractIntegrat equil = maybe_reform_equilibrium(equil, equil.config, nothing, intr, ctrl, nothing) end - locstab, ballooning_boundary = run_local_stability(ctrl, equil) metric, mats, _ = prepare_force_free_states!(intr, ctrl, equil, kf_ctrl, nothing) result = run_force_free_states(ctrl, equil, mats, intr, metric) if ctrl.write_outputs_to_HDF5 - write_outputs_to_HDF5(result; locstab=locstab, ballooning_boundary=ballooning_boundary) + write_outputs_to_HDF5(result) @info "Results written to $(ctrl.HDF5_filename)" end @@ -1492,7 +1506,7 @@ end """ write_outputs_to_HDF5(result::ForceFreeStatesResult; git_version, inputs, forcing_modes, - locstab, ballooning_boundary) + local_stability) Write the HDF5 output file with the run, equilibrium and stability products carried by a force-free-states `result`. This combines the functionality of several pieces of the Fortran @@ -1507,8 +1521,8 @@ The solution-adjacent datasets come from two independent sources: the dense instead), and ψ, q, the step counts, `crit`, the asymptotic coefficients and the edge scan from `result.diagnostics`. Either may be absent, and is then written empty. -`locstab` and `ballooning_boundary` come from the LocalStability stage, which is not part of -the force-free-states solve. +`local_stability` is the `LocalStabilityResult` of the separate LocalStability stage, or +`nothing` when it did not run. ### TODOs @@ -1519,8 +1533,7 @@ function write_outputs_to_HDF5( git_version::String="unknown", inputs::Union{Nothing,Dict{String,Any}}=nothing, forcing_modes::Union{Nothing,Vector{ForcingTerms.ForcingMode}}=nothing, - locstab::Union{FastInterpolations.CubicSeriesInterpolant,Nothing}=nothing, - ballooning_boundary=(psi=Float64[], alpha=Float64[], alpha_critical=Float64[]), + local_stability::Union{LocalStabilityResult,Nothing}=nothing, layer_overlap=nothing ) @@ -1623,25 +1636,13 @@ function write_outputs_to_HDF5( # LocalStability/D_I = Mercier D_I (det(d0bar)); LocalStability/D_R = resistive interchange D_R; # LocalStability/ballooning_Delta_prime = high-n ballooning Δ' (distinct from the Riccati # tearing Δ' under PerturbedEquilibrium/SingularCoupling/Delta_prime). - if locstab !== nothing - locstab_xs = locstab.cache.x - out_h5["LocalStability/psi"] = collect(locstab_xs) # cached vector → dense for HDF5 - out_h5["LocalStability/D_I"] = locstab.y[:, 1] ./ locstab_xs - out_h5["LocalStability/D_R"] = locstab.y[:, 2] ./ locstab_xs - else - out_h5["LocalStability/psi"] = Float64[] - out_h5["LocalStability/D_I"] = Float64[] - out_h5["LocalStability/D_R"] = Float64[] + # The α boundary (ballooning_psi, alpha, alpha_critical) is on its own scan grid, distinct from LocalStability/psi. + ls = local_stability + for name in (:psi, :D_I, :D_R, :ballooning_Delta_prime, :ballooning_psi, :alpha, :alpha_critical) + out_h5["LocalStability/$name"] = ls === nothing ? Float64[] : getfield(ls, name) end - out_h5["SingularSurfaces/D_I"] = (locstab !== nothing && !isempty(result.surfaces)) ? - [locstab(sing.psifac)[1] / sing.psifac for sing in result.surfaces] : Float64[] - out_h5["LocalStability/ballooning_Delta_prime"] = locstab !== nothing ? locstab.y[:, 4] : Float64[] - - # First ballooning stability boundary: experimental α vs critical α (BALOO-style). - # Its own scan grid, distinct from the LocalStability/psi profile grid above. - out_h5["LocalStability/ballooning_psi"] = ballooning_boundary.psi - out_h5["LocalStability/alpha"] = ballooning_boundary.alpha - out_h5["LocalStability/alpha_critical"] = ballooning_boundary.alpha_critical + out_h5["SingularSurfaces/D_I"] = (ls !== nothing && !isempty(result.surfaces)) ? + [LocalStability.mercier_D_I(ls, sing.psifac) for sing in result.surfaces] : Float64[] # Resistive-layer overlap scan. Written whenever the scan ran, whether or not it # constrained the domain, so a run always shows where layer physics would have cut. diff --git a/src/HDF5Schema.jl b/src/HDF5Schema.jl index 7b0f6b5c9..b86c4e4e2 100644 --- a/src/HDF5Schema.jl +++ b/src/HDF5Schema.jl @@ -282,6 +282,7 @@ const MAIN_H5_ANNOTATIONS = [ const RUNTIME_H5_ANNOTATIONS = [ "Info/Runtimes/equilibrium" => (; long_name="wall-clock time of the Equilibrium construction stage", units="s"), "Info/Runtimes/galerkin" => (; long_name="wall-clock time of the Galerkin outer-region solve (measured inside force_free_states, not additional to it)", units="s"), + "Info/Runtimes/local_stability" => (; long_name="wall-clock time of the LocalStability stage (Mercier, resistive interchange and ballooning scans)", units="s"), "Info/Runtimes/force_free_states" => (; long_name="wall-clock time of the ForceFreeStates stability stage", units="s"), "Info/Runtimes/tearing" => (; long_name="wall-clock time of the Tearing stage (SLAYER inner-layer solve)", units="s"), "Info/Runtimes/forcing_terms" => (; diff --git a/src/LocalStability/Ballooning.jl b/src/LocalStability/Ballooning.jl index 9979b913b..2c33a2d9a 100644 --- a/src/LocalStability/Ballooning.jl +++ b/src/LocalStability/Ballooning.jl @@ -79,19 +79,6 @@ function compute_ballooning_stability!( end -""" - compute_local_stability(plasma_eq; verbose=false) -> CubicSeriesInterpolant - -Local stability profile spline over `plasma_eq.profiles.xs`, with the columns filled by -[`compute_ballooning_stability!`](@ref): 1 = `D_I·ψ`, 2 = `D_R·ψ`, 4 = ballooning `Δ'`. -""" -function compute_local_stability(plasma_eq::Equilibrium.PlasmaEquilibrium; verbose::Bool=false) - xs = plasma_eq.profiles.xs - locstab_fs = zeros(Float64, length(xs), 5) - compute_ballooning_stability!(locstab_fs, plasma_eq; verbose=verbose) - return cubic_interp(xs, Series(locstab_fs); extrap=ExtendExtrap()) -end - """ resistive_interchange_h(flux_surface_index, plasma_eq) diff --git a/src/LocalStability/LocalStability.jl b/src/LocalStability/LocalStability.jl index e68c8029a..6aa492c38 100644 --- a/src/LocalStability/LocalStability.jl +++ b/src/LocalStability/LocalStability.jl @@ -15,6 +15,6 @@ include("Ballooning.jl") include("Problem.jl") export LocalStabilityProblem, Ballooning, LocalStabilityResult, mercier_D_I -export compute_local_stability, compute_ballooning_stability!, ballooning_alpha_boundary, ballooning_alpha_boundaries +export compute_ballooning_stability!, ballooning_alpha_boundary, ballooning_alpha_boundaries end diff --git a/test/runtests_localstability.jl b/test/runtests_localstability.jl index c52769b92..dd05b59ad 100644 --- a/test/runtests_localstability.jl +++ b/test/runtests_localstability.jl @@ -1,3 +1,4 @@ +using HDF5 using TOML # The LocalStability stage on the coarse Solovev fixture deck (mpsi=16): the named profiles of @@ -64,4 +65,60 @@ using TOML @test isempty(readdir(dir)) end end + + # Copy the fixture deck into `dir` with HDF5 output on, no [LocalStability] section, and the + # given `[ForceFreeStates]` keys and extra sections. + function _deck(dir; ffs_extra=Dict{String,Any}(), sections=Dict{String,Any}()) + for name in readdir(template) + cp(joinpath(template, name), joinpath(dir, name)) + end + toml_path = joinpath(dir, "gpec.toml") + inputs = TOML.parsefile(toml_path) + delete!(inputs, "LocalStability") + inputs["ForceFreeStates"]["write_outputs_to_HDF5"] = true + merge!(inputs["ForceFreeStates"], ffs_extra) + merge!(inputs, sections) + open(io -> TOML.print(io, inputs), toml_path, "w") + return dir + end + _h5_local_stability(dir) = h5open(joinpath(dir, "gpec.h5"), "r") do f + Dict(k => read(f, "LocalStability/$k") for k in keys(f["LocalStability"])) + end + quiet_section = Dict{String,Any}("LocalStability" => Dict{String,Any}("verbose" => false)) + + @testset "deck section and solve give identical results" begin + mktempdir() do dir + run = GPEC.main([_deck(dir; sections=quiet_section)]) + for name in fieldnames(LocalStabilityResult) + name === :di_psi && continue + @test isequal(getfield(run.local_stability, name), getfield(res, name)) + end + h5 = _h5_local_stability(dir) + @test h5["D_I"] == res.D_I + @test isequal(h5["alpha_critical"], res.alpha_critical) + sing_di = h5open(f -> read(f, "SingularSurfaces/D_I"), joinpath(dir, "gpec.h5")) + @test !isempty(sing_di) + @test sing_di == [LS.mercier_D_I(res, s.psifac) for s in run.ffs.surfaces] + end + end + + @testset "no section, no stage" begin + mktempdir() do dir + run = GPEC.main([_deck(dir)]) + @test run.local_stability === nothing + @test all(isempty, values(_h5_local_stability(dir))) + end + end + + @testset "deck errors" begin + mktempdir() do dir + @test_throws MethodError GPEC.main([_deck(dir; ffs_extra=Dict{String,Any}("local_stability_flag" => true))]) + end + @test_throws ArgumentError GPEC.local_stability_algorithm(Dict{String,Any}("LocalStability" => Dict{String,Any}("n_scna" => 32))) + alg = GPEC.local_stability_algorithm(Dict{String,Any}("LocalStability" => Dict{String,Any}("psi_window" => [0.2, 0.8], "n_scan" => 32))) + @test alg.psi_window === (0.2, 0.8) + @test alg.n_scan == 32 + @test GPEC.local_stability_algorithm(Dict{String,Any}()) === nothing + @test_throws ErrorException EulerLagrangeProblem(equil; nn=1, local_stability_flag=true) + end end diff --git a/test/runtests_rerun_from_h5.jl b/test/runtests_rerun_from_h5.jl index 13172216b..d23a93957 100644 --- a/test/runtests_rerun_from_h5.jl +++ b/test/runtests_rerun_from_h5.jl @@ -112,6 +112,14 @@ end end end + @testset "the LocalStability section replays" begin + mktempdir() do out_dir + inputs, = GeneralizedPerturbedEquilibrium.build_inputs_from_h5([source_h5, "--output-dir", out_dir]) + @test haskey(inputs, "LocalStability") + @test !isempty(h5open(f -> read(f, "LocalStability/D_I"), source_h5)) + end + end + @testset "rerun refuses to overwrite its own source" begin # Default output is `_rerun.h5`, so clobbering only happens if the user # routes the rerun back onto the source file; exercise that guard. diff --git a/test/test_data/regression_solovev_ideal_example/gpec.toml b/test/test_data/regression_solovev_ideal_example/gpec.toml index 2e1607f7a..5496d83f0 100644 --- a/test/test_data/regression_solovev_ideal_example/gpec.toml +++ b/test/test_data/regression_solovev_ideal_example/gpec.toml @@ -24,8 +24,11 @@ triangularity = 0.5 # Triangularity parameter for wall shape corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +verbose = false # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml b/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml index 9ba608c55..517826893 100644 --- a/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml +++ b/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml @@ -24,8 +24,11 @@ triangularity = 0.5 # Triangularity parameter for wall shape corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +verbose = false # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/test/test_data/regression_solovev_kinetic_calculated/gpec.toml b/test/test_data/regression_solovev_kinetic_calculated/gpec.toml index a05caa035..c2b0dcaa3 100644 --- a/test/test_data/regression_solovev_kinetic_calculated/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_calculated/gpec.toml @@ -25,8 +25,11 @@ triangularity = 0.5 # Triangularity parameter for wall shape corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +verbose = false # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/test/test_data/regression_solovev_kinetic_example/gpec.toml b/test/test_data/regression_solovev_kinetic_example/gpec.toml index 92684ed25..e528e50cb 100644 --- a/test/test_data/regression_solovev_kinetic_example/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_example/gpec.toml @@ -24,8 +24,11 @@ triangularity = 0.5 # Triangularity parameter for wall shape corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +verbose = false # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml b/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml index e70b6cccc..0d6fde149 100644 --- a/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml @@ -24,8 +24,11 @@ triangularity = 0.5 # Triangularity parameter for wall shape corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +verbose = false # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml b/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml index 5df1debce..7fd01216f 100644 --- a/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml @@ -25,8 +25,11 @@ triangularity = 0.5 # Triangularity parameter for wall shape corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +verbose = false # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable From 7d2cbbe5836ebca7dad30f8b7c5d897571b3da7a Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Fri, 23 Oct 2026 16:06:35 +0200 Subject: [PATCH 09/60] Examples - MINOR - Move every deck's local_stability_flag to a [LocalStability] section --- benchmarks/benchmark_against_fortran_run.jl | 6 ++++-- benchmarks/benchmark_diiid_ideal_ntv_torque.jl | 3 ++- benchmarks/benchmark_diiid_kinetic_stability.jl | 3 ++- docs/development/architecture.md | 3 ++- docs/src/stability.md | 11 ++++++----- examples/DIIID-like_SLAYER_example/gpec.toml | 12 +++++++++++- examples/DIIID-like_error_field_example/gpec.toml | 12 +++++++++++- examples/DIIID-like_gal_resistive_example/gpec.toml | 12 +++++++++++- .../DIIID-like_gal_resistive_pe_example/gpec.toml | 12 +++++++++++- examples/DIIID-like_ideal_example/gpec.toml | 12 +++++++++++- examples/DIIID-like_ideal_example_IMAS/gpec.toml | 1 - .../run_imas_example.jl | 1 - examples/DIIID-like_ideal_multi_n_example/gpec.toml | 12 +++++++++++- .../DIIID-like_riccati_deltaprime_example/gpec.toml | 12 +++++++++++- examples/DIIID-like_truncation_example/gpec.toml | 1 - examples/LAR_beta_scan/gpec.toml | 12 +++++++++++- examples/LAR_epsilon_scan/gpec.toml | 12 +++++++++++- examples/LAR_ideal_match_test/gpec.toml | 12 +++++++++++- examples/LAR_resistive_match_test/gpec.toml | 12 +++++++++++- examples/Solovev_ideal_example/gpec.toml | 12 +++++++++++- examples/Solovev_ideal_example_3D/gpec.toml | 12 +++++++++++- examples/Solovev_ideal_example_multi_n/gpec.toml | 12 +++++++++++- .../single_n_1/gpec.toml | 12 +++++++++++- .../single_n_2/gpec.toml | 12 +++++++++++- examples/Solovev_kinetic_NTV_example/gpec.toml | 12 +++++++++++- .../Solovev_kinetic_calculated_example/gpec.toml | 12 +++++++++++- examples/Solovev_kinetic_multiion_example/gpec.toml | 12 +++++++++++- examples/a10_kinetic_example/gpec.toml | 12 +++++++++++- regression-harness/cases/diiid_n1.toml | 2 +- .../regression_solovev_ideal_example/gpec.toml | 7 +++++++ .../gpec.toml | 7 +++++++ .../regression_solovev_kinetic_calculated/gpec.toml | 7 +++++++ .../regression_solovev_kinetic_example/gpec.toml | 7 +++++++ .../regression_solovev_kinetic_multi_n/gpec.toml | 7 +++++++ .../regression_solovev_kinetic_nuzero/gpec.toml | 7 +++++++ 35 files changed, 279 insertions(+), 34 deletions(-) diff --git a/benchmarks/benchmark_against_fortran_run.jl b/benchmarks/benchmark_against_fortran_run.jl index 841995301..7adfffc22 100644 --- a/benchmarks/benchmark_against_fortran_run.jl +++ b/benchmarks/benchmark_against_fortran_run.jl @@ -409,8 +409,9 @@ function write_gpec_toml_coil( println(io, "[Wall]") println(io, "shape = \"nowall\"") println(io) + println(io, "[LocalStability]") + println(io) println(io, "[ForceFreeStates]") - println(io, "local_stability_flag = true") println(io, "vac_flag = true") @printf(io, "qlow = %.4f\n", p.qlow) # Match the Fortran sas_flag truncation: integration stops at q = qhigh @@ -472,8 +473,9 @@ function write_gpec_toml_file( println(io, "[Wall]") println(io, "shape = \"nowall\"") println(io) + println(io, "[LocalStability]") + println(io) println(io, "[ForceFreeStates]") - println(io, "local_stability_flag = true") println(io, "vac_flag = true") @printf(io, "qlow = %.4f\n", p.qlow) @printf(io, "dW_edge_scan_start = %.4f\n", p.psiedge) diff --git a/benchmarks/benchmark_diiid_ideal_ntv_torque.jl b/benchmarks/benchmark_diiid_ideal_ntv_torque.jl index 430c353cc..ade40501f 100644 --- a/benchmarks/benchmark_diiid_ideal_ntv_torque.jl +++ b/benchmarks/benchmark_diiid_ideal_ntv_torque.jl @@ -128,8 +128,9 @@ flux_surface_rtol = 1e-7 [Wall] shape = "nowall" +[LocalStability] + [ForceFreeStates] -local_stability_flag = true vac_flag = true force_termination = false diff --git a/benchmarks/benchmark_diiid_kinetic_stability.jl b/benchmarks/benchmark_diiid_kinetic_stability.jl index 1c87d9814..21db14304 100644 --- a/benchmarks/benchmark_diiid_kinetic_stability.jl +++ b/benchmarks/benchmark_diiid_kinetic_stability.jl @@ -135,8 +135,9 @@ flux_surface_rtol = 1e-7 [Wall] shape = "nowall" +[LocalStability] + [ForceFreeStates] -local_stability_flag = true vac_flag = true force_termination = true # Skip PE+KF post-processing — we only need FFS eigenvalues diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 059539a8b..ba99748f5 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -72,7 +72,8 @@ Splines are provided by the external `FastInterpolations` package rather than by 5. **LocalStability** (`src/LocalStability/`) - Local high-n stability - `Ballooning.jl` - Local stability scan: Mercier D_I, resistive interchange D_R, and high-n ballooning Δ' (s–α). Replaces the former standalone `Mercier.jl`. - Depends only on Equilibrium (plus math libraries); carries no stability-solver state - - Main entry points: `compute_local_stability`, `ballooning_alpha_boundary` + - `Problem.jl` - `solve(LocalStabilityProblem(equil), Ballooning())` → `LocalStabilityResult`; a deck's `[LocalStability]` section runs the same solve + - Kernels: `compute_ballooning_stability!`, `ballooning_alpha_boundary` - Status: Stable ### Perturbed Equilibrium Modules diff --git a/docs/src/stability.md b/docs/src/stability.md index b056753ab..8d845b5db 100644 --- a/docs/src/stability.md +++ b/docs/src/stability.md @@ -156,8 +156,12 @@ integrator = "galerkin" ## Local stability: Mercier and ballooning (s–α) -Setting `local_stability_flag = true` in `[ForceFreeStates]` runs a local high-``n`` -stability scan over every flux surface, in addition to the global ideal analysis above. +A `[LocalStability]` section in the deck runs a local high-``n`` stability scan over every +flux surface, as its own stage before the global ideal analysis above. The section's keys are +the [`Ballooning`](@ref) keywords (the ballooning angle, ODE tolerance and the α-boundary scan +knobs), and a script runs the same stage with `solve(LocalStabilityProblem(eq), Ballooning())` +(see the [scripting API](api.md)). The section replaces the removed `local_stability_flag` +of `[ForceFreeStates]`. For the derivation and implementation details behind these diagnostics, see [Ballooning and Mercier Local Stability](ballooning.md). Three diagnostics are produced and stored under the `LocalStability/` HDF5 group, each a profile @@ -379,9 +383,6 @@ numsteps_init = 200 # initial step budget per chunk numunorms_init = 50 # renorm checkpoint budget reltol = 1e-6 # ODE relative tolerance -# Local stability -local_stability_flag = false # scan Mercier D_I, resistive D_R, and ballooning Δ' over ψ - # Output verbose = true write_outputs_to_HDF5 = true diff --git a/examples/DIIID-like_SLAYER_example/gpec.toml b/examples/DIIID-like_SLAYER_example/gpec.toml index 08df13c11..26bb5712d 100644 --- a/examples/DIIID-like_SLAYER_example/gpec.toml +++ b/examples/DIIID-like_SLAYER_example/gpec.toml @@ -28,9 +28,19 @@ triangularity = 0.5 # Triangularity parameter for wall shapes corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] force_termination = true # Run FFS + SLAYER, skip PerturbedEquilibrium -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/examples/DIIID-like_error_field_example/gpec.toml b/examples/DIIID-like_error_field_example/gpec.toml index a4f303562..fe054e619 100644 --- a/examples/DIIID-like_error_field_example/gpec.toml +++ b/examples/DIIID-like_error_field_example/gpec.toml @@ -32,8 +32,18 @@ triangularity = 0.5 # Triangularity parameter for wall shapes corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/examples/DIIID-like_gal_resistive_example/gpec.toml b/examples/DIIID-like_gal_resistive_example/gpec.toml index dac772545..d67b7bcb2 100644 --- a/examples/DIIID-like_gal_resistive_example/gpec.toml +++ b/examples/DIIID-like_gal_resistive_example/gpec.toml @@ -28,8 +28,18 @@ triangularity = 0.5 # Triangularity parameter for wall shapes corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/examples/DIIID-like_gal_resistive_pe_example/gpec.toml b/examples/DIIID-like_gal_resistive_pe_example/gpec.toml index a93549f3b..661dd0900 100644 --- a/examples/DIIID-like_gal_resistive_pe_example/gpec.toml +++ b/examples/DIIID-like_gal_resistive_pe_example/gpec.toml @@ -28,8 +28,18 @@ triangularity = 0.5 # Triangularity parameter for wall shapes corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/examples/DIIID-like_ideal_example/gpec.toml b/examples/DIIID-like_ideal_example/gpec.toml index 448ac5d07..5eaf100c7 100644 --- a/examples/DIIID-like_ideal_example/gpec.toml +++ b/examples/DIIID-like_ideal_example/gpec.toml @@ -27,8 +27,18 @@ triangularity = 0.5 # Triangularity parameter for wall shapes corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/examples/DIIID-like_ideal_example_IMAS/gpec.toml b/examples/DIIID-like_ideal_example_IMAS/gpec.toml index 0893460a2..b1e8c9119 100644 --- a/examples/DIIID-like_ideal_example_IMAS/gpec.toml +++ b/examples/DIIID-like_ideal_example_IMAS/gpec.toml @@ -24,7 +24,6 @@ shape = "nowall" # Wall shape (nowall, conformal, elliptical, dee, nn_low = 1 # Smallest toroidal mode number to include nn_high = 1 # Largest toroidal mode number to include vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes -local_stability_flag = false # Perform local stability analysis (Mercier and ballooning) across the ψ profile write_outputs_to_HDF5 = false # Write outputs to the HDF5 file verbose = false # Enable verbose logging delta_mhigh = 16 # Expands upper bound of Fourier harmonics diff --git a/examples/DIIID-like_ideal_example_IMAS/run_imas_example.jl b/examples/DIIID-like_ideal_example_IMAS/run_imas_example.jl index c4fe1637f..ef01d67eb 100644 --- a/examples/DIIID-like_ideal_example_IMAS/run_imas_example.jl +++ b/examples/DIIID-like_ideal_example_IMAS/run_imas_example.jl @@ -29,7 +29,6 @@ const SHARED_CONFIG = Dict( "nn_low" => 1, "nn_high" => 1, "vac_flag" => true, - "local_stability_flag" => false, "write_outputs_to_HDF5" => false, "verbose" => false, "delta_mhigh" => 16, diff --git a/examples/DIIID-like_ideal_multi_n_example/gpec.toml b/examples/DIIID-like_ideal_multi_n_example/gpec.toml index 325a24854..6b6c8b541 100644 --- a/examples/DIIID-like_ideal_multi_n_example/gpec.toml +++ b/examples/DIIID-like_ideal_multi_n_example/gpec.toml @@ -28,8 +28,18 @@ triangularity = 0.5 # Triangularity parameter for wall shapes corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/examples/DIIID-like_riccati_deltaprime_example/gpec.toml b/examples/DIIID-like_riccati_deltaprime_example/gpec.toml index 2c369b253..8ed214bd9 100644 --- a/examples/DIIID-like_riccati_deltaprime_example/gpec.toml +++ b/examples/DIIID-like_riccati_deltaprime_example/gpec.toml @@ -28,8 +28,18 @@ triangularity = 0.5 # Triangularity parameter for wall shapes corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes force_termination = true # Terminate after force-free states (skip perturbed equilibrium calculations) diff --git a/examples/DIIID-like_truncation_example/gpec.toml b/examples/DIIID-like_truncation_example/gpec.toml index eb2180e03..341c01263 100644 --- a/examples/DIIID-like_truncation_example/gpec.toml +++ b/examples/DIIID-like_truncation_example/gpec.toml @@ -21,7 +21,6 @@ flux_surface_rtol = 1e-10 # Relative tolerance of the ODE solves that trace force_termination = false # Terminate after equilibrium setup (skip stability calculations) [ForceFreeStates] -local_stability_flag = false # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes force_termination = true # Terminate after force-free states (skip perturbed equilibrium calculations) diff --git a/examples/LAR_beta_scan/gpec.toml b/examples/LAR_beta_scan/gpec.toml index da0e40fe1..bfd5ed0e5 100644 --- a/examples/LAR_beta_scan/gpec.toml +++ b/examples/LAR_beta_scan/gpec.toml @@ -33,8 +33,18 @@ mtheta = 128 # TJ-analytic internal poloidal grid resolution ( shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) standoff_fraction = 20 # Wall standoff in plasma minor radii (conformal, dee) (20× → effectively no wall) +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes qlow = 1.02 # Integration initiated at q determined by min(q0, qlow) diff --git a/examples/LAR_epsilon_scan/gpec.toml b/examples/LAR_epsilon_scan/gpec.toml index ee6e3709c..e7ace3ad5 100644 --- a/examples/LAR_epsilon_scan/gpec.toml +++ b/examples/LAR_epsilon_scan/gpec.toml @@ -34,8 +34,18 @@ mtheta = 128 # TJ-analytic internal poloidal grid resolution ( shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) standoff_fraction = 20 # Wall standoff in plasma minor radii (conformal, dee) (20× → effectively no wall) +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes qlow = 1.02 # Integration initiated at q determined by min(q0, qlow) diff --git a/examples/LAR_ideal_match_test/gpec.toml b/examples/LAR_ideal_match_test/gpec.toml index b24604a11..7f4378750 100644 --- a/examples/LAR_ideal_match_test/gpec.toml +++ b/examples/LAR_ideal_match_test/gpec.toml @@ -30,9 +30,19 @@ mtheta = 128 # Poloidal grid points shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) standoff_fraction = 20 # Wall standoff in plasma minor radii (conformal, dee) +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] HDF5_filename = "gpec_lar_ideal.h5" # Output filename for the stability-stage results -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the psi profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes qlow = 1.02 # Integration initiated at q determined by min(q0, qlow) diff --git a/examples/LAR_resistive_match_test/gpec.toml b/examples/LAR_resistive_match_test/gpec.toml index 1e2bb7ed6..23bf53ba5 100644 --- a/examples/LAR_resistive_match_test/gpec.toml +++ b/examples/LAR_resistive_match_test/gpec.toml @@ -31,9 +31,19 @@ mtheta = 128 # Poloidal grid points shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) standoff_fraction = 20 # Wall standoff in plasma minor radii (conformal, dee) +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] HDF5_filename = "gpec_lar_resistive.h5" # Output filename for the stability-stage results -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the psi profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes qlow = 1.02 # Integration initiated at q determined by min(q0, qlow) diff --git a/examples/Solovev_ideal_example/gpec.toml b/examples/Solovev_ideal_example/gpec.toml index a42fe4cb0..bed91d59c 100644 --- a/examples/Solovev_ideal_example/gpec.toml +++ b/examples/Solovev_ideal_example/gpec.toml @@ -49,8 +49,18 @@ regularization_width = 0.05 # Regularization width for singular surf # The SLAYER example and regression case use the DIII-D-like geqdsk instead # (see examples/DIIID-like_SLAYER_example). +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/examples/Solovev_ideal_example_3D/gpec.toml b/examples/Solovev_ideal_example_3D/gpec.toml index ab1909e15..fa8d52730 100644 --- a/examples/Solovev_ideal_example_3D/gpec.toml +++ b/examples/Solovev_ideal_example_3D/gpec.toml @@ -14,8 +14,18 @@ newq0 = 0 # Target on-axis safety factor q(0) (0 = flux_surface_rtol = 1e-7 # Relative tolerance of the ODE solves that trace each flux surface force_termination = false # Terminate after equilibrium setup (skip stability calculations) +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 1.0 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/examples/Solovev_ideal_example_multi_n/gpec.toml b/examples/Solovev_ideal_example_multi_n/gpec.toml index 0150e1b73..949579bf6 100644 --- a/examples/Solovev_ideal_example_multi_n/gpec.toml +++ b/examples/Solovev_ideal_example_multi_n/gpec.toml @@ -26,8 +26,18 @@ triangularity = 0.5 # Triangularity parameter for wall shape corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/examples/Solovev_ideal_example_multi_n/single_n_1/gpec.toml b/examples/Solovev_ideal_example_multi_n/single_n_1/gpec.toml index 472546634..19a2cfeb7 100644 --- a/examples/Solovev_ideal_example_multi_n/single_n_1/gpec.toml +++ b/examples/Solovev_ideal_example_multi_n/single_n_1/gpec.toml @@ -25,8 +25,18 @@ triangularity = 0.5 # Triangularity parameter for wall shape corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/examples/Solovev_ideal_example_multi_n/single_n_2/gpec.toml b/examples/Solovev_ideal_example_multi_n/single_n_2/gpec.toml index 97f108a5a..c8a2708d0 100644 --- a/examples/Solovev_ideal_example_multi_n/single_n_2/gpec.toml +++ b/examples/Solovev_ideal_example_multi_n/single_n_2/gpec.toml @@ -25,8 +25,18 @@ triangularity = 0.5 # Triangularity parameter for wall shape corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) equal_arc_wall = true # Equal arc length distribution of nodes on wall +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/examples/Solovev_kinetic_NTV_example/gpec.toml b/examples/Solovev_kinetic_NTV_example/gpec.toml index cd02c559f..729a5c779 100644 --- a/examples/Solovev_kinetic_NTV_example/gpec.toml +++ b/examples/Solovev_kinetic_NTV_example/gpec.toml @@ -46,8 +46,18 @@ verbose = true # Enable verbose logging write_outputs_to_HDF5 = true # Write perturbed equilibrium outputs to HDF5 regularization_width = 0.05 # Regularization width for singular surfaces (0 = disabled) +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/examples/Solovev_kinetic_calculated_example/gpec.toml b/examples/Solovev_kinetic_calculated_example/gpec.toml index a6bce31b9..6fbc910e8 100644 --- a/examples/Solovev_kinetic_calculated_example/gpec.toml +++ b/examples/Solovev_kinetic_calculated_example/gpec.toml @@ -41,8 +41,18 @@ verbose = false # Enable verbose logging write_outputs_to_HDF5 = true # Write perturbed equilibrium outputs to HDF5 regularization_width = 0.05 # Regularization width for singular surfaces (0 = disabled) +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = false # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/examples/Solovev_kinetic_multiion_example/gpec.toml b/examples/Solovev_kinetic_multiion_example/gpec.toml index bee59220e..37e9cc924 100644 --- a/examples/Solovev_kinetic_multiion_example/gpec.toml +++ b/examples/Solovev_kinetic_multiion_example/gpec.toml @@ -46,9 +46,19 @@ verbose = true # Enable verbose logging write_outputs_to_HDF5 = true # Write perturbed equilibrium outputs to HDF5 regularization_width = 0.05 # Regularization width for singular surfaces (0 = disabled) +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] integrator = "forward" # Dense ξ profiles required by PerturbedEquilibrium and kinetic torque diagnostics -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes dW_edge_scan_start = 0.99 # Edge dW(ψ) diagnostic scan band [dW_edge_scan_start, psilim]; set ≥ psilim to disable diff --git a/examples/a10_kinetic_example/gpec.toml b/examples/a10_kinetic_example/gpec.toml index c7a32b36c..dd15b8013 100644 --- a/examples/a10_kinetic_example/gpec.toml +++ b/examples/a10_kinetic_example/gpec.toml @@ -21,8 +21,18 @@ flux_surface_rtol = 1e-7 # Relative tolerance of the ODE solves that trace [Wall] shape = "nowall" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) +# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords +[LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN +verbose = true # Print progress and per-surface α-scan failures + [ForceFreeStates] -local_stability_flag = true # Perform local stability analysis (Mercier and ballooning) across the ψ profile vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes truncate_at_rational_offset = false # FALSE for limited/analytical equilibria — rationals sparse, rational_offset_fraction would chop too much edge diff --git a/regression-harness/cases/diiid_n1.toml b/regression-harness/cases/diiid_n1.toml index 1e033e5ca..72ccadc89 100644 --- a/regression-harness/cases/diiid_n1.toml +++ b/regression-harness/cases/diiid_n1.toml @@ -396,7 +396,7 @@ label = "pressure profile (checksum)" noise_threshold = 0 order = 71 -# Local stability profiles (local_stability_flag = true). Checksums hash the full +# Local stability profiles (the [LocalStability] section). Checksums hash the full # profile so they are robust to NaN edge-surface entries and catch any change. [quantities.locstab_di] h5path = "LocalStability/D_I" diff --git a/test/test_data/regression_solovev_ideal_example/gpec.toml b/test/test_data/regression_solovev_ideal_example/gpec.toml index 5496d83f0..2dc2c057c 100644 --- a/test/test_data/regression_solovev_ideal_example/gpec.toml +++ b/test/test_data/regression_solovev_ideal_example/gpec.toml @@ -26,6 +26,13 @@ equal_arc_wall = true # Equal arc length distribution of nodes # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN verbose = false # Print progress and per-surface α-scan failures [ForceFreeStates] diff --git a/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml b/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml index 517826893..f87c07b1b 100644 --- a/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml +++ b/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml @@ -26,6 +26,13 @@ equal_arc_wall = true # Equal arc length distribution of nodes # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN verbose = false # Print progress and per-surface α-scan failures [ForceFreeStates] diff --git a/test/test_data/regression_solovev_kinetic_calculated/gpec.toml b/test/test_data/regression_solovev_kinetic_calculated/gpec.toml index c2b0dcaa3..30fd5f854 100644 --- a/test/test_data/regression_solovev_kinetic_calculated/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_calculated/gpec.toml @@ -27,6 +27,13 @@ equal_arc_wall = true # Equal arc length distribution of nodes # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN verbose = false # Print progress and per-surface α-scan failures [ForceFreeStates] diff --git a/test/test_data/regression_solovev_kinetic_example/gpec.toml b/test/test_data/regression_solovev_kinetic_example/gpec.toml index e528e50cb..af9bf78f8 100644 --- a/test/test_data/regression_solovev_kinetic_example/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_example/gpec.toml @@ -26,6 +26,13 @@ equal_arc_wall = true # Equal arc length distribution of nodes # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN verbose = false # Print progress and per-surface α-scan failures [ForceFreeStates] diff --git a/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml b/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml index 0d6fde149..bd31bef3e 100644 --- a/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml @@ -26,6 +26,13 @@ equal_arc_wall = true # Equal arc length distribution of nodes # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN verbose = false # Print progress and per-surface α-scan failures [ForceFreeStates] diff --git a/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml b/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml index 7fd01216f..0404c6025 100644 --- a/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml @@ -27,6 +27,13 @@ equal_arc_wall = true # Equal arc length distribution of nodes # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] +theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan +ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' +alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) +n_scan = 24 # Resolution of the α scan for the first boundary +max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α +alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples +psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN verbose = false # Print progress and per-surface α-scan failures [ForceFreeStates] From 59b4b4de92dbc95e5813dc2d227238740adae572 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Fri, 23 Oct 2026 16:25:51 +0200 Subject: [PATCH 10/60] LocalStability - MINOR - Drop the local_stability_flag checks and let the old key fail as an unknown keyword No backward-compatibility code: remove the EulerLagrangeProblem error for local_stability_flag, its deprecated-key pre-commit entry, the two tests asserting it errors, and the docs sentence naming it. Co-Authored-By: Claude Opus 5.5 --- .pre-commit-config.yaml | 2 +- docs/src/stability.md | 3 +-- src/GeneralizedPerturbedEquilibrium.jl | 2 -- test/runtests_localstability.jl | 6 +----- 4 files changed, 3 insertions(+), 10 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0fd39daad..5efde01b0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -77,5 +77,5 @@ repos: - id: toml-no-deprecated-keys name: 'TOML conventions: no deprecated config keys' language: pygrep - entry: '^(mer_flag|force_wv_symmetry|ode_flag|cyl_flag|mat_flag|reform_eq_with_psilim|use_riccati|use_parallel|parallel_threads|populate_dense_xi|gal_flag|nstep|diagnose_ca|diagnose|ksing|power_bp|power_b|power_r|power_rc|use_galgrid|local_stability_flag)\s*=' + entry: '^(mer_flag|force_wv_symmetry|ode_flag|cyl_flag|mat_flag|reform_eq_with_psilim|use_riccati|use_parallel|parallel_threads|populate_dense_xi|gal_flag|nstep|diagnose_ca|diagnose|ksing|power_bp|power_b|power_r|power_rc|use_galgrid)\s*=' files: ^(examples/.*\.toml|test/test_data/.*\.toml)$ diff --git a/docs/src/stability.md b/docs/src/stability.md index 8d845b5db..3f5701379 100644 --- a/docs/src/stability.md +++ b/docs/src/stability.md @@ -160,8 +160,7 @@ A `[LocalStability]` section in the deck runs a local high-``n`` stability scan flux surface, as its own stage before the global ideal analysis above. The section's keys are the [`Ballooning`](@ref) keywords (the ballooning angle, ODE tolerance and the α-boundary scan knobs), and a script runs the same stage with `solve(LocalStabilityProblem(eq), Ballooning())` -(see the [scripting API](api.md)). The section replaces the removed `local_stability_flag` -of `[ForceFreeStates]`. +(see the [scripting API](api.md)). For the derivation and implementation details behind these diagnostics, see [Ballooning and Mercier Local Stability](ballooning.md). Three diagnostics are produced and stored under the `LocalStability/` HDF5 group, each a profile diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index a10dfa379..356249b9d 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -922,8 +922,6 @@ function EulerLagrangeProblem( kwargs... ) ctrl_kwargs = Dict{Symbol,Any}(kwargs) - haskey(ctrl_kwargs, :local_stability_flag) && - error("local stability is its own stage now; drop local_stability_flag and call solve(LocalStabilityProblem(equil), Ballooning())") (haskey(ctrl_kwargs, :nn_low) || haskey(ctrl_kwargs, :nn_high)) && error("the toroidal mode range comes from the `nn` keyword; drop nn_low/nn_high") ctrl_kwargs[:nn_low] = first(nn) diff --git a/test/runtests_localstability.jl b/test/runtests_localstability.jl index dd05b59ad..2d2866bbb 100644 --- a/test/runtests_localstability.jl +++ b/test/runtests_localstability.jl @@ -110,15 +110,11 @@ using TOML end end - @testset "deck errors" begin - mktempdir() do dir - @test_throws MethodError GPEC.main([_deck(dir; ffs_extra=Dict{String,Any}("local_stability_flag" => true))]) - end + @testset "deck section parsing" begin @test_throws ArgumentError GPEC.local_stability_algorithm(Dict{String,Any}("LocalStability" => Dict{String,Any}("n_scna" => 32))) alg = GPEC.local_stability_algorithm(Dict{String,Any}("LocalStability" => Dict{String,Any}("psi_window" => [0.2, 0.8], "n_scan" => 32))) @test alg.psi_window === (0.2, 0.8) @test alg.n_scan == 32 @test GPEC.local_stability_algorithm(Dict{String,Any}()) === nothing - @test_throws ErrorException EulerLagrangeProblem(equil; nn=1, local_stability_flag=true) end end From ece661db3798a076f52852055c543addaa9e0e08 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Fri, 23 Oct 2026 16:38:15 +0200 Subject: [PATCH 11/60] Test - TEST - Drop the assertion that a removed Runtimes entry is absent Co-Authored-By: Claude Opus 5.5 --- test/runtests_error_fields.jl | 1 - 1 file changed, 1 deletion(-) diff --git a/test/runtests_error_fields.jl b/test/runtests_error_fields.jl index b9b143d2a..b1dd677d0 100644 --- a/test/runtests_error_fields.jl +++ b/test/runtests_error_fields.jl @@ -99,7 +99,6 @@ include("h5_metadata_check.jl") h5open(h5path, "r") do h5 @test haskey(h5, "Info/Runtimes/error_fields") @test read(h5["Info/Runtimes/error_fields"]) > 0 - @test !haskey(h5, "Info/Runtimes/forcing_terms") # timed from main, so only whole stages appear @test haskey(h5, "Info/Runtimes/perturbed_equilibrium") @test haskey(h5, "Info/Runtimes/kinetic_forces") @test haskey(h5, "Info/Runtimes/total") From c6b65120fd4d2a5d41747e562390823b68372fb8 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Fri, 23 Oct 2026 16:38:15 +0200 Subject: [PATCH 12/60] ForceFreeStates - REFACTOR! - Take the cut solution off the deck surface gal_cut_solution is no longer a ForceFreeStatesControl field or a deprecated deck key. Galerkin(; cut_solution=true) passes it to galerkin_solve as a solve option, so only a script can keep the cut solution. Co-Authored-By: Claude Opus 5.5 --- src/ForceFreeStates/CoreTypes.jl | 1 - src/ForceFreeStates/Galerkin/GalerkinSolve.jl | 9 +++++---- src/ForceFreeStates/Integrators.jl | 1 + src/GeneralizedPerturbedEquilibrium.jl | 13 +++++++------ 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/ForceFreeStates/CoreTypes.jl b/src/ForceFreeStates/CoreTypes.jl index ef6ad8d41..1419e4b20 100644 --- a/src/ForceFreeStates/CoreTypes.jl +++ b/src/ForceFreeStates/CoreTypes.jl @@ -201,7 +201,6 @@ gpec.toml. gal_sing_order_ceiling::Bool = true # auto-raise order by ceil(2·Re(α)) per surface (high Mercier index) gal_rpec_flag::Bool = false # append mpert coil-response columns to the Δ′ solve (RDCON rpec_flag): unit boundary sources whose plasma response is recorded; needed for the driven (resistive perturbed-equilibrium) Δ_gw gal_edge_onesided::Bool = false # pack the two end intervals one-sided toward their single rational end (vs the Fortran symmetric "both" pack); avoids the fine edge cell that inflates cond(A). Default false = faithful to gal.f. - gal_cut_solution::Bool = false # also keep the cut solution for a later match's composite inner profiles; set by Galerkin(; cut_solution=true), not a deck key # --- DRIVEN (RPEC) outer↔inner asymptotic matching (rmatch match_rpec port) --- gal_match_flag::Bool = false # match the inner layer on the finished solve (coil-driven ξ from Δ′ + inner Δ(Q)); needs gal_rpec_flag=true, or riccati with vac_flag=true gal_ideal_flag::Bool = false # within the match, build the IDEAL solution: skip the inner-layer Δ, use bare coil columns (cout=0). Mirrors Fortran rmatch coil%ideal_flag (the EL reference). eta/rho/rotation ignored. diff --git a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl index 5ab2d1569..e3c0a075f 100644 --- a/src/ForceFreeStates/Galerkin/GalerkinSolve.jl +++ b/src/ForceFreeStates/Galerkin/GalerkinSolve.jl @@ -46,7 +46,7 @@ end """ galerkin_solve(ctrl::ForceFreeStatesControl, equil, mats::MatrixSplines, - intr::ForceFreeStatesInternal; wv=nothing) -> (GalerkinResult, Union{Nothing,DeltaPrimeData}) + intr::ForceFreeStatesInternal; wv=nothing, cut_solution=false) -> (GalerkinResult, Union{Nothing,DeltaPrimeData}) Compute the outer-region Δ′ matching matrix by the singular Galerkin method. Port of `gal_solve` (gal.f). Single toroidal mode only (`intr.npert == 1`). Returns the solver internals as a @@ -54,10 +54,11 @@ Compute the outer-region Δ′ matching matrix by the singular Galerkin method. resonant surfaces in the domain it returns an empty result and `nothing`. `wv` is the vacuum energy matrix from `free_run`, which supplies the free-boundary edge term -`wv_edge = wv · psio²`; pass `nothing` for a fixed-boundary edge. +`wv_edge = wv · psio²`; pass `nothing` for a fixed-boundary edge. `cut_solution` also keeps the +cut solution, for a later `MatchProblem`'s composite inner profiles. """ function galerkin_solve(ctrl::ForceFreeStatesControl, equil, mats::MatrixSplines, - intr::ForceFreeStatesInternal; wv=nothing) + intr::ForceFreeStatesInternal; wv=nothing, cut_solution::Bool=false) intr.npert == 1 || error("galerkin_solve: only single-n (npert == 1) is supported") ctrl.gal_solver in ("LU", "cholesky") || error("galerkin_solve: gal_solver must be \"LU\" or \"cholesky\"") @@ -183,7 +184,7 @@ function galerkin_solve(ctrl::ForceFreeStatesControl, equil, mats::MatrixSplines # solution only when a later match needs the composite inner profiles. ctrl.verbose && @info "Reconstructing outer-region ξ and analytic ξ′ on the gal grid" solution = gal_output_solution(ws, asymps, sings, intr, equil.profiles, psihigh; - delta=(ctrl.gal_cut_solution ? delta : nothing)) + delta=(cut_solution ? delta : nothing)) sing_psi = [s.psifac for s in sings] sing_q = [s.q for s in sings] diff --git a/src/ForceFreeStates/Integrators.jl b/src/ForceFreeStates/Integrators.jl index 7ef4567e1..d5168177b 100644 --- a/src/ForceFreeStates/Integrators.jl +++ b/src/ForceFreeStates/Integrators.jl @@ -122,6 +122,7 @@ end function _apply_alg!(kwargs::Dict{Symbol,Any}, alg::Galerkin) _set_ctrl!(kwargs, :integrator, String(_integrator_symbol(alg)), alg) for name in fieldnames(Galerkin) + name === :cut_solution && continue # a solve option, not a control field _set_ctrl!(kwargs, Symbol(:gal_, name), getfield(alg, name), alg) end return kwargs diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 818e80f63..4ed6273df 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -94,7 +94,7 @@ using .ForcingTerms: RMPField const _DEPRECATED_FFS_KEYS = ("mer_flag", "force_wv_symmetry", "ode_flag", "cyl_flag", "mat_flag", "reform_eq_with_psilim", "use_riccati", "use_parallel", "parallel_threads", "populate_dense_xi", - "gal_flag", "nstep", "diagnose_ca", "diagnose", "ksing", "gal_cut_solution") + "gal_flag", "nstep", "diagnose_ca", "diagnose", "ksing") const _DEPRECATED_EQUIL_KEYS = ("power_bp", "power_b", "power_r", "power_rc", "use_galgrid") # Drop deprecated keys from a parsed gpec.toml section so legacy files keep parsing @@ -769,18 +769,19 @@ function prepare_force_free_states!( end """ - run_force_free_states(ctrl, equil, mats, intr, metric) -> ForceFreeStatesResult + run_force_free_states(ctrl, equil, mats, intr, metric; cut_solution=false) -> ForceFreeStatesResult Run the formalism selected by `ctrl.integrator` — the standalone Galerkin solve, or the Euler-Lagrange sweep with its free-boundary energies and Δ′ BVP — and publish its products as a -`ForceFreeStatesResult`. +`ForceFreeStatesResult`. `cut_solution` is the scripting-only `Galerkin(; cut_solution)` option. """ function run_force_free_states( ctrl::ForceFreeStatesControl, equil::Equilibrium.PlasmaEquilibrium, mats, intr::ForceFreeStatesInternal, - metric + metric; + cut_solution::Bool=false ) nstring = _mode_range_label(intr) @@ -796,7 +797,7 @@ function run_force_free_states( error("integrator = \"galerkin\" does not support kinetic runs (kinetic_factor > 0); use integrator = \"forward\".") gal_start = time() wv = ctrl.vac_flag ? first(ForceFreeStates.compute_scaled_wv(ctrl, equil, intr)) : nothing - gal_data, gal_dp = galerkin_solve(ctrl, equil, mats, intr; wv=wv) + gal_data, gal_dp = galerkin_solve(ctrl, equil, mats, intr; wv=wv, cut_solution=cut_solution) gal_dt = time() - gal_start @info "Galerkin solve completed in $(@sprintf("%.3f", gal_dt)) s" else @@ -980,7 +981,7 @@ function solve(prob::EulerLagrangeProblem, alg::ForceFreeStates.AbstractIntegrat locstab, ballooning_boundary = run_local_stability(ctrl, equil) metric, mats, _ = prepare_force_free_states!(intr, ctrl, equil, kf_ctrl, nothing) - result = run_force_free_states(ctrl, equil, mats, intr, metric) + result = run_force_free_states(ctrl, equil, mats, intr, metric; cut_solution=alg isa ForceFreeStates.Galerkin && alg.cut_solution) if ctrl.write_outputs_to_HDF5 write_outputs_to_HDF5(result; locstab=locstab, ballooning_boundary=ballooning_boundary) From bc9e1341db56106ed0296312cb1b097f18b44dcd Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Fri, 23 Oct 2026 20:34:26 +0200 Subject: [PATCH 13/60] Analysis - BUGFIX - Read Jb_theta, Jb_zeta and the kinetic rational_psi, and smoke-test the gpec.h5 plots --- src/Analysis/ForceFreeStates.jl | 2 +- src/Analysis/PerturbedEquilibrium.jl | 17 +++--- test/runtests.jl | 1 + test/runtests_analysis_plots.jl | 79 ++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 test/runtests_analysis_plots.jl diff --git a/src/Analysis/ForceFreeStates.jl b/src/Analysis/ForceFreeStates.jl index 304b9ba54..bff4d1dbd 100644 --- a/src/Analysis/ForceFreeStates.jl +++ b/src/Analysis/ForceFreeStates.jl @@ -465,7 +465,7 @@ function plot_cond_fbar(h5path; save_path=nothing, zoom=false) (read(kg["scan_psi"]), read(kg["scan_cond"]), read(kg["scan_threshold"]), - read(kg["psi"]), + read(kg["rational_psi"]), read(fid["SingularSurfaces/rational_psi"]), read(fid["SingularSurfaces/rational_q"]), read(kg["rational_count"])) diff --git a/src/Analysis/PerturbedEquilibrium.jl b/src/Analysis/PerturbedEquilibrium.jl index 33f8bdc0f..03f269660 100644 --- a/src/Analysis/PerturbedEquilibrium.jl +++ b/src/Analysis/PerturbedEquilibrium.jl @@ -342,8 +342,9 @@ Inspired by `plot_spectrograms.py` from OMFIT GPEC. ### Keyword arguments - `component`: Response field component to plot; one of `:xi_psi`, `:b_psi`, - `:b_theta`, `:b_zeta` (default: `:xi_psi`). `:b_psi` reads the area-normalized - `b_psi_area_weighted` dataset. + `:Jb_theta`, `:Jb_zeta` (default: `:xi_psi`). `:b_psi` reads the area-normalized + `b_psi_area_weighted` dataset; `:Jb_theta` and `:Jb_zeta` are the Jacobian-weighted + contravariant field components. - `save_path`: If provided, save the figure to this path (default: `nothing`) ### Returns @@ -352,13 +353,13 @@ A `Plots.jl` plot object. """ function plot_mode_spectrogram(h5path; component=:xi_psi, save_path=nothing) comp_map = Dict( - :xi_psi => "xi_psi", - :b_psi => "b_psi_area_weighted", # area-normalized b^ψ - :b_theta => "b_theta", - :b_zeta => "b_zeta", + :xi_psi => "xi_psi", + :b_psi => "b_psi_area_weighted", # area-normalized b^ψ + :Jb_theta => "Jb_theta", + :Jb_zeta => "Jb_zeta", ) haskey(comp_map, component) || - error("component must be one of :xi_psi, :b_psi, :b_theta, :b_zeta") + error("component must be one of :xi_psi, :b_psi, :Jb_theta, :Jb_zeta") base = "PerturbedEquilibrium/Response/" dataset_path = base * comp_map[component] @@ -368,7 +369,7 @@ function plot_mode_spectrogram(h5path; component=:xi_psi, save_path=nothing) data, psi_response, mlow, mhigh, nhigh, q95, rational_psi = h5open(h5path, "r") do fid read(fid[dataset_path]), - read(fid["ForceFreeStates/Solutions/ForwardIntegration/psi"]), + read(fid[base * "psi"]), read(fid["Info/mlow"]), read(fid["Info/mhigh"]), read(fid["Info/nhigh"]), read(fid["Equilibrium/q_95"]), read(fid["PerturbedEquilibrium/SingularCoupling/rational_psi"]) diff --git a/test/runtests.jl b/test/runtests.jl index 7a7f17e4c..9bca6d6a2 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -50,6 +50,7 @@ else include("./runtests_kinetic_profiles.jl") include("./runtests_resist_eval.jl") include("./runtests_localstability.jl") + include("./runtests_analysis_plots.jl") include("./runtests_slayer_params.jl") include("./runtests_slayer_riccati.jl") include("./runtests_slayer_inputs.jl") diff --git a/test/runtests_analysis_plots.jl b/test/runtests_analysis_plots.jl new file mode 100644 index 000000000..63bd4b598 --- /dev/null +++ b/test/runtests_analysis_plots.jl @@ -0,0 +1,79 @@ +using HDF5 +using Plots +using TOML + +# Smoke tests of the gpec.h5 plots against real writer output: each plot must read the datasets the +# writers emit, so a renamed path fails here instead of silently drawing a "No ... data" placeholder. +@testset "Analysis plots from gpec.h5" begin + ENV["GKSwstype"] = "100" + GPEC = GeneralizedPerturbedEquilibrium + A = GPEC.Analysis + + # Copy a coarse fixture deck into `dir` with HDF5 output on and the given extra sections; return its gpec.h5. + function _run_fixture(dir, fixture; sections=Dict{String,Any}()) + template = joinpath(@__DIR__, "test_data", fixture) + for name in readdir(template) + cp(joinpath(template, name), joinpath(dir, name)) + end + toml_path = joinpath(dir, "gpec.toml") + inputs = TOML.parsefile(toml_path) + inputs["ForceFreeStates"]["write_outputs_to_HDF5"] = true + merge!(inputs, sections) + open(io -> TOML.print(io, inputs), toml_path, "w") + GPEC.main([dir]) + return joinpath(dir, "gpec.h5") + end + _placeholder(p) = any(sp -> startswith(string(get(sp.attr, :title, "")), "No "), p.subplots) + _drew(p) = p isa Plots.Plot && !_placeholder(p) + + @testset "ideal run with coil forcing and the perturbed-equilibrium response" begin + mktempdir() do dir + h5 = _run_fixture(dir, "regression_solovev_ideal_example"; sections=Dict{String,Any}( + "ForcingTerms" => Dict{String,Any}("forcing_data_format" => "coil", "mtheta_coil" => 240, "nzeta_coil" => 32, + "coil_set" => [Dict{String,Any}("name" => "hoop", "source" => "pf_hoop", "radius" => 1.5, "height" => 0.4, + "currents" => [2.0e3], "tiltx" => [3.0])]), + "PerturbedEquilibrium" => Dict{String,Any}("compute_response" => true, "compute_singular_coupling" => true, + "verbose" => false, "write_outputs_to_HDF5" => true))) + + for f in (A.Equilibrium.plot_qprofile, A.Equilibrium.plot_pressure_profile, A.Equilibrium.plot_f_profile, + A.Equilibrium.plot_flux_surfaces, A.Equilibrium.plot_equilibrium_summary) + @test _drew(f(h5)) + end + for f in (A.ForceFreeStates.plot_mode_displacement, A.ForceFreeStates.plot_fixed_boundary_stability_criterion, + A.ForceFreeStates.plot_energy_eigenvectors, A.ForceFreeStates.plot_edge_stability_scan, + A.ForceFreeStates.plot_eigenvalues, A.ForceFreeStates.plot_delta_prime, + A.ForceFreeStates.plot_ballooning_alpha_boundary, A.ForceFreeStates.plot_ffs_summary) + @test _drew(f(h5)) + end + for f in (A.PerturbedEquilibrium.plot_resonant_area_weighted_field_amplitude, A.PerturbedEquilibrium.plot_island_widths, + A.PerturbedEquilibrium.plot_chirikov_parameter, A.PerturbedEquilibrium.plot_driven_delta_prime, + A.PerturbedEquilibrium.plot_resonant_area_weighted_field, A.PerturbedEquilibrium.plot_perturbed_equilibrium_summary) + @test _drew(f(h5)) + end + for component in (:xi_psi, :b_psi, :Jb_theta, :Jb_zeta) + @test _drew(A.PerturbedEquilibrium.plot_mode_spectrogram(h5; component=component)) + end + @test_throws ErrorException A.PerturbedEquilibrium.plot_mode_spectrogram(h5; component=:b_theta) + + # Optional outputs this run does not produce: no kinetic scan, no GSE sidecar files. + @test (@test_logs (:warn,) A.ForceFreeStates.plot_cond_fbar(h5)) === nothing + @test A.Equilibrium.plot_gse_by_theta(h5) === nothing + @test A.Equilibrium.plot_gse_integrated(h5) === nothing + + png = joinpath(dir, "qprofile.png") + A.Equilibrium.plot_qprofile(h5; save_path=png) + @test isfile(png) + + theta_data, theta_grid, n_vals = A.PerturbedEquilibriumModes.modes_to_theta(h5, "PerturbedEquilibrium/Response/xi_psi") + npsi = h5open(f -> length(read(f, "PerturbedEquilibrium/Response/psi")), h5) + @test size(theta_data) == (npsi, length(theta_grid), length(n_vals)) + end + end + + @testset "kinetic run draws the F̄ condition-number scan" begin + mktempdir() do dir + h5 = _run_fixture(dir, "regression_solovev_kinetic_example") + @test _drew(A.ForceFreeStates.plot_cond_fbar(h5)) + end + end +end From d23a92c46fc36cb9b85546a80cee6232c49cf2f0 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Tue, 27 Oct 2026 16:51:17 +0100 Subject: [PATCH 14/60] Examples - BUGFIX - Remove the duplicated LocalStability section from the calculated-kinetic deck --- examples/Solovev_kinetic_calculated_example/gpec.toml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/examples/Solovev_kinetic_calculated_example/gpec.toml b/examples/Solovev_kinetic_calculated_example/gpec.toml index 392e8e471..d5737509c 100644 --- a/examples/Solovev_kinetic_calculated_example/gpec.toml +++ b/examples/Solovev_kinetic_calculated_example/gpec.toml @@ -51,17 +51,6 @@ alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the s psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN verbose = false # Print progress and per-surface α-scan failures -# Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords -[LocalStability] -theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan -ode_tol = 1e-8 # Relative and absolute tolerance of the ballooning ODE integration behind Δ' -alpha_boundary_scan = true # Run the first ballooning α-boundary scan (a root find per ψ surface) -n_scan = 24 # Resolution of the α scan for the first boundary -max_alpha_scale = 8.0 # Upper end of the α search, as a multiple of the experimental α -alpha_tol = 1e-3 # Bisection tolerance of the critical α, in the same multiples -psi_window = [0.1, 0.99] # ψ_N range of the α scan; surfaces outside it are NaN -verbose = false # Print progress and per-surface α-scan failures - [ForceFreeStates] vac_flag = true # Compute plasma, vacuum, and total energies for free-boundary modes From 14a374549e82363819ffd005eddda4c7476596d0 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Wed, 28 Oct 2026 15:10:11 +0100 Subject: [PATCH 15/60] Vacuum - API! - Make walls optional with shape-specific settings --- benchmarks/benchmark_against_fortran_run.jl | 6 - benchmarks/benchmark_delta_prime_methods.jl | 3 +- .../benchmark_diiid_ideal_ntv_torque.jl | 3 - .../benchmark_diiid_kinetic_stability.jl | 3 - benchmarks/benchmark_riccati_der.jl | 3 +- benchmarks/benchmark_threads.jl | 3 +- docs/development/architecture.md | 2 +- docs/development/toml-conventions.md | 2 +- docs/src/set_up.md | 2 +- docs/src/stability.md | 3 +- docs/src/vacuum.md | 20 +- docs/src/workflow.md | 4 +- examples/DIIID-like_SLAYER_example/gpec.toml | 10 - .../DIIID-like_error_field_example/gpec.toml | 10 - .../gpec.toml | 10 - .../gpec.toml | 10 - examples/DIIID-like_ideal_example/gpec.toml | 10 - .../DIIID-like_ideal_example_IMAS/gpec.toml | 3 - .../run_imas_example.jl | 5 +- .../gpec.toml | 10 - .../gpec.toml | 10 - examples/LAR_beta_scan/gpec.toml | 4 +- examples/LAR_epsilon_scan/gpec.toml | 4 +- examples/LAR_ideal_match_test/gpec.toml | 4 +- examples/LAR_resistive_match_test/gpec.toml | 4 +- examples/Solovev_ideal_example/gpec.toml | 9 +- examples/Solovev_ideal_example_3D/gpec.toml | 9 +- .../Solovev_ideal_example_multi_n/gpec.toml | 9 +- .../single_n_1/gpec.toml | 9 +- .../single_n_2/gpec.toml | 9 +- .../Solovev_kinetic_NTV_example/gpec.toml | 9 +- .../gpec.toml | 9 +- .../gpec.toml | 9 +- examples/a10_kinetic_example/gpec.toml | 3 - src/ForceFreeStates/CoreTypes.jl | 4 +- src/ForceFreeStates/Result.jl | 4 +- src/GeneralizedPerturbedEquilibrium.jl | 16 +- src/PerturbedEquilibrium/ResponseMatrices.jl | 2 +- src/Vacuum/DataTypes.jl | 197 ++++++++++++------ src/Vacuum/Vacuum.jl | 16 +- test/runtests_eulerlagrange.jl | 2 +- test/runtests_parallel_integration.jl | 12 +- test/runtests_riccati.jl | 3 +- test/runtests_solve_api.jl | 4 +- test/runtests_vacuum.jl | 141 +++++++------ .../gpec.toml | 9 +- .../gpec.toml | 9 +- .../gpec.toml | 9 +- .../gpec.toml | 9 +- .../gpec.toml | 9 +- .../gpec.toml | 9 +- 51 files changed, 302 insertions(+), 376 deletions(-) diff --git a/benchmarks/benchmark_against_fortran_run.jl b/benchmarks/benchmark_against_fortran_run.jl index 2b51564bb..212329311 100644 --- a/benchmarks/benchmark_against_fortran_run.jl +++ b/benchmarks/benchmark_against_fortran_run.jl @@ -406,9 +406,6 @@ function write_gpec_toml_coil( println(io, "mtheta = $(p.mtheta_equil)") println(io, "flux_surface_rtol = 1e-7") println(io) - println(io, "[Wall]") - println(io, "shape = \"nowall\"") - println(io) println(io, "[LocalStability]") println(io) println(io, "[ForceFreeStates]") @@ -468,9 +465,6 @@ function write_gpec_toml_file( println(io, "mtheta = $(p.mtheta_equil)") println(io, "flux_surface_rtol = 1e-7") println(io) - println(io, "[Wall]") - println(io, "shape = \"nowall\"") - println(io) println(io, "[LocalStability]") println(io) println(io, "[ForceFreeStates]") diff --git a/benchmarks/benchmark_delta_prime_methods.jl b/benchmarks/benchmark_delta_prime_methods.jl index 94c7dc0ee..6e7b655c1 100644 --- a/benchmarks/benchmark_delta_prime_methods.jl +++ b/benchmarks/benchmark_delta_prime_methods.jl @@ -29,8 +29,7 @@ function setup_and_run_solovev() (Symbol(k) => v for (k, v) in inputs["ForceFreeStates"])...) eq_config = GeneralizedPerturbedEquilibrium.Equilibrium.EquilibriumConfig(inputs["Equilibrium"], ex) equil = GeneralizedPerturbedEquilibrium.Equilibrium.PlasmaEquilibrium(eq_config) - intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.WallShapeSettings(; - (Symbol(k) => v for (k, v) in inputs["Wall"])...) + intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.parse_wall(get(inputs, "Wall", nothing)) FFS.sing_lim!(intr, ctrl, equil) intr.nlow = ctrl.nn_low; intr.nhigh = ctrl.nn_high; diff --git a/benchmarks/benchmark_diiid_ideal_ntv_torque.jl b/benchmarks/benchmark_diiid_ideal_ntv_torque.jl index 8cad18ab2..cbcb95d41 100644 --- a/benchmarks/benchmark_diiid_ideal_ntv_torque.jl +++ b/benchmarks/benchmark_diiid_ideal_ntv_torque.jl @@ -125,9 +125,6 @@ mtheta = 256 newq0 = 0 flux_surface_rtol = 1e-7 -[Wall] -shape = "nowall" - [LocalStability] [ForceFreeStates] diff --git a/benchmarks/benchmark_diiid_kinetic_stability.jl b/benchmarks/benchmark_diiid_kinetic_stability.jl index f576994b1..b85216ef9 100644 --- a/benchmarks/benchmark_diiid_kinetic_stability.jl +++ b/benchmarks/benchmark_diiid_kinetic_stability.jl @@ -133,9 +133,6 @@ mtheta = 256 newq0 = 0 flux_surface_rtol = 1e-7 -[Wall] -shape = "nowall" - [LocalStability] [ForceFreeStates] diff --git a/benchmarks/benchmark_riccati_der.jl b/benchmarks/benchmark_riccati_der.jl index eaaa64f89..531241a80 100644 --- a/benchmarks/benchmark_riccati_der.jl +++ b/benchmarks/benchmark_riccati_der.jl @@ -30,8 +30,7 @@ function setup_solovev() (Symbol(k) => v for (k, v) in inputs["ForceFreeStates"])...) eq_config = GeneralizedPerturbedEquilibrium.Equilibrium.EquilibriumConfig(inputs["Equilibrium"], ex) equil = GeneralizedPerturbedEquilibrium.Equilibrium.PlasmaEquilibrium(eq_config) - intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.WallShapeSettings(; - (Symbol(k) => v for (k, v) in inputs["Wall"])...) + intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.parse_wall(get(inputs, "Wall", nothing)) FFS.sing_lim!(intr, ctrl, equil) intr.nlow = ctrl.nn_low; intr.nhigh = ctrl.nn_high; diff --git a/benchmarks/benchmark_threads.jl b/benchmarks/benchmark_threads.jl index 8a9a05dc8..277694379 100644 --- a/benchmarks/benchmark_threads.jl +++ b/benchmarks/benchmark_threads.jl @@ -18,8 +18,7 @@ function run_ffs(ex; integrator) (Symbol(k) => v for (k, v) in inputs["ForceFreeStates"])...) eq_config = GeneralizedPerturbedEquilibrium.Equilibrium.EquilibriumConfig(inputs["Equilibrium"], ex) equil = GeneralizedPerturbedEquilibrium.Equilibrium.PlasmaEquilibrium(eq_config) - intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.WallShapeSettings(; - (Symbol(k) => v for (k, v) in inputs["Wall"])...) + intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.parse_wall(get(inputs, "Wall", nothing)) GeneralizedPerturbedEquilibrium.ForceFreeStates.sing_lim!(intr, ctrl, equil) intr.nlow = ctrl.nn_low; intr.nhigh = ctrl.nn_high; diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 2f83974d3..387e8836a 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -204,7 +204,7 @@ The complete GPEC analysis pipeline: ### Vacuum - `VacuumInput` - Input parameters for vacuum calculations -- `WallShapeSettings` - Wall geometry configuration +- `WallSettings` - Wall geometry configuration ### Stability - `SingType` - Singular surface data including: diff --git a/docs/development/toml-conventions.md b/docs/development/toml-conventions.md index 43bd03a08..24bf349ba 100644 --- a/docs/development/toml-conventions.md +++ b/docs/development/toml-conventions.md @@ -10,7 +10,7 @@ modify them. **Do not invent a new convention** — match what the existing exam n-range, ideal vs kinetic). No ASCII-art separators or decorative dividers. 2. **Inline annotation on every variable line.** Every `key = value` gets a trailing `# description`. Source the wording from the matching config struct's docstring - (`EquilibriumConfig`, `WallShapeSettings`, `ForceFreeStatesControl`, `ForcingTermsControl`, + (`EquilibriumConfig`, `WallSettings`, `ForceFreeStatesControl`, `ForcingTermsControl`, the `plasma_response`/`perturbed_equilibrium` docstrings, `SolovevConfig`, `TJAnalyticConfig`, `KineticForcesControl`) and keep it to one terse line. **Use the same description for the same variable across all files** — `examples/DIIID-like_ideal_example/gpec.toml` is the canonical reference for the diff --git a/docs/src/set_up.md b/docs/src/set_up.md index 371dbac8e..c08817dbc 100644 --- a/docs/src/set_up.md +++ b/docs/src/set_up.md @@ -353,7 +353,7 @@ println("Beta-N: ", equil.params.betan) GPEC uses TOML configuration files (`gpec.toml`) with the following main sections: - **`[Equilibrium]`**: Equilibrium solver settings (input file, grid resolution, coordinate system, etc.) -- **`[Wall]`**: Wall geometry for vacuum calculations (shape, size, position) +- **`[Wall]`**: Optional wall geometry for vacuum calculations (shape and its parameters); omit for no wall - **`[ForceFreeStates]`**: Stability analysis settings (mode numbers, tolerances, flags) - **`[PerturbedEquilibrium]`**: Plasma response settings (forcing data, output options) diff --git a/docs/src/stability.md b/docs/src/stability.md index 99d3fd1c5..e8bc684ad 100644 --- a/docs/src/stability.md +++ b/docs/src/stability.md @@ -443,8 +443,7 @@ equil = GeneralizedPerturbedEquilibrium.Equilibrium.PlasmaEquilibrium( GeneralizedPerturbedEquilibrium.Equilibrium.EquilibriumConfig(inputs["Equilibrium"], ex)) intr = FFS.ForceFreeStatesInternal(; dir_path=ex) -intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.WallShapeSettings(; - (Symbol(k) => v for (k, v) in inputs["Wall"])...) +intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.parse_wall(get(inputs, "Wall", nothing)) intr.nlow = ctrl.nn_low; intr.nhigh = ctrl.nn_high; intr.npert = 1 FFS.sing_lim!(intr, ctrl, equil) FFS.sing_find!(intr, equil) diff --git a/docs/src/vacuum.md b/docs/src/vacuum.md index 6fdff539e..4c96da09e 100644 --- a/docs/src/vacuum.md +++ b/docs/src/vacuum.md @@ -22,11 +22,12 @@ Contains plasma boundary data and calculation parameters including: - Grid resolution (mtheta) for vacuum calculations - Optional kernel sign and symmetry flags -### WallShapeSettings -Specifies wall geometry configuration with options for: -- No wall, conformal, elliptical, dee-shaped, or custom walls -- Geometric parameters (a, aw, bw, cw, dw, tw) -- Equal arc length spacing option +### WallSettings +The abstract wall type has five concrete shapes: `Vacuum.ConformalWall`, `Vacuum.EllipticalWall`, +`Vacuum.DeeWall`, `Vacuum.ModDeeWall`, and `Vacuum.FileWall`. Each accepts only its shape's +parameters and `equal_arc_wall`. Pass `nothing` for no wall; a deck omits `[Wall]` in that case. +A wall section selects `shape = "conformal"`, `"elliptical"`, `"dee"`, `"mod_dee"`, or `"file"`. +Unknown or unused parameters error, listing the selected shape's allowed keys. ## API Reference @@ -62,10 +63,9 @@ inputs = GeneralizedPerturbedEquilibrium.Vacuum.VacuumInput( ) # Define wall settings -wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.WallShapeSettings( - shape = "conformal", # Wall shape type - a = 0.3, # Wall distance parameter - equal_arc_wall = true # Use equal arc length spacing +wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.ConformalWall(; + standoff_fraction=0.3, # Wall standoff in plasma minor radii + equal_arc_wall=true # Use equal arc length spacing ) # Compute vacuum response; returns a VacuumResponse with wv, I_v, plasma_pts, wall_pts @@ -77,5 +77,5 @@ vac = GeneralizedPerturbedEquilibrium.Vacuum.compute_vacuum_response(inputs, wal - The Julia implementation uses Bulirsch's algorithm for elliptic integrals, providing improved accuracy over polynomial approximations - For large mode numbers (nρ̂ ≥ 0.1), 32-point Gaussian quadrature is used for Legendre function evaluation - For n=0 modes with closed walls, automatic regularization is applied -- Wall shapes support: nowall, conformal, elliptical, dee, mod_dee, or custom from file +- Use `nothing` for no wall, or a concrete wall shape for a conducting boundary - The vacuum response matrix wv is scaled by the singular factor (m - nq)(m' - nq) per [Chance Phys. Plasmas 1997] diff --git a/docs/src/workflow.md b/docs/src/workflow.md index 1d729c962..16ed157c8 100644 --- a/docs/src/workflow.md +++ b/docs/src/workflow.md @@ -52,7 +52,7 @@ The single `gpec.toml` file supplies user-selected options to every module. The **Purpose**: Compute the magnetostatic vacuum response matrices that couple the plasma boundary to external fields. These matrices are used by ForceFreeStates and PerturbedEquilibrium to apply boundary conditions and decompose fields in poloidal mode space. -**Configuration**: `[Wall]` section of `gpec.toml` +**Configuration**: optional `[Wall]` section of `gpec.toml`; omit it for no wall **Inputs**: - `PlasmaEquilibrium` from Stage 1 (plasma boundary coordinates, toroidal mode number n) @@ -148,7 +148,7 @@ eq_filename = "g-file.txt" [Wall] shape = "conformal" -a = 0.3 +standoff_fraction = 0.3 [ForceFreeStates] mlow = -3 diff --git a/examples/DIIID-like_SLAYER_example/gpec.toml b/examples/DIIID-like_SLAYER_example/gpec.toml index 8f01a2105..3bb22ae50 100644 --- a/examples/DIIID-like_SLAYER_example/gpec.toml +++ b/examples/DIIID-like_SLAYER_example/gpec.toml @@ -19,16 +19,6 @@ newq0 = 0 # Target on-axis safety factor q(0) (0 = use inpu flux_surface_rtol = 1e-10 # Relative tolerance of the ODE solves that trace each flux surface force_termination = false # Terminate after equilibrium setup (skip stability calculations) -[Wall] -shape = "nowall" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) -equal_arc_wall = true # Equal arc length distribution of nodes on wall - # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan diff --git a/examples/DIIID-like_error_field_example/gpec.toml b/examples/DIIID-like_error_field_example/gpec.toml index 8ce278155..0bfad5fcb 100644 --- a/examples/DIIID-like_error_field_example/gpec.toml +++ b/examples/DIIID-like_error_field_example/gpec.toml @@ -23,16 +23,6 @@ newq0 = 0 # Target on-axis safety factor q(0) (0 = use inpu flux_surface_rtol = 1e-10 # Relative tolerance of the ODE solves that trace each flux surface force_termination = false # Terminate after equilibrium setup (skip stability calculations) -[Wall] -shape = "nowall" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) -equal_arc_wall = true # Equal arc length distribution of nodes on wall - # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan diff --git a/examples/DIIID-like_gal_resistive_example/gpec.toml b/examples/DIIID-like_gal_resistive_example/gpec.toml index ddeefd19f..f1a369783 100644 --- a/examples/DIIID-like_gal_resistive_example/gpec.toml +++ b/examples/DIIID-like_gal_resistive_example/gpec.toml @@ -18,16 +18,6 @@ newq0 = 0 # Target on-axis safety factor q(0) (0 = use inpu flux_surface_rtol = 1e-10 # Relative tolerance of the ODE solves that trace each flux surface force_termination = false # Terminate after equilibrium setup (skip stability calculations) -[Wall] -shape = "nowall" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) -equal_arc_wall = true # Equal arc length distribution of nodes on wall - # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan diff --git a/examples/DIIID-like_gal_resistive_pe_example/gpec.toml b/examples/DIIID-like_gal_resistive_pe_example/gpec.toml index 0c86a0348..98a14f718 100644 --- a/examples/DIIID-like_gal_resistive_pe_example/gpec.toml +++ b/examples/DIIID-like_gal_resistive_pe_example/gpec.toml @@ -19,16 +19,6 @@ newq0 = 0 # Target on-axis safety factor q(0) (0 = use inpu flux_surface_rtol = 1e-10 # Relative tolerance of the ODE solves that trace each flux surface force_termination = false # Terminate after equilibrium setup (skip stability calculations) -[Wall] -shape = "nowall" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) -equal_arc_wall = true # Equal arc length distribution of nodes on wall - # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan diff --git a/examples/DIIID-like_ideal_example/gpec.toml b/examples/DIIID-like_ideal_example/gpec.toml index 8d056b01c..05ec01924 100644 --- a/examples/DIIID-like_ideal_example/gpec.toml +++ b/examples/DIIID-like_ideal_example/gpec.toml @@ -18,16 +18,6 @@ newq0 = 0 # Target on-axis safety factor q(0) (0 = use inpu flux_surface_rtol = 1e-10 # Relative tolerance of the ODE solves that trace each flux surface force_termination = false # Terminate after equilibrium setup (skip stability calculations) -[Wall] -shape = "nowall" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) -equal_arc_wall = true # Equal arc length distribution of nodes on wall - # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan diff --git a/examples/DIIID-like_ideal_example_IMAS/gpec.toml b/examples/DIIID-like_ideal_example_IMAS/gpec.toml index b1e8c9119..dc511e6e0 100644 --- a/examples/DIIID-like_ideal_example_IMAS/gpec.toml +++ b/examples/DIIID-like_ideal_example_IMAS/gpec.toml @@ -17,9 +17,6 @@ mpsi = 128 # Number of radial grid intervals (0 = two-pass a mtheta = 256 # Number of poloidal grid points imas_cocos = 11 # Expected COCOS convention of the input IMAS dd (11 = IMAS standard) -[Wall] -shape = "nowall" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) - [ForceFreeStates] nn_low = 1 # Smallest toroidal mode number to include nn_high = 1 # Largest toroidal mode number to include diff --git a/examples/DIIID-like_ideal_example_IMAS/run_imas_example.jl b/examples/DIIID-like_ideal_example_IMAS/run_imas_example.jl index ef01d67eb..dc86ad1ef 100644 --- a/examples/DIIID-like_ideal_example_IMAS/run_imas_example.jl +++ b/examples/DIIID-like_ideal_example_IMAS/run_imas_example.jl @@ -22,7 +22,7 @@ using EFIT const GEQDSK = joinpath(@__DIR__, "../DIIID-like_ideal_example/TkMkr_D3Dlike_Hmode.geqdsk") @assert isfile(GEQDSK) "G-file not found at $GEQDSK" -# Shared settings: both runs use identical ForceFreeStates and Wall config. +# Shared settings: both runs use identical ForceFreeStates config and no wall. # Only the [Equilibrium] section differs between IMAS and g-file paths. const SHARED_CONFIG = Dict( "ForceFreeStates" => Dict( @@ -33,8 +33,7 @@ const SHARED_CONFIG = Dict( "verbose" => false, "delta_mhigh" => 16, "qlow" => 1.02 - ), - "Wall" => Dict("shape" => "nowall") + ) ) const EQ_COMMON = Dict( diff --git a/examples/DIIID-like_ideal_multi_n_example/gpec.toml b/examples/DIIID-like_ideal_multi_n_example/gpec.toml index 8abd24511..b0e002d67 100644 --- a/examples/DIIID-like_ideal_multi_n_example/gpec.toml +++ b/examples/DIIID-like_ideal_multi_n_example/gpec.toml @@ -18,16 +18,6 @@ newq0 = 0 # Target on-axis safety factor q(0) (0 = use inpu flux_surface_rtol = 1e-10 # Relative tolerance of the ODE solves that trace each flux surface force_termination = false # Terminate after equilibrium setup (skip stability calculations) -[Wall] -shape = "nowall" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) -equal_arc_wall = true # Equal arc length distribution of nodes on wall - # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan diff --git a/examples/DIIID-like_riccati_deltaprime_example/gpec.toml b/examples/DIIID-like_riccati_deltaprime_example/gpec.toml index 8ed214bd9..c7fc5e4ee 100644 --- a/examples/DIIID-like_riccati_deltaprime_example/gpec.toml +++ b/examples/DIIID-like_riccati_deltaprime_example/gpec.toml @@ -18,16 +18,6 @@ newq0 = 0 # Override for on-axis safety factor (0 = use inp flux_surface_rtol = 1e-10 # Relative tolerance of the ODE solves that trace each flux surface force_termination = false # Terminate after equilibrium setup (skip stability calculations) -[Wall] -shape = "nowall" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) -equal_arc_wall = true # Equal arc length distribution of nodes on wall - # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan diff --git a/examples/LAR_beta_scan/gpec.toml b/examples/LAR_beta_scan/gpec.toml index bfd5ed0e5..9b05e0644 100644 --- a/examples/LAR_beta_scan/gpec.toml +++ b/examples/LAR_beta_scan/gpec.toml @@ -30,8 +30,8 @@ ma = 128 # TJ-analytic internal radial grid resolution (sh mtheta = 128 # TJ-analytic internal poloidal grid resolution (θ-spline nodes) [Wall] -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 20 # Wall standoff in plasma minor radii (conformal, dee) (20× → effectively no wall) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 20 # Wall standoff in plasma minor radii # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] diff --git a/examples/LAR_epsilon_scan/gpec.toml b/examples/LAR_epsilon_scan/gpec.toml index e7ace3ad5..55f27ad3a 100644 --- a/examples/LAR_epsilon_scan/gpec.toml +++ b/examples/LAR_epsilon_scan/gpec.toml @@ -31,8 +31,8 @@ ma = 128 # TJ-analytic internal radial grid resolution (sh mtheta = 128 # TJ-analytic internal poloidal grid resolution (θ-spline nodes) [Wall] -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 20 # Wall standoff in plasma minor radii (conformal, dee) (20× → effectively no wall) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 20 # Wall standoff in plasma minor radii # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] diff --git a/examples/LAR_ideal_match_test/gpec.toml b/examples/LAR_ideal_match_test/gpec.toml index 3ba233f0e..e22dc603e 100644 --- a/examples/LAR_ideal_match_test/gpec.toml +++ b/examples/LAR_ideal_match_test/gpec.toml @@ -27,8 +27,8 @@ ma = 128 # Radial grid points mtheta = 128 # Poloidal grid points [Wall] -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 20 # Wall standoff in plasma minor radii (conformal, dee) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 20 # Wall standoff in plasma minor radii # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] diff --git a/examples/LAR_resistive_match_test/gpec.toml b/examples/LAR_resistive_match_test/gpec.toml index 6ff7db22c..7065b1347 100644 --- a/examples/LAR_resistive_match_test/gpec.toml +++ b/examples/LAR_resistive_match_test/gpec.toml @@ -28,8 +28,8 @@ ma = 128 # Radial grid points mtheta = 128 # Poloidal grid points [Wall] -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 20 # Wall standoff in plasma minor radii (conformal, dee) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 20 # Wall standoff in plasma minor radii # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] diff --git a/examples/Solovev_ideal_example/gpec.toml b/examples/Solovev_ideal_example/gpec.toml index 10a59ae27..5328ed24d 100644 --- a/examples/Solovev_ideal_example/gpec.toml +++ b/examples/Solovev_ideal_example/gpec.toml @@ -22,13 +22,8 @@ force_termination = false # Terminate after equilibrium setup (ski # (dpm magnitudes ~ 10¹¹, |Im/Re| ≫ 1). This fixture's role is integration-pipeline # smoke testing + et[1] regression, NOT BVP Δ' regression — DIIID-like is the canonical # Δ'-matrix fixture (stable et[1] = +1.6, clean BVP Δ'). -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 0.2415 # Wall standoff in plasma minor radii equal_arc_wall = true # Equal arc length distribution of nodes on wall [ForcingTerms] diff --git a/examples/Solovev_ideal_example_3D/gpec.toml b/examples/Solovev_ideal_example_3D/gpec.toml index 1ef0d3f5c..06e71f193 100644 --- a/examples/Solovev_ideal_example_3D/gpec.toml +++ b/examples/Solovev_ideal_example_3D/gpec.toml @@ -54,13 +54,8 @@ rational_offset_fraction = 0.2 # Truncate integration at (last_rational_q + rati [Wall] # See examples/Solovev_ideal_example/gpec.toml for the rationale behind the close conformal wall. -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 0.2415 # Wall standoff in plasma minor radii equal_arc_wall = false # Equal arc length distribution of nodes on wall # Uncomment to enable perturbed equilibrium calculations: diff --git a/examples/Solovev_ideal_example_multi_n/gpec.toml b/examples/Solovev_ideal_example_multi_n/gpec.toml index 949579bf6..1aa5c22ed 100644 --- a/examples/Solovev_ideal_example_multi_n/gpec.toml +++ b/examples/Solovev_ideal_example_multi_n/gpec.toml @@ -17,13 +17,8 @@ force_termination = false # Terminate after equilibrium setup (ski [Wall] # See examples/Solovev_ideal_example/gpec.toml for the rationale behind the close conformal wall. -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 0.2415 # Wall standoff in plasma minor radii equal_arc_wall = true # Equal arc length distribution of nodes on wall # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords diff --git a/examples/Solovev_ideal_example_multi_n/single_n_1/gpec.toml b/examples/Solovev_ideal_example_multi_n/single_n_1/gpec.toml index 19a2cfeb7..65b626808 100644 --- a/examples/Solovev_ideal_example_multi_n/single_n_1/gpec.toml +++ b/examples/Solovev_ideal_example_multi_n/single_n_1/gpec.toml @@ -16,13 +16,8 @@ force_termination = false # Terminate after equilibrium setup (ski [Wall] # See examples/Solovev_ideal_example/gpec.toml for the rationale behind the close conformal wall. -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 0.2415 # Wall standoff in plasma minor radii equal_arc_wall = true # Equal arc length distribution of nodes on wall # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords diff --git a/examples/Solovev_ideal_example_multi_n/single_n_2/gpec.toml b/examples/Solovev_ideal_example_multi_n/single_n_2/gpec.toml index c8a2708d0..8c39e7401 100644 --- a/examples/Solovev_ideal_example_multi_n/single_n_2/gpec.toml +++ b/examples/Solovev_ideal_example_multi_n/single_n_2/gpec.toml @@ -16,13 +16,8 @@ force_termination = false # Terminate after equilibrium setup (ski [Wall] # See examples/Solovev_ideal_example/gpec.toml for the rationale behind the close conformal wall. -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 0.2415 # Wall standoff in plasma minor radii equal_arc_wall = true # Equal arc length distribution of nodes on wall # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords diff --git a/examples/Solovev_kinetic_NTV_example/gpec.toml b/examples/Solovev_kinetic_NTV_example/gpec.toml index 767ee63a5..e7f02b95b 100644 --- a/examples/Solovev_kinetic_NTV_example/gpec.toml +++ b/examples/Solovev_kinetic_NTV_example/gpec.toml @@ -25,13 +25,8 @@ force_termination = false # Terminate after equilibrium setup (ski # (dpm magnitudes ~ 10¹¹, |Im/Re| ≫ 1). This fixture's role is integration-pipeline # smoke testing + et[1] regression, NOT BVP Δ' regression — DIIID-like is the canonical # Δ'-matrix fixture (stable et[1] = +1.6, clean BVP Δ'). -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 0.2415 # Wall standoff in plasma minor radii equal_arc_wall = true # Equal arc length distribution of nodes on wall [ForcingTerms] diff --git a/examples/Solovev_kinetic_calculated_example/gpec.toml b/examples/Solovev_kinetic_calculated_example/gpec.toml index d5737509c..0f13a9b31 100644 --- a/examples/Solovev_kinetic_calculated_example/gpec.toml +++ b/examples/Solovev_kinetic_calculated_example/gpec.toml @@ -21,13 +21,8 @@ flux_surface_rtol = 1e-7 # Relative tolerance of the ODE solves t force_termination = false # Terminate after equilibrium setup (skip stability calculations) [Wall] -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 0.2415 # Wall standoff in plasma minor radii equal_arc_wall = true # Equal arc length distribution of nodes on wall [ForcingTerms] diff --git a/examples/Solovev_kinetic_multiion_example/gpec.toml b/examples/Solovev_kinetic_multiion_example/gpec.toml index a95019e94..3f226d5cf 100644 --- a/examples/Solovev_kinetic_multiion_example/gpec.toml +++ b/examples/Solovev_kinetic_multiion_example/gpec.toml @@ -25,13 +25,8 @@ force_termination = false # Terminate after equilibrium setup (ski # (dpm magnitudes ~ 10¹¹, |Im/Re| ≫ 1). This fixture's role is integration-pipeline # smoke testing + et[1] regression, NOT BVP Δ' regression — DIIID-like is the canonical # Δ'-matrix fixture (stable et[1] = +1.6, clean BVP Δ'). -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 0.2415 # Wall standoff in plasma minor radii equal_arc_wall = true # Equal arc length distribution of nodes on wall [ForcingTerms] diff --git a/examples/a10_kinetic_example/gpec.toml b/examples/a10_kinetic_example/gpec.toml index 82e2957d4..a86cda873 100644 --- a/examples/a10_kinetic_example/gpec.toml +++ b/examples/a10_kinetic_example/gpec.toml @@ -19,9 +19,6 @@ mtheta = 256 # Number of poloidal grid points newq0 = 0 # Target on-axis safety factor q(0) (0 = use input value, -1 = flip axis extrapolation) flux_surface_rtol = 1e-7 # Relative tolerance of the ODE solves that trace each flux surface -[Wall] -shape = "nowall" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) - # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords [LocalStability] theta_k = 0.0 # Ballooning angle θ_k, used by both the profile scan and the α-boundary scan diff --git a/src/ForceFreeStates/CoreTypes.jl b/src/ForceFreeStates/CoreTypes.jl index 0cc4b6ce2..5c9c1cfd6 100644 --- a/src/ForceFreeStates/CoreTypes.jl +++ b/src/ForceFreeStates/CoreTypes.jl @@ -51,7 +51,7 @@ A mutable struct holding internal state variables for stability calculations. - `psilim::Float64` - Flux limit for integration - `qlim::Float64` - Safety factor at psilim - `q1lim::Float64` - Safety factor derivative at psilim - - `wall_settings::Vacuum.WallShapeSettings` - Wall shape settings for vacuum calculations + - `wall_settings::Union{Nothing,Vacuum.WallSettings}` - Wall shape settings for vacuum calculations """ @kwdef mutable struct ForceFreeStatesInternal <: ModeSpace dir_path::String = "" @@ -74,7 +74,7 @@ A mutable struct holding internal state variables for stability calculations. qlim::Float64 = 0.0 q1lim::Float64 = 0.0 debug_settings::DebugSettings = DebugSettings() - wall_settings::Vacuum.WallShapeSettings = Vacuum.WallShapeSettings() + wall_settings::Union{Nothing,Vacuum.WallSettings} = nothing """ Inter-surface Δ' matrix of shape (msing × msing) in PEST3 convention. Computed by `compute_delta_prime_matrix!` (parallel FM path only) using the STRIDE diff --git a/src/ForceFreeStates/Result.jl b/src/ForceFreeStates/Result.jl index 4c03700f9..2469e8a54 100644 --- a/src/ForceFreeStates/Result.jl +++ b/src/ForceFreeStates/Result.jl @@ -53,7 +53,7 @@ so bpen and closure are always present. - `mlow`, `mhigh`, `mpert`, `nlow`, `nhigh`, `npert`, `numpert_total` - Resolved (m, n) mode space; see `ModeSpace`. - `psilow`, `psilim`, `qlim`, `q1lim` - Integration bounds, and q with its ψ-derivative at `psilim`. - `dir_path::String` - Working directory of the run. - - `wall_settings::Vacuum.WallShapeSettings` - Wall shape used by the vacuum calculation. + - `wall_settings::Union{Nothing,Vacuum.WallSettings}` - Wall shape used by the vacuum calculation. - `debug_settings::DebugSettings` - Diagnostic dump settings (the `[DEBUG]` deck section / `debug=` API keyword). - `metric::MetricData`, `mats::MatrixSplines` - Metric data and Euler-Lagrange matrix interpolants. - `surfaces::Vector{SingType}` - Ideal singular surfaces in the integration domain, with asymptotic bases and GGJ coefficients. @@ -96,7 +96,7 @@ struct ForceFreeStatesResult{E<:Equilibrium.PlasmaEquilibrium,F<:MatrixSplines} qlim::Float64 q1lim::Float64 dir_path::String - wall_settings::Vacuum.WallShapeSettings + wall_settings::Union{Nothing,Vacuum.WallSettings} debug_settings::DebugSettings # Assembly products, always present. diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 680c4eb04..3722575ad 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -263,11 +263,7 @@ function main_from_inputs( return end - if "Wall" in keys(inputs) - intr.wall_settings = Vacuum.WallShapeSettings(; (Symbol(k) => v for (k, v) in inputs["Wall"])...) - else - intr.wall_settings = Vacuum.WallShapeSettings() - end + intr.wall_settings = Vacuum.parse_wall(get(inputs, "Wall", nothing)) if "DEBUG" in keys(inputs) intr.debug_settings = DebugSettings(; (Symbol(k) => v for (k, v) in inputs["DEBUG"])...) @@ -773,7 +769,7 @@ function run_force_free_states( # Compute free boundary energies. if ctrl.vac_flag if ctrl.verbose - wall_desc = intr.wall_settings.shape == "nowall" ? "no wall" : intr.wall_settings.shape + wall_desc = intr.wall_settings === nothing ? "no wall" : nameof(typeof(intr.wall_settings)) @info "Computing free boundary energies ($wall_desc)" end free_energies = free_run(odet, ctrl, equil, mats, intr) @@ -840,7 +836,7 @@ function run_deck_match(result::ForceFreeStatesResult, ctrl::ForceFreeStatesCont end """ - EulerLagrangeProblem(equil; nn, wall=Vacuum.WallShapeSettings(), + EulerLagrangeProblem(equil; nn, wall=nothing, dir_path=".", debug=DebugSettings(), kwargs...) The perturbed-plasma Euler-Lagrange problem posed on an equilibrium: the extremization of @@ -859,14 +855,14 @@ come from `nn`. ## Fields - `equil::Equilibrium.PlasmaEquilibrium` - The equilibrium the problem is posed on. - - `wall::Vacuum.WallShapeSettings` - Vacuum wall shape for the free-boundary energies. + - `wall::Union{Nothing,Vacuum.WallSettings}` - Vacuum wall shape for the free-boundary energies. - `dir_path::String` - Working directory for outputs. - `debug::DebugSettings` - Diagnostic dump settings. - `ctrl_kwargs::Dict{Symbol,Any}` - `ForceFreeStatesControl` keywords, `nn` already folded in. """ struct EulerLagrangeProblem equil::Equilibrium.PlasmaEquilibrium - wall::Vacuum.WallShapeSettings + wall::Union{Nothing,Vacuum.WallSettings} dir_path::String debug::DebugSettings ctrl_kwargs::Dict{Symbol,Any} @@ -875,7 +871,7 @@ end function EulerLagrangeProblem( equil::Equilibrium.PlasmaEquilibrium; nn::Union{Int,AbstractUnitRange{Int}}, - wall::Vacuum.WallShapeSettings=Vacuum.WallShapeSettings(), + wall::Union{Nothing,Vacuum.WallSettings}=nothing, dir_path::AbstractString=".", debug::DebugSettings=DebugSettings(), kwargs... diff --git a/src/PerturbedEquilibrium/ResponseMatrices.jl b/src/PerturbedEquilibrium/ResponseMatrices.jl index a9224c2b0..b2a2213fd 100644 --- a/src/PerturbedEquilibrium/ResponseMatrices.jl +++ b/src/PerturbedEquilibrium/ResponseMatrices.jl @@ -299,7 +299,7 @@ function calc_surface_inductance( n_modes::Union{Integer,AbstractVector{<:Integer}} )::Matrix{ComplexF64} vac_input = Vacuum.VacuumInput(equil, ψ, mtheta, 1, m_modes, vcat(n_modes)) - wall_settings = Vacuum.WallShapeSettings(; shape="nowall") + wall_settings = nothing I_v = Vacuum.compute_vacuum_response(vac_input, wall_settings; compute_Iv=true).I_v μ₀ = 4π * 1e-7 return inv(I_v) .* (μ₀ * (2π)^2) diff --git a/src/Vacuum/DataTypes.jl b/src/Vacuum/DataTypes.jl index 27301285b..97dfb2106 100644 --- a/src/Vacuum/DataTypes.jl +++ b/src/Vacuum/DataTypes.jl @@ -184,65 +184,146 @@ function VacuumResponse(inputs::VacuumInput) end """ - WallShapeSettings + WallSettings -Struct containing input settings for vacuum wall geometry. +Abstract supertype of the vacuum wall shapes: [`ConformalWall`](@ref), [`EllipticalWall`](@ref), +[`DeeWall`](@ref), [`ModDeeWall`](@ref) and [`FileWall`](@ref). A run with no wall has no +`WallSettings` at all; the wall is `nothing`, and the deck omits its `[Wall]` section. -# Fields +Every shape carries `equal_arc_wall::Bool` (default `true`), which redistributes the wall nodes +to equal arc length (recommended unless the wall is very close to the plasma). Re-parameterizing +the contour breaks the plasma/wall grid index correspondence that the singular quadrature relies +on, so it is ignored outright for `nzeta_in > 1` walls. - - `shape::String`: String selecting wall shape. Options are: +A non-axisymmetric boundary (`nzeta_in > 1`) supports only [`ConformalWall`](@ref); the other +shapes are poloidal contours that get revolved and so need `nzeta_in == 1`. +""" +abstract type WallSettings end - + `"nowall"`: No wall - + `"conformal"`: Wall conformal to plasma surface at `standoff_fraction` minor radii - + `"elliptical"`: Elliptical wall - + `"dee"`: Dee-shaped wall - + `"mod_dee"`: Modified Dee-shaped wall - + `"filepath"`: Custom wall shape from the file you specify +""" + ConformalWall(; standoff_fraction=0.3, equal_arc_wall=true) - A non-axisymmetric boundary (`nzeta_in > 1`) supports `"nowall"` and `"conformal"`; the others - are poloidal contours that get revolved and so need `nzeta_in == 1`. +Wall offset from the plasma surface along its normal. - - `standoff_fraction::Float64`: Wall standoff in units of the plasma minor radius `0.5(max R - min R)` - ("conformal" and "dee"). On a non-axisymmetric boundary the extrema are taken over the whole torus, - so it scales with half the global R-extent; this reduces to the axisymmetric definition. +## Fields - - `minor_radius::Float64`: Wall minor radius in m ("elliptical" and "mod_dee") + - `standoff_fraction::Float64`: Distance of the wall from the plasma in units of the minor radius `0.5(max R - min R)`. + On a non-axisymmetric boundary the extrema are taken over the whole torus, so `standoff_fraction` scales with half + the global R-extent rather than a cross-section minor radius. + - `equal_arc_wall::Bool`: Equal arc length node distribution, see [`WallSettings`](@ref) +""" +@kwdef struct ConformalWall <: WallSettings + standoff_fraction::Float64 = 0.3 + equal_arc_wall::Bool = true +end - - `half_thickness::Float64`: Half-thickness parameter for Dee-shaped walls +""" + EllipticalWall(; minor_radius=0.3, equal_arc_wall=true) - - `elongation::Float64`: Elongation parameter for wall shapes +Ellipse confocal with the plasma's bounding ellipse. - - `center_offset::Float64`: Offset of the wall center from the major radius ("dee", in minor radii); wall center R in m ("mod_dee") +## Fields - - `triangularity::Float64`: Triangularity parameter for wall shapes + - `minor_radius::Float64`: Horizontal semi-axis in meters; the vertical semi-axis follows from the confocality + - `equal_arc_wall::Bool`: Equal arc length node distribution, see [`WallSettings`](@ref) +""" +@kwdef struct EllipticalWall <: WallSettings + minor_radius::Float64 = 0.3 + equal_arc_wall::Bool = true +end + +""" + DeeWall(; standoff_fraction=0.3, half_thickness=0.05, elongation=1.5, center_offset=0.0, triangularity=0.5, corner_sharpness=0.05, equal_arc_wall=true) + +Dee-shaped wall scaled to the plasma: minor radius `r_minor (1 + standoff_fraction - center_offset)`, centered at +`r_major + center_offset r_minor`. - - `corner_sharpness::Float64`: Sharpness of the corners of the wall (try 0.05 as initial value) +## Fields - # Core shape selection + - `standoff_fraction::Float64`: Distance of the wall from the plasma in units of the minor radius `0.5(max R - min R)` + - `half_thickness::Float64`: Half-thickness parameter + - `elongation::Float64`: Elongation parameter + - `center_offset::Float64`: Offset of the wall center from the major radius, in units of the minor radius + - `triangularity::Float64`: Triangularity parameter + - `corner_sharpness::Float64`: Sharpness of the corners (try 0.05 as initial value) + - `equal_arc_wall::Bool`: Equal arc length node distribution, see [`WallSettings`](@ref) +""" +@kwdef struct DeeWall <: WallSettings + standoff_fraction::Float64 = 0.3 + half_thickness::Float64 = 0.05 + elongation::Float64 = 1.5 + center_offset::Float64 = 0.0 + triangularity::Float64 = 0.5 + corner_sharpness::Float64 = 0.05 + equal_arc_wall::Bool = true +end - - `equal_arc_wall::Bool`: Flag to enforce equal arc length distribution of nodes on the wall - (recommended unless wall is very close to plasma). Re-parameterizing the contour breaks the - plasma/wall grid index correspondence that the singular quadrature relies on; it is ignored - outright for `nzeta_in > 1` walls. """ -@kwdef struct WallShapeSettings + ModDeeWall(; minor_radius=0.3, half_thickness=0.05, elongation=1.5, center_offset=0.0, triangularity=0.5, corner_sharpness=0.05, equal_arc_wall=true) - # Core shape selection - shape::String = "nowall" +Dee-shaped wall in absolute coordinates, independent of the plasma. Same shape parameters as +[`DeeWall`](@ref), except that `minor_radius` and `center_offset` are lengths in meters. - # Standard geometric parameters for Dee/Mod-Dee - standoff_fraction::Float64 = 0.3 +## Fields + + - `minor_radius::Float64`: Minor radius of the wall in meters + - `half_thickness::Float64`: Half-thickness parameter + - `elongation::Float64`: Elongation parameter + - `center_offset::Float64`: Major radius of the wall center in meters + - `triangularity::Float64`: Triangularity parameter + - `corner_sharpness::Float64`: Sharpness of the corners (try 0.05 as initial value) + - `equal_arc_wall::Bool`: Equal arc length node distribution, see [`WallSettings`](@ref) +""" +@kwdef struct ModDeeWall <: WallSettings minor_radius::Float64 = 0.3 half_thickness::Float64 = 0.05 elongation::Float64 = 1.5 center_offset::Float64 = 0.0 triangularity::Float64 = 0.5 corner_sharpness::Float64 = 0.05 + equal_arc_wall::Bool = true +end + +""" + FileWall(; file, equal_arc_wall=true) - # Algorithmic options +Wall contour read from a text file: the point count, a reference major radius and a header line, +then one `index R Z` row per point, with at least `mtheta` points. + +## Fields + + - `file::String`: Path to the wall file + - `equal_arc_wall::Bool`: Equal arc length node distribution, see [`WallSettings`](@ref) +""" +@kwdef struct FileWall <: WallSettings + file::String equal_arc_wall::Bool = true end +const WALL_SHAPES = Dict("conformal" => ConformalWall, "elliptical" => EllipticalWall, "dee" => DeeWall, "mod_dee" => ModDeeWall, "file" => FileWall) + +""" + parse_wall(section) -> Union{Nothing,WallSettings} + +Build the wall from the `[Wall]` section of a deck, or return `nothing` (no wall) when there is no +section. The section's `shape` picks the [`WallSettings`](@ref) type. Keys that type does not use are +rejected with an error, and a parameter it requires (such as `file` for `FileWall`) is an error if missing. +""" +parse_wall(::Nothing) = nothing +function parse_wall(section::AbstractDict) + params = Dict{String,Any}(section) + shape = pop!(params, "shape", nothing) + haskey(WALL_SHAPES, shape) || error("[Wall] needs a `shape` of $(join(sort!(collect(keys(WALL_SHAPES))), ", ")), got $(repr(shape)). For no wall, omit the [Wall] section.") + T = WALL_SHAPES[shape] + unused = setdiff(keys(params), string.(fieldnames(T))) + isempty(unused) || error("[Wall] shape \"$shape\" does not use $(join(sort!(collect(unused)), ", ")); allowed parameters are $(join(fieldnames(T), ", ")).") + try + return T(; (Symbol(k) => v for (k, v) in params)...) + catch err + err isa UndefKeywordError ? error("[Wall] shape \"$shape\" requires `$(err.var)`.") : rethrow() + end +end + """ PlasmaGeometry @@ -431,7 +512,7 @@ function PlasmaGeometry3D(inputs::VacuumInput) end """ - WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_settings::WallShapeSettings) -> WallGeometry + WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_settings::Union{Nothing,WallSettings}) -> WallGeometry Constructor to initialize the wall geometry based on the provided vacuum inputs and wall shape settings. @@ -443,7 +524,7 @@ surface data for vacuum calculations. - `inputs::VacuumInput`: Struct containing vacuum calculation parameters - `plasma_surf::PlasmaGeometry`: Struct with plasma surface geometry (used for reference) - - `wall_settings::WallShapeSettings`: Struct specifying wall shape and parameters + - `wall_settings::Union{Nothing,WallSettings}`: Wall shape settings, or `nothing` for no wall # Returns @@ -454,14 +535,14 @@ surface data for vacuum calculations. - Supports multiple wall shapes: nowall, conformal, elliptical, dee, mod_dee, from_file - Optionally redistributes wall points to equal arc length spacing if `equal_arc_wall=true` """ -function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_settings::WallShapeSettings) +function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_settings::Union{Nothing,WallSettings}) # Output wall coordinate arrays mtheta = inputs.mtheta x_wall = zeros(mtheta) z_wall = zeros(mtheta) - if wall_settings.shape == "nowall" + if isnothing(wall_settings) return WallGeometry( true, x_wall, @@ -482,14 +563,10 @@ function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_set r_minor = 0.5 * (xmax - xmin) r_major = 0.5 * (xmax + xmin) - # Destructuring settings for readability - # Short Fortran vac.in names for the shape formulas; `a` is the standoff or wall radius per shape - aw, bw, cw, dw, tw = wall_settings.half_thickness, wall_settings.elongation, wall_settings.center_offset, - wall_settings.triangularity, wall_settings.corner_sharpness - a = wall_settings.shape in ("elliptical", "mod_dee") ? wall_settings.minor_radius : wall_settings.standoff_fraction - wcentr = 0.0 # Initialize + wcentr = 0.0 - if wall_settings.shape == "conformal" + if wall_settings isa ConformalWall + a = wall_settings.standoff_fraction dx = a * r_minor @info "Calculating conformal wall shape $((@sprintf "%.2e" dx)) m from plasma surface." wcentr = r_major @@ -507,7 +584,8 @@ function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_set @warn "Conformal wall with standoff_fraction=$a would cross R=0 axis; forcing minimum wall R to $(@sprintf "%.2e" centerstack_min) m to avoid unphysical geometry." end - elseif wall_settings.shape == "elliptical" + elseif wall_settings isa EllipticalWall + a = wall_settings.minor_radius @info "Calculating elliptical wall shape with a = $((@sprintf "%.2e" a)) m." wcentr = r_major @@ -522,7 +600,9 @@ function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_set z_wall[i] = bw_eff * a * sin(the) end - elseif wall_settings.shape == "dee" + elseif wall_settings isa DeeWall + a = wall_settings.standoff_fraction + aw, bw, cw, dw, tw = wall_settings.half_thickness, wall_settings.elongation, wall_settings.center_offset, wall_settings.triangularity, wall_settings.corner_sharpness wcentr = r_major + cw * r_minor @info "Calculating dee-shaped wall with R = $((@sprintf "%.2e" wcentr)) + $((@sprintf "%.2e" r_minor)) * (1.0 + $((@sprintf "%.2e" a)) - $((@sprintf "%.2e" cw))) * cos(θ + $((@sprintf "%.2e" dw)) * sin(θ)), Z = $((@sprintf "%.2e" bw)) * $((@sprintf "%.2e" r_minor)) * (1.0 + $((@sprintf "%.2e" a)) - $((@sprintf "%.2e" cw))) * sin(θ + $((@sprintf "%.2e" tw)) * sin(2θ)) + $((@sprintf "%.2e" aw)) * $((@sprintf "%.2e" r_minor)) * sin(2θ)." for i in 1:mtheta @@ -531,7 +611,9 @@ function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_set z_wall[i] = bw * r_minor * (1.0 + a - cw) * sin(the + tw * sin(2.0*the)) + aw * r_minor * sin(2.0*the) end - elseif wall_settings.shape == "mod_dee" + elseif wall_settings isa ModDeeWall + a = wall_settings.minor_radius + aw, bw, cw, dw, tw = wall_settings.half_thickness, wall_settings.elongation, wall_settings.center_offset, wall_settings.triangularity, wall_settings.corner_sharpness @info "Calculating modified dee-shaped wall with R = $((@sprintf "%.2e" cw)) + $((@sprintf "%.2e" a)) * cos(θ + $((@sprintf "%.2e" dw)) * sin(θ)), Z = $((@sprintf "%.2e" bw)) * $((@sprintf "%.2e" a)) * sin(θ + $((@sprintf "%.2e" tw)) * sin(2θ)) + $((@sprintf "%.2e" aw)) * sin(2θ)." wcentr = cw for i in 1:mtheta @@ -541,17 +623,16 @@ function WallGeometry(inputs::VacuumInput, plasma_surf::PlasmaGeometry, wall_set end else - filepath = wall_settings.shape + filepath = wall_settings.file if !isfile(filepath) error( "Wall geometry file $filepath does not exist. " * - "Please set the wall shape parameter to a valid file path or a built-in shape " * - "(nowall, conformal, elliptical, dee, mod_dee)." + "Please set the FileWall file parameter to a valid file path." ) end wcentr = 0.0 - open(wall_settings.shape, "r") do io + open(wall_settings.file, "r") do io npots0 = parse(Int, readline(io)) # Number of points in file wcentr = parse(Float64, readline(io)) readline(io) # Skip header/comment line @@ -616,7 +697,7 @@ struct WallGeometry3D end """ - WallGeometry3D(inputs::VacuumInput, plasma_surf::PlasmaGeometry3D, wall_settings::WallShapeSettings) -> WallGeometry3D + WallGeometry3D(inputs::VacuumInput, plasma_surf::PlasmaGeometry3D, wall_settings::Union{Nothing,WallSettings}) -> WallGeometry3D Constructor to initialize the 3D wall geometry based on the provided vacuum inputs, plasma surface and wall shape settings. This is the 3D counterpart of [`WallGeometry`](@ref) and selects the shape the same @@ -630,7 +711,7 @@ Expects a full-torus boundary on the same `mtheta`/`nzeta` grid as `plasma_surf` - `inputs::VacuumInput`: Struct containing vacuum calculation parameters - `plasma_surf::PlasmaGeometry3D`: Plasma surface the wall is built around (used by the conformal wall) - - `wall_settings::WallShapeSettings`: Struct specifying wall shape and parameters + - `wall_settings::Union{Nothing,WallSettings}`: Wall shape settings, or `nothing` for no wall # Returns @@ -650,10 +731,10 @@ Expects a full-torus boundary on the same `mtheta`/`nzeta` grid as `plasma_surf` Both fold tests are local, so a global self-intersection between distant lobes — where the offset surface meets itself with aligned normals and a positive area element — passes them """ -function WallGeometry3D(inputs::VacuumInput, plasma_surf::PlasmaGeometry3D, wall_settings::WallShapeSettings) +function WallGeometry3D(inputs::VacuumInput, plasma_surf::PlasmaGeometry3D, wall_settings::Union{Nothing,WallSettings}) # Basic wall flags - nowall = wall_settings.shape == "nowall" + nowall = isnothing(wall_settings) is_closed_toroidal = true (; mtheta, nzeta) = inputs @@ -718,12 +799,12 @@ function WallGeometry3D(inputs::VacuumInput, plasma_surf::PlasmaGeometry3D, wall Z_p = plasma_surf.r[idx, 3] gap[idx] = minimum(hypot(R_p - wall_2D.x[i], Z_p - wall_2D.z[i]) for i in 1:mtheta) end - elseif wall_settings.shape == "conformal" + elseif wall_settings isa ConformalWall # Displace every plasma point outward along its own normal wall_settings.equal_arc_wall && @info "equal_arc_wall is ignored for non-axisymmetric (nzeta_in > 1) walls: it re-parameterizes a 2D contour and would break the plasma/wall index alignment the near-field patch relies on." - # Same logic as 2D, with the extrema taken over the whole torus (see WallShapeSettings.standoff_fraction) + # Same logic as 2D, with the extrema taken over the whole torus (see ConformalWall.standoff_fraction) R_plasma = [hypot(plasma_surf.r[idx, 1], plasma_surf.r[idx, 2]) for idx in axes(plasma_surf.r, 1)] offset_gap = wall_settings.standoff_fraction * 0.5 * (maximum(R_plasma) - minimum(R_plasma)) @info "Calculating conformal wall shape $((@sprintf "%.2e" offset_gap)) m from plasma surface." @@ -739,7 +820,7 @@ function WallGeometry3D(inputs::VacuumInput, plasma_surf::PlasmaGeometry3D, wall # Every point moves exactly offset_gap along its own normal, so the separation needs no search fill!(gap, offset_gap) else - error("Wall shape $(wall_settings.shape) is not available for a non-axisymmetric boundary (nzeta_in > 1).") + error("Wall shape $(nameof(typeof(wall_settings))) is not available for a non-axisymmetric boundary (nzeta_in > 1).") end # Compute tangent vectors and normal vectors via periodic bicubic splines @@ -760,7 +841,7 @@ function WallGeometry3D(inputs::VacuumInput, plasma_surf::PlasmaGeometry3D, wall @views normal .*= normal_orient # Fold check needs a pointwise normal offset (same-index pair) - equal_arc_wall re-parameterizes and breaks that pairing - if wall_settings.shape == "conformal" && (inputs.nzeta_in > 1 || !wall_settings.equal_arc_wall) + if wall_settings isa ConformalWall && (inputs.nzeta_in > 1 || !wall_settings.equal_arc_wall) # The offset stays regular while the normals stay aligned and the area element has not collapsed; both fail past the local concave radius of curvature min_align = Inf # min n̂_wall · n̂_plasma_outward; +1 healthy, ≤0 folded min_area_ratio = Inf # min ||n_wall||/||n_plasma||; →0 at a caustic diff --git a/src/Vacuum/Vacuum.jl b/src/Vacuum/Vacuum.jl index b0e008575..e135bda52 100644 --- a/src/Vacuum/Vacuum.jl +++ b/src/Vacuum/Vacuum.jl @@ -18,7 +18,7 @@ include("PnQuadCache.jl") include("Kernel2D.jl") include("Kernel3D.jl") -export VacuumInput, VacuumResponse, WallShapeSettings +export VacuumInput, VacuumResponse, WallSettings export compute_vacuum_response, compute_vacuum_response! export extract_plasma_surface_at_psi export PlasmaGeometry @@ -50,7 +50,7 @@ function _warn_and_symmetrize!(mat::AbstractMatrix, name::String) end """ - _compute_vacuum_response_2d!(vac_data::VacuumResponse, inputs::VacuumInput, wall_settings::WallShapeSettings; compute_Iv=false) + _compute_vacuum_response_2d!(vac_data::VacuumResponse, inputs::VacuumInput, wall_settings::Union{Nothing,WallSettings}; compute_Iv=false) 2D (axisymmetric) vacuum response calculation. @@ -59,7 +59,7 @@ building the double-/single-layer operators, solving the exterior system for `wv optionally the interior system to build `I_v` when `compute_Iv=true`. Green's functions are internal scratch only. """ -@with_pool pool function _compute_vacuum_response_2d!(vac_data::VacuumResponse, inputs::VacuumInput, wall_settings::WallShapeSettings; compute_Iv::Bool=false) +@with_pool pool function _compute_vacuum_response_2d!(vac_data::VacuumResponse, inputs::VacuumInput, wall_settings::Union{Nothing,WallSettings}; compute_Iv::Bool=false) mpert = length(inputs.m_modes) mlow = inputs.m_modes[1] @@ -226,7 +226,7 @@ Real `D_d`, `S_d` give `D̂₋ₖ = conj(D̂ₖ)`, so class `mod(nfp - k, nfp)` a conjugated mode basis. A stellarator-symmetric boundary gets a [`StellSymBasis`](@ref), making the class operator real and splitting a self-conjugate class into two half-size blocks. """ -@with_pool pool function _compute_vacuum_response_3d!(vac_data::VacuumResponse, inputs::VacuumInput, wall_settings::WallShapeSettings; compute_Iv::Bool=false) +@with_pool pool function _compute_vacuum_response_3d!(vac_data::VacuumResponse, inputs::VacuumInput, wall_settings::Union{Nothing,WallSettings}; compute_Iv::Bool=false) (; mtheta, nzeta, nfp, m_modes, n_modes) = inputs fill!(vac_data.wv, 0) @@ -392,23 +392,23 @@ class operator real and splitting a self-conjugate class into two half-size bloc end """ - compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSettings; compute_Iv=false) -> VacuumResponse + compute_vacuum_response(inputs::VacuumInput, wall_settings::Union{Nothing,WallSettings}; compute_Iv=false) -> VacuumResponse Allocating version of [`compute_vacuum_response!`](@ref). """ -function compute_vacuum_response(inputs::VacuumInput, wall_settings::WallShapeSettings; compute_Iv::Bool=false) +function compute_vacuum_response(inputs::VacuumInput, wall_settings::Union{Nothing,WallSettings}; compute_Iv::Bool=false) vac = VacuumResponse(inputs) compute_vacuum_response!(vac, inputs, wall_settings; compute_Iv) return vac end """ - compute_vacuum_response!(vac_data::VacuumResponse, inputs::VacuumInput, wall_settings::WallShapeSettings; compute_Iv=false) + compute_vacuum_response!(vac_data::VacuumResponse, inputs::VacuumInput, wall_settings::Union{Nothing,WallSettings}; compute_Iv=false) Compute the vacuum response into `vac_data`. 2D (`inputs.nzeta == 1`) and 3D otherwise. Pass `compute_Iv=true` to also fill the surface-current matrix `I_v`. """ -function compute_vacuum_response!(vac_data::VacuumResponse, inputs::VacuumInput, wall_settings::WallShapeSettings; compute_Iv::Bool=false) +function compute_vacuum_response!(vac_data::VacuumResponse, inputs::VacuumInput, wall_settings::Union{Nothing,WallSettings}; compute_Iv::Bool=false) if inputs.nzeta == 1 _compute_vacuum_response_2d!(vac_data, inputs, wall_settings; compute_Iv) else diff --git a/test/runtests_eulerlagrange.jl b/test/runtests_eulerlagrange.jl index 0ff45007d..fb9c03b8d 100644 --- a/test/runtests_eulerlagrange.jl +++ b/test/runtests_eulerlagrange.jl @@ -562,7 +562,7 @@ end eq_config = GeneralizedPerturbedEquilibrium.Equilibrium.EquilibriumConfig(inputs["Equilibrium"], example_dir) sol_cfg = haskey(inputs, "SOL_INPUT") ? GeneralizedPerturbedEquilibrium.Equilibrium.SolovevConfig(inputs["SOL_INPUT"]) : nothing equil = GeneralizedPerturbedEquilibrium.Equilibrium.PlasmaEquilibrium(eq_config, sol_cfg) - intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.WallShapeSettings(; (Symbol(k) => v for (k, v) in inputs["Wall"])...) + intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.parse_wall(get(inputs, "Wall", nothing)) FFS.sing_lim!(intr, ctrl, equil) intr.nlow = ctrl.nn_low intr.nhigh = ctrl.nn_high diff --git a/test/runtests_parallel_integration.jl b/test/runtests_parallel_integration.jl index 67c21f60e..4ac2fcbbb 100644 --- a/test/runtests_parallel_integration.jl +++ b/test/runtests_parallel_integration.jl @@ -295,8 +295,7 @@ end eq_config, haskey(inputs, "SOL_INPUT") ? GeneralizedPerturbedEquilibrium.Equilibrium.SolovevConfig(inputs["SOL_INPUT"]) : nothing ) - intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.WallShapeSettings(; - (Symbol(k) => v for (k, v) in inputs["Wall"])...) + intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.parse_wall(get(inputs, "Wall", nothing)) intr.nlow = ctrl.nn_low; intr.nhigh = ctrl.nn_high; intr.npert = 1 @@ -357,8 +356,7 @@ end rerun_input = GeneralizedPerturbedEquilibrium.Equilibrium.build_direct_from_ingest(eq_config, equil.ingest) equil = GeneralizedPerturbedEquilibrium.Equilibrium.PlasmaEquilibrium(eq_config, rerun_input; override_psi_nodes=psi_nodes) end - intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.WallShapeSettings(; - (Symbol(k) => v for (k, v) in inputs["Wall"])...) + intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.parse_wall(get(inputs, "Wall", nothing)) intr.nlow = ctrl.nn_low; intr.nhigh = ctrl.nn_high; intr.npert = 1 @@ -465,8 +463,7 @@ end eq_config, haskey(inputs, "SOL_INPUT") ? GeneralizedPerturbedEquilibrium.Equilibrium.SolovevConfig(inputs["SOL_INPUT"]) : nothing ) - intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.WallShapeSettings(; - (Symbol(k) => v for (k, v) in inputs["Wall"])...) + intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.parse_wall(get(inputs, "Wall", nothing)) intr.nlow = ctrl.nn_low; intr.nhigh = ctrl.nn_high; intr.npert = 1 @@ -530,8 +527,7 @@ end rerun_input = GeneralizedPerturbedEquilibrium.Equilibrium.build_direct_from_ingest(eq_config, equil.ingest) equil = GeneralizedPerturbedEquilibrium.Equilibrium.PlasmaEquilibrium(eq_config, rerun_input; override_psi_nodes=psi_nodes) end - intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.WallShapeSettings(; - (Symbol(k) => v for (k, v) in inputs["Wall"])...) + intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.parse_wall(get(inputs, "Wall", nothing)) intr.nlow = ctrl.nn_low; intr.nhigh = ctrl.nn_high; intr.npert = 1 diff --git a/test/runtests_riccati.jl b/test/runtests_riccati.jl index 2140b5ac4..661db1e08 100644 --- a/test/runtests_riccati.jl +++ b/test/runtests_riccati.jl @@ -7,8 +7,7 @@ const FFS = GeneralizedPerturbedEquilibrium.ForceFreeStates # setup because intr is mutated by each integration (sing[s].delta_prime etc.). function make_solovev_intr(inputs, ctrl, equil, ex) intr = FFS.ForceFreeStatesInternal(; dir_path=ex) - intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.WallShapeSettings(; - (Symbol(k) => v for (k, v) in inputs["Wall"])...) + intr.wall_settings = GeneralizedPerturbedEquilibrium.Vacuum.parse_wall(get(inputs, "Wall", nothing)) intr.nlow = ctrl.nn_low; intr.nhigh = ctrl.nn_high; intr.npert = 1 diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index 6aedfa3eb..46142b2aa 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -15,7 +15,7 @@ using HDF5 GPEC.Equilibrium.EquilibriumConfig(deck["Equilibrium"], template), GPEC.Equilibrium.SolovevConfig(deck["SOL_INPUT"]) ) - wall = GPEC.Vacuum.WallShapeSettings(; (Symbol(k) => v for (k, v) in deck["Wall"])...) + wall = GPEC.Vacuum.parse_wall(get(deck, "Wall", nothing)) # The deck's `[ForceFreeStates]` block as `solve` keywords: everything except the knobs the # integrator object and the `nn` keyword own. @@ -115,7 +115,7 @@ using HDF5 GPEC.Equilibrium.SolovevConfig(kdeck["SOL_INPUT"]) ) @test kequil.kinetic !== nothing - kwall = GPEC.Vacuum.WallShapeSettings(; (Symbol(k) => v for (k, v) in kdeck["Wall"])...) + kwall = GPEC.Vacuum.parse_wall(get(kdeck, "Wall", nothing)) kkwargs = Dict(Symbol(k) => v for (k, v) in kdeck["ForceFreeStates"] if !(k in ("integrator", "nn_low", "nn_high"))) mktempdir() do dir for name in readdir(kin_template) diff --git a/test/runtests_vacuum.jl b/test/runtests_vacuum.jl index 6b6e91519..2184fe2e2 100644 --- a/test/runtests_vacuum.jl +++ b/test/runtests_vacuum.jl @@ -1,3 +1,6 @@ +using GeneralizedPerturbedEquilibrium: Vacuum +using GeneralizedPerturbedEquilibrium.Vacuum: ConformalWall, EllipticalWall, DeeWall, ModDeeWall, FileWall + @testset "Vacuum.jl Unit Tests" begin @testset "Vacuum.jl (2D)" begin @@ -28,20 +31,34 @@ end end - @testset "WallShapeSettings" begin - @testset "default constructor" begin - w = WallShapeSettings() - @test w.shape == "nowall" - @test w.standoff_fraction == 0.3 - @test w.equal_arc_wall == true - end - - @testset "keyword constructor" begin - w = WallShapeSettings(; shape="conformal", standoff_fraction=0.2, equal_arc_wall=false) - @test w.shape == "conformal" - @test w.standoff_fraction == 0.2 - @test w.equal_arc_wall == false + @testset "WallSettings" begin + @test Vacuum.parse_wall(nothing) === nothing + for (shape, T) in Vacuum.WALL_SHAPES + params = Dict{String,Any}("shape" => shape) + shape == "file" && (params["file"] = "wall.dat") + @test Vacuum.parse_wall(params) isa T + params["unused"] = 1 + err = try + Vacuum.parse_wall(params) + catch err + err + end + @test err isa ErrorException + @test occursin(shape, sprint(showerror, err)) + @test occursin("allowed parameters", sprint(showerror, err)) end + @test_throws ErrorException Vacuum.parse_wall(Dict("shape" => "file")) + @test_throws ErrorException Vacuum.parse_wall(Dict("shape" => "nowall")) + @test_throws ErrorException Vacuum.parse_wall(Dict{String,Any}()) + @test_throws ErrorException Vacuum.parse_wall(Dict("shape" => "wall.dat")) + @test_throws ErrorException Vacuum.parse_wall(Dict("shape" => "conformal", "minor_radius" => 0.2)) + w = Vacuum.parse_wall(Dict("shape" => "conformal", "standoff_fraction" => 1, "equal_arc_wall" => false)) + @test w.standoff_fraction === 1.0 + @test !w.equal_arc_wall + @test ConformalWall().standoff_fraction == 0.3 + @test EllipticalWall().minor_radius == 0.3 + @test DeeWall().center_offset == 0.0 + @test ModDeeWall().center_offset == 0.0 end @testset "PlasmaGeometry" begin @@ -100,7 +117,7 @@ @testset "nowall" begin inputs = _circle_inputs(16) plasma_surf = GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry(inputs) - wall_settings = WallShapeSettings(; shape="nowall") + wall_settings = nothing wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry(inputs, plasma_surf, wall_settings) @test wall.nowall == true @test length(wall.x) == 16 @@ -110,7 +127,7 @@ @testset "conformal" begin inputs = _circle_inputs(16) plasma_surf = GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry(inputs) - wall_settings = WallShapeSettings(; shape="conformal", standoff_fraction=0.2) + wall_settings = ConformalWall(; standoff_fraction=0.2) wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry(inputs, plasma_surf, wall_settings) @test wall.nowall == false @test length(wall.x) == 16 @@ -125,7 +142,7 @@ @testset "elliptical" begin inputs = _circle_inputs(16) plasma_surf = GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry(inputs) - wall_settings = WallShapeSettings(; shape="elliptical", minor_radius=0.5) + wall_settings = EllipticalWall(; minor_radius=0.5) wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry(inputs, plasma_surf, wall_settings) @test wall.nowall == false @test length(wall.x) == 16 @@ -136,7 +153,7 @@ @testset "dee" begin inputs = _circle_inputs(16) plasma_surf = GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry(inputs) - wall_settings = WallShapeSettings(; shape="dee", standoff_fraction=0.1, center_offset=0.0) + wall_settings = DeeWall(; standoff_fraction=0.1, center_offset=0.0) wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry(inputs, plasma_surf, wall_settings) @test wall.nowall == false @test length(wall.x) == 16 @@ -159,7 +176,7 @@ # Use a "dee" wall shape with parameters that will produce R < 0 # Setting cw (offset) to a large negative value will shift the wall left past R=0 - wall_settings = WallShapeSettings(; shape="dee", center_offset=-1.5, standoff_fraction=0.1) + wall_settings = DeeWall(; center_offset=-1.5, standoff_fraction=0.1) @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry(inputs, plasma_surf_near_zero, wall_settings) end @@ -179,7 +196,7 @@ nzeta=1 ) ) - wall_settings = WallShapeSettings(; shape="conformal", standoff_fraction=10.0, equal_arc_wall=false) + wall_settings = ConformalWall(; standoff_fraction=10.0, equal_arc_wall=false) wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry(inputs, plasma_surf_near_zero, wall_settings) expected_min = min(0.1, 0.1 * minimum(plasma_surf_near_zero.x)) @test all(wall.x .>= expected_min) @@ -362,7 +379,7 @@ @testset "nowall" begin inputs = _make_inputs() - wall_settings = WallShapeSettings(; shape="nowall") + wall_settings = nothing (; wv, I_v, plasma_pts, wall_pts) = compute_vacuum_response(inputs, wall_settings) numpoints = inputs.mtheta * inputs.nzeta @@ -383,7 +400,7 @@ @testset "nowall compute_Iv=true" begin inputs = _make_inputs() - wall_settings = WallShapeSettings(; shape="nowall") + wall_settings = nothing (; wv, I_v, plasma_pts, wall_pts) = compute_vacuum_response(inputs, wall_settings; compute_Iv=true) num_modes = length(inputs.m_modes) * length(inputs.n_modes) @@ -397,7 +414,7 @@ @testset "conformal wall" begin inputs = _make_inputs() - wall_settings = WallShapeSettings(; shape="conformal", standoff_fraction=0.5) + wall_settings = ConformalWall(; standoff_fraction=0.5) (; wv, I_v, plasma_pts, wall_pts) = compute_vacuum_response(inputs, wall_settings) num_modes = length(inputs.m_modes) * length(inputs.n_modes) @@ -413,7 +430,7 @@ @testset "edge: single poloidal mode mpert=1" begin inputs = _make_inputs(; m_modes=[1], n_modes=[1]) - wall_settings = WallShapeSettings(; shape="nowall") + wall_settings = nothing (; wv, I_v, plasma_pts, wall_pts) = compute_vacuum_response(inputs, wall_settings) @test size(wv) == (1, 1) @test all(isfinite, wv) @@ -423,7 +440,7 @@ @testset "edge: small mtheta" begin # Keep mtheta_eq=17 so boundary has enough points for periodic spline inputs = _make_inputs(; mtheta=16, mtheta_eq=17) - wall_settings = WallShapeSettings(; shape="nowall") + wall_settings = nothing (; wv, I_v, plasma_pts, wall_pts) = compute_vacuum_response(inputs, wall_settings) @test size(wv) == (2, 2) @test size(I_v) == (2, 2) @@ -433,7 +450,7 @@ @testset "in-place compute_vacuum_response! matches wrapper" begin # The allocating wrapper is a thin caller of the in-place routine; verify the # in-place entry populates caller-owned storage identically. - for wall_settings in (WallShapeSettings(; shape="nowall"), WallShapeSettings(; shape="conformal", standoff_fraction=0.5)) + for wall_settings in (nothing, ConformalWall(; standoff_fraction=0.5)) inputs = _make_inputs() ref = compute_vacuum_response(inputs, wall_settings; compute_Iv=true) @@ -453,11 +470,11 @@ inputs = _make_inputs() vac = VacuumResponse(inputs) - compute_vacuum_response!(vac, inputs, WallShapeSettings(; shape="conformal", standoff_fraction=0.5); compute_Iv=true) + compute_vacuum_response!(vac, inputs, ConformalWall(; standoff_fraction=0.5); compute_Iv=true) @test !all(iszero, vac.I_v) - compute_vacuum_response!(vac, inputs, WallShapeSettings(; shape="nowall")) - fresh = compute_vacuum_response(inputs, WallShapeSettings(; shape="nowall")) + compute_vacuum_response!(vac, inputs, nothing) + fresh = compute_vacuum_response(inputs, nothing) @test all(iszero, vac.I_v) @test vac.wv ≈ fresh.wv @@ -612,7 +629,7 @@ @testset "WallGeometry3D nowall" begin inputs = _make_3d_inputs(; mtheta=32, nzeta=32, mtheta_eq=17) wall = - GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(inputs, GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(inputs), WallShapeSettings(; shape="nowall")) + GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(inputs, GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(inputs), nothing) @test wall.nowall == true @test wall.mtheta == 32 @test wall.nzeta == 32 @@ -624,7 +641,7 @@ wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( inputs, GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(inputs), - WallShapeSettings(; shape="conformal", standoff_fraction=0.2) + ConformalWall(; standoff_fraction=0.2) ) @test wall.nowall == false @test wall.mtheta == 32 @@ -656,7 +673,7 @@ @testset "WallGeometry3D conformal, non-axisymmetric input" begin inputs = _make_3d_periodic_inputs(; mtheta=24, nzeta=24) - settings = WallShapeSettings(; shape="conformal", standoff_fraction=0.2, equal_arc_wall=false) + settings = ConformalWall(; standoff_fraction=0.2, equal_arc_wall=false) plasma = GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(inputs) wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(inputs, plasma, settings) @@ -692,7 +709,7 @@ inputs = _make_3d_periodic_inputs(; mtheta=24, nzeta=24, mtheta_in=32, nzeta_in=16, eps=0.0) plasma = GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(inputs) for a in (0.2, 0.5) - wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(inputs, plasma, WallShapeSettings(; shape="conformal", standoff_fraction=a, equal_arc_wall=false)) + wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(inputs, plasma, ConformalWall(; standoff_fraction=a, equal_arc_wall=false)) radii = [hypot(hypot(wall.r[i, 1], wall.r[i, 2]) - R0, wall.r[i, 3]) for i in axes(wall.r, 1)] # Residual is the 32 -> 24 poloidal resampling of the boundary spline, not the offset @test all(isapprox(rad, r_minor * (1 + a); atol=5e-4) for rad in radii) @@ -702,35 +719,35 @@ @testset "WallGeometry3D non-axisymmetric error paths" begin inputs = _make_3d_periodic_inputs(; mtheta=24, nzeta=24) plasma = GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(inputs) - for shape in ("elliptical", "dee", "mod_dee", "some_wall_file.dat") - @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(inputs, plasma, WallShapeSettings(; shape=shape)) + for settings in (EllipticalWall(), DeeWall(), ModDeeWall(), FileWall(; file="some_wall_file.dat")) + @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(inputs, plasma, settings) end # equal_arc_wall has no meaning without a 2D contour and is ignored with a notice. It defaults to # true, so this fires on runs that never set it: @info, not @warn. @test_logs (:info, r"equal_arc_wall is ignored") match_mode = :any GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( inputs, plasma, - WallShapeSettings(; shape="conformal", standoff_fraction=0.2, equal_arc_wall=true) + ConformalWall(; standoff_fraction=0.2, equal_arc_wall=true) ) # nfp > 1 can only reach the constructor from an axisymmetric boundary, and is rejected there per_period = VacuumInput(; x=inputs.x, y=inputs.y, z=inputs.z, mtheta_in=16, nzeta_in=16, m_modes=inputs.m_modes, n_modes=inputs.n_modes, mtheta=24, nzeta=24, nfp=3) - @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(per_period, plasma, WallShapeSettings(; shape="conformal", standoff_fraction=0.2)) + @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(per_period, plasma, ConformalWall(; standoff_fraction=0.2)) # nowall does not skip that guard - @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(per_period, plasma, WallShapeSettings(; shape="nowall")) + @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(per_period, plasma, nothing) # a plasma surface from a different grid is rejected rather than indexed off the end other_grid = _make_3d_periodic_inputs(; mtheta=16, nzeta=16) @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( inputs, GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(other_grid), - WallShapeSettings(; shape="conformal", standoff_fraction=0.2) + ConformalWall(; standoff_fraction=0.2) ) # an offset that folds the surface is rejected rather than silently returned folded = _make_3d_nonaxis_inputs(; mtheta=24, nzeta=24, mtheta_in=12, nzeta_in=12) @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( folded, GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(folded), - WallShapeSettings(; shape="conformal", standoff_fraction=0.2, equal_arc_wall=false) + ConformalWall(; standoff_fraction=0.2, equal_arc_wall=false) ) end @@ -741,9 +758,9 @@ axi = VacuumInput(; mtheta_in=32, nzeta_in=1, x=collect(1.7 .+ 0.3 .* cos.(θ_eq)), z=collect(0.45 .* sin.(θ_eq)), ν=zeros(32), m_modes=[1, 2], n_modes=[0, 1], mtheta=32, nzeta=32) - for (shape, settings) in (("elliptical", WallShapeSettings(; shape="elliptical", minor_radius=0.5)), - ("dee", WallShapeSettings(; shape="dee", standoff_fraction=0.3)), - ("mod_dee", WallShapeSettings(; shape="mod_dee", minor_radius=0.5, center_offset=1.7))) + for (shape, settings) in (("elliptical", EllipticalWall(; minor_radius=0.5)), + ("dee", DeeWall(; standoff_fraction=0.3)), + ("mod_dee", ModDeeWall(; minor_radius=0.5, center_offset=1.7))) wall = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(axi, GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(axi), settings) @test size(wall.r) == (32 * 32, 3) @test all(isfinite, wall.normal) @@ -754,19 +771,19 @@ # Gap warning uses the measured same-index separation, not a conformal-only offset that # is 0 for these shapes. a = 1 m sits well outside one coarser cell, so no sub-cell warning. @test_logs min_level = Base.CoreLogging.Warn GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( - axi, GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(axi), WallShapeSettings(; shape="elliptical", minor_radius=1.0) + axi, GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(axi), EllipticalWall(; minor_radius=1.0) ) # an unreadable wall file is still reported by the 2D reader @test_throws ErrorException GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( axi, GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(axi), - WallShapeSettings(; shape="no_such_wall.dat") + FileWall(; file="no_such_wall.dat") ) end @testset "compute_vacuum_response 3D nowall" begin inputs = _make_3d_inputs(; mtheta=32, nzeta=32, mtheta_eq=17) - wall_settings = WallShapeSettings(; shape="nowall") + wall_settings = nothing (; wv, I_v, plasma_pts, wall_pts) = compute_vacuum_response(inputs, wall_settings) numpoints = inputs.mtheta * inputs.nzeta @@ -787,7 +804,7 @@ @testset "compute_vacuum_response 3D nonaxisymmetric boundary" begin inputs = _make_3d_nonaxis_inputs(; mtheta=24, nzeta=24, mtheta_in=12, nzeta_in=12, mpert=2, nlow=0, npert=2) - wall_settings = WallShapeSettings(; shape="nowall") + wall_settings = nothing (; wv, I_v, plasma_pts, wall_pts) = compute_vacuum_response(inputs, wall_settings) numpoints = inputs.mtheta * inputs.nzeta @@ -806,7 +823,7 @@ @testset "compute_vacuum_response 3D conformal wall" begin inputs = _make_3d_inputs(; mtheta=32, nzeta=32, mtheta_eq=17) - wall_settings = WallShapeSettings(; shape="conformal", standoff_fraction=0.3) + wall_settings = ConformalWall(; standoff_fraction=0.3) (; wv, I_v, plasma_pts, wall_pts) = compute_vacuum_response(inputs, wall_settings) num_modes = length(inputs.m_modes) * length(inputs.n_modes) @@ -822,7 +839,7 @@ @testset "compute_vacuum_response 3D compute_Iv=true" begin inputs = _make_3d_inputs(; mtheta=32, nzeta=32, mtheta_eq=17) # Both wall branches: I_v was left zeroed in 3D before, and the wall adds a second source block - for wall_settings in (WallShapeSettings(; shape="nowall"), WallShapeSettings(; shape="conformal", standoff_fraction=0.3)) + for wall_settings in (nothing, ConformalWall(; standoff_fraction=0.3)) (; I_v) = compute_vacuum_response(inputs, wall_settings; compute_Iv=true) @test all(isfinite, I_v) @test !all(iszero, I_v) @@ -830,9 +847,9 @@ # A reused buffer must not keep a stale I_v from an earlier compute_Iv=true solve vac = GeneralizedPerturbedEquilibrium.Vacuum.VacuumResponse(inputs) - compute_vacuum_response!(vac, inputs, WallShapeSettings(; shape="nowall"); compute_Iv=true) + compute_vacuum_response!(vac, inputs, nothing; compute_Iv=true) @test !all(iszero, vac.I_v) - compute_vacuum_response!(vac, inputs, WallShapeSettings(; shape="nowall")) + compute_vacuum_response!(vac, inputs, nothing) @test all(iszero, vac.I_v) end @@ -847,7 +864,7 @@ Z = 0.3 .* sin.(θ) .+ 0.08 .* sin.(2θ) .+ 0.08 .* cos.(θ) make(nzeta) = VacuumInput(; x=collect(R), z=collect(Z), ν=zeros(mtheta), mtheta_in=mtheta, nzeta_in=1, m_modes=[-2, -1, 0, 1, 2], n_modes=[1], mtheta=mtheta, nzeta=nzeta) - nowall = WallShapeSettings(; shape="nowall") + nowall = nothing r2d = compute_vacuum_response(make(1), nowall; compute_Iv=true) r3d = compute_vacuum_response(make(96), nowall; compute_Iv=true) @@ -868,7 +885,7 @@ ν = 0.25 .* sin.(θ) .+ 0.1 .* cos.(2θ) .+ 0.05 make(nzeta, ν) = VacuumInput(x=collect(R), z=collect(Z), ν=collect(ν), mtheta_in=mtheta, nzeta_in=1, m_modes=collect(-3:3), n_modes=[1], mtheta=mtheta, nzeta=nzeta) - nowall = WallShapeSettings(shape="nowall") + nowall = nothing r3d = compute_vacuum_response(make(48, ν), nowall) @test norm(compute_vacuum_response(make(1, ν), nowall).wv - r3d.wv) / norm(r3d.wv) < 1e-2 @@ -884,7 +901,7 @@ Z = 0.8 .* sin.(θ) .+ 0.08 .* cos.(θ) .+ 0.1 .* cos.(2θ) m_modes = collect(-4:6) D = Diagonal(cis.(m_modes .* (2π * k / mtheta))) - nowall = WallShapeSettings(shape="nowall") + nowall = nothing # 2D only: "3D I_v matches the 2D path" ties the 3D frame to this one make(x, z) = VacuumInput(; x, z, ν=zeros(mtheta), mtheta_in=mtheta, nzeta_in=1, m_modes, n_modes=[1], mtheta, nzeta=1) ref = compute_vacuum_response(make(collect(R), collect(Z)), nowall; compute_Iv=true) @@ -900,7 +917,7 @@ θ = range(; start=0, length=mtheta, step=2π/mtheta) R, Z = 1.7 .+ 0.3 .* cos.(θ), 0.4 .* sin.(θ) make(x, z) = VacuumInput(; x, z, ν=zeros(mtheta), mtheta_in=mtheta, nzeta_in=1, m_modes=collect(-2:2), n_modes=[1], mtheta, nzeta=1) - @test_throws ArgumentError compute_vacuum_response(make(reverse(R), reverse(Z)), WallShapeSettings(shape="nowall")) + @test_throws ArgumentError compute_vacuum_response(make(reverse(R), reverse(Z)), nothing) # A wall file written clockwise is turned round and gives the same operator as its counter-clockwise twin Rw, Zw = 1.7 .+ 0.6 .* cos.(θ), 0.8 .* sin.(θ) @@ -910,7 +927,7 @@ println(io, mtheta, "\n", 1.7, "\nindex R Z") foreach(i -> println(io, i, " ", x[i], " ", z[i]), 1:mtheta) end - compute_vacuum_response(make(collect(R), collect(Z)), WallShapeSettings(shape=path, equal_arc_wall=false)) + compute_vacuum_response(make(collect(R), collect(Z)), FileWall(; file=path, equal_arc_wall=false)) end ccw = wall_response(Rw, Zw) cw = @test_logs (:warn, r"runs clockwise") match_mode = :any wall_response(circshift(reverse(Rw), 1), circshift(reverse(Zw), 1)) @@ -924,7 +941,7 @@ # the near-field patch keeps its full PATCH_RAD. On a 24 x 24 grid those two demands cannot be met # by a close wall: h_ζ ~ 2π R0 / nzeta is 0.45 m here. inputs = _make_3d_periodic_inputs(; mtheta=24, nzeta=24) - settings = WallShapeSettings(; shape="conformal", standoff_fraction=2.5, equal_arc_wall=false) + settings = ConformalWall(; standoff_fraction=2.5, equal_arc_wall=false) wv = compute_vacuum_response(inputs, settings).wv @test all(isfinite, wv) @@ -932,7 +949,7 @@ # A conducting wall is stabilizing, so Wᵛ must exceed the nowall value. Catches a flipped normal # or an offset built on the wrong side, which finiteness and Hermiticity both pass. - @test real(wv[1, 1]) > real(compute_vacuum_response(inputs, WallShapeSettings(; shape="nowall")).wv[1, 1]) + @test real(wv[1, 1]) > real(compute_vacuum_response(inputs, nothing).wv[1, 1]) # This configuration clears the one-cell floor, so it draws no sub-cell or near-fold warning @test_logs min_level = Base.CoreLogging.Warn GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D( @@ -977,7 +994,7 @@ mtheta=mtheta, nzeta=nzeta_p, nfp=nfp ) - wall_settings = WallShapeSettings(; shape="nowall") + wall_settings = nothing # Reduced (block-circulant) path vac_red = compute_vacuum_response(inputs_red, wall_settings) @@ -1023,7 +1040,7 @@ # A wall adds a second source block to the operator, so repeat the check with one present: # the field-period fold has to land the wall columns in the right block for both to agree. - walled = WallShapeSettings(; shape="conformal", standoff_fraction=0.2, equal_arc_wall=false) + walled = ConformalWall(; standoff_fraction=0.2, equal_arc_wall=false) vac_red_wall = compute_vacuum_response(inputs_red, walled; compute_Iv=true) vac_full_wall = compute_vacuum_response(inputs_full, walled; compute_Iv=true) @test isapprox(vac_red_wall.wv, vac_full_wall.wv; rtol=1e-6, atol=1e-7) @@ -1061,7 +1078,7 @@ end mtheta, nzeta_p = 24, 8 - walled = WallShapeSettings(; shape="conformal", standoff_fraction=0.2, equal_arc_wall=false) + walled = ConformalWall(; standoff_fraction=0.2, equal_arc_wall=false) # The whole item rests on the operator inheriting the reflection symmetry, so assert it directly. inputs = _stell_inputs(; mtheta=mtheta, nzeta_p=nzeta_p, nfp=3, n_modes=[1]) @@ -1103,7 +1120,7 @@ inp = _stell_inputs(; mtheta=mtheta, nzeta_p=nzeta_p, nfp=nfp, n_modes=[k]) geom = GeneralizedPerturbedEquilibrium.Vacuum.expand_field_periods(inp) plas = GeneralizedPerturbedEquilibrium.Vacuum.PlasmaGeometry3D(geom) - wal = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(geom, plas, WallShapeSettings(; shape="nowall")) + wal = GeneralizedPerturbedEquilibrium.Vacuum.WallGeometry3D(geom, plas, nothing) mirror = GeneralizedPerturbedEquilibrium.Vacuum.stell_sym_map(plas, wal, nfp) @test mirror !== nothing nfp_pts = plas.mtheta * plas.nzeta ÷ nfp @@ -1137,7 +1154,7 @@ # Every reduced-vs-full check above uses a symmetric boundary; this takes the general complex path, with pairing asym_red = _stell_inputs(; mtheta=mtheta, nzeta_p=nzeta_p, nfp=3, n_modes=[1, 2, 3, 4], odd=0.07) asym_ref = GeneralizedPerturbedEquilibrium.Vacuum.expand_field_periods(asym_red) - for ws in (WallShapeSettings(; shape="nowall"), walled) + for ws in (nothing, walled) red = compute_vacuum_response(asym_red, ws; compute_Iv=true) ref = compute_vacuum_response(asym_ref, ws; compute_Iv=true) @test isapprox(red.wv, ref.wv; rtol=1e-10) diff --git a/test/test_data/regression_solovev_ideal_example/gpec.toml b/test/test_data/regression_solovev_ideal_example/gpec.toml index 2dc2c057c..925c419f1 100644 --- a/test/test_data/regression_solovev_ideal_example/gpec.toml +++ b/test/test_data/regression_solovev_ideal_example/gpec.toml @@ -15,13 +15,8 @@ flux_surface_rtol = 1e-7 # Relative tolerance of the ODE solves t force_termination = false # Terminate after equilibrium setup (skip stability calculations) [Wall] -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath); close conformal wall stabilizes the Solovev n=1 kink (see examples/Solovev_ideal_example/gpec.toml) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 0.2415 # Wall standoff in plasma minor radii equal_arc_wall = true # Equal arc length distribution of nodes on wall # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords diff --git a/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml b/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml index f87c07b1b..9eb253949 100644 --- a/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml +++ b/test/test_data/regression_solovev_ideal_example_multi_n/gpec.toml @@ -15,13 +15,8 @@ flux_surface_rtol = 1e-7 # Relative tolerance of the ODE solves t force_termination = false # Terminate after equilibrium setup (skip stability calculations) [Wall] -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath); close conformal wall stabilizes the Solovev n=1 kink (see examples/Solovev_ideal_example/gpec.toml) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 0.2415 # Wall standoff in plasma minor radii equal_arc_wall = true # Equal arc length distribution of nodes on wall # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords diff --git a/test/test_data/regression_solovev_kinetic_calculated/gpec.toml b/test/test_data/regression_solovev_kinetic_calculated/gpec.toml index 6d482c706..993bccbfe 100644 --- a/test/test_data/regression_solovev_kinetic_calculated/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_calculated/gpec.toml @@ -17,13 +17,8 @@ force_termination = false # Terminate after equilibrium setup (ski [Wall] # A close conformal wall stabilizes the Solovev n=1 kink for this fixture. -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 0.2415 # Wall standoff in plasma minor radii equal_arc_wall = true # Equal arc length distribution of nodes on wall # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords diff --git a/test/test_data/regression_solovev_kinetic_example/gpec.toml b/test/test_data/regression_solovev_kinetic_example/gpec.toml index af9bf78f8..315272914 100644 --- a/test/test_data/regression_solovev_kinetic_example/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_example/gpec.toml @@ -15,13 +15,8 @@ flux_surface_rtol = 1e-7 # Relative tolerance of the ODE solves t force_termination = false # Terminate after equilibrium setup (skip stability calculations) [Wall] -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath); close conformal wall stabilizes the Solovev n=1 kink (see examples/Solovev_ideal_example/gpec.toml) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 0.2415 # Wall standoff in plasma minor radii equal_arc_wall = true # Equal arc length distribution of nodes on wall # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords diff --git a/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml b/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml index bd31bef3e..9a8b41008 100644 --- a/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_multi_n/gpec.toml @@ -15,13 +15,8 @@ flux_surface_rtol = 1e-7 # Relative tolerance of the ODE solves t force_termination = false # Terminate after equilibrium setup (skip stability calculations) [Wall] -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath); close conformal wall stabilizes the Solovev n=1 kink (see examples/Solovev_ideal_example/gpec.toml) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 0.2415 # Wall standoff in plasma minor radii equal_arc_wall = true # Equal arc length distribution of nodes on wall # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords diff --git a/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml b/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml index c5743bbb9..7c4d326e7 100644 --- a/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml +++ b/test/test_data/regression_solovev_kinetic_nuzero/gpec.toml @@ -17,13 +17,8 @@ flux_surface_rtol = 1e-7 # Relative tolerance of the ODE solves t force_termination = false # Terminate after equilibrium setup (skip stability calculations) [Wall] -shape = "conformal" # Wall shape (nowall, conformal, elliptical, dee, mod_dee, filepath) -standoff_fraction = 0.2415 # Wall standoff in plasma minor radii (conformal, dee) -half_thickness = 0.05 # Half-thickness parameter for Dee-shaped walls -elongation = 1.5 # Elongation parameter for wall shapes -center_offset = 0 # Offset of wall center from major radius -triangularity = 0.5 # Triangularity parameter for wall shapes -corner_sharpness = 0.05 # Sharpness of wall corners (try 0.05 as initial value) +shape = "conformal" # Wall shape (conformal, elliptical, dee, mod_dee, file) +standoff_fraction = 0.2415 # Wall standoff in plasma minor radii equal_arc_wall = true # Equal arc length distribution of nodes on wall # Presence runs the LocalStability stage (Mercier D_I, resistive D_R, ballooning Δ', first α boundary); keys are the Ballooning keywords From c6536ef242fae77f6081e2d6688c42032f10a588 Mon Sep 17 00:00:00 2001 From: Matthew Pharr Date: Wed, 28 Oct 2026 22:21:45 +0100 Subject: [PATCH 16/60] Repo - API! - Unify stage result saving under save_to_hdf5 Implement consolidation spec commits 1 and 2 together: introduce the shared save protocol, give stage results their own writers, and move file writes from computation into the driver. Keep schema metadata in Utilities, preserve the HDF5Schema title Area, and record FFS input provenance for explicit script saves. Validate focused FFS output, frozen equilibrium, multi-ion, tearing, torque-response, and error-field tests, plus documentation. The SDCC comparison against wall-port commit 14a374549 follows this commit. --- ci/conventions/check_subject.py | 3 +- docs/development/hdf5-conventions.md | 4 +- docs/development/naming.md | 2 +- docs/src/api.md | 13 +- docs/src/perturbed_equilibrium.md | 3 +- src/Equilibrium/Equilibrium.jl | 2 + src/Equilibrium/Output.jl | 40 ++ src/ErrorFields/Output.jl | 35 +- src/ForceFreeStates/ForceFreeStates.jl | 3 + src/ForceFreeStates/InputSnapshot.jl | 44 ++ src/ForceFreeStates/Integrators.jl | 6 + src/ForceFreeStates/Matching/ResonantMatch.jl | 2 +- src/ForceFreeStates/Output.jl | 293 ++++++++++ src/ForceFreeStates/Result.jl | 7 +- src/GeneralizedPerturbedEquilibrium.jl | 549 +++--------------- src/KineticForces/Output.jl | 19 +- src/LocalStability/LocalStability.jl | 2 + src/LocalStability/Output.jl | 16 + .../PerturbedEquilibrium.jl | 2 +- src/PerturbedEquilibrium/Utils.jl | 280 ++++----- src/Rerun.jl | 4 +- src/Tearing/Runner/HDF5Output.jl | 37 +- src/Tearing/Runner/Runner.jl | 2 +- src/Utilities/HDF5Annotations.jl | 44 +- src/{ => Utilities}/HDF5Schema.jl | 14 +- src/Utilities/Output.jl | 17 + src/Utilities/Utilities.jl | 3 + test/runtests.jl | 1 + test/runtests_ffs_output.jl | 46 ++ test/runtests_frozen_equilibrium.jl | 4 +- test/runtests_multiion.jl | 6 +- test/runtests_ntv_limits.jl | 2 +- test/runtests_slayer_runner.jl | 8 +- test/runtests_solve_api.jl | 8 +- test/runtests_torque_response.jl | 4 +- 35 files changed, 819 insertions(+), 706 deletions(-) create mode 100644 src/Equilibrium/Output.jl create mode 100644 src/ForceFreeStates/InputSnapshot.jl create mode 100644 src/ForceFreeStates/Output.jl create mode 100644 src/LocalStability/Output.jl rename src/{ => Utilities}/HDF5Schema.jl (98%) create mode 100644 src/Utilities/Output.jl create mode 100644 test/runtests_ffs_output.jl diff --git a/ci/conventions/check_subject.py b/ci/conventions/check_subject.py index 543844ccc..ecb8b4d8b 100644 --- a/ci/conventions/check_subject.py +++ b/ci/conventions/check_subject.py @@ -48,6 +48,7 @@ "CI", "Docs", "Examples", + "HDF5Schema", "Regression", "Repo", "Test", @@ -137,7 +138,7 @@ def module_areas(root: Path) -> list[str]: if any(sub.glob("*.jl")): areas.append(f"{module.name}.{sub.name}") - # Top-level sources such as Rerun.jl and HDF5Schema.jl are components in + # Top-level sources such as Rerun.jl are components in # their own right and are committed against directly. for source in sorted(src.glob("*.jl")): if source.name != PACKAGE_ENTRY: diff --git a/docs/development/hdf5-conventions.md b/docs/development/hdf5-conventions.md index 0b94c9cb1..a276f55a3 100644 --- a/docs/development/hdf5-conventions.md +++ b/docs/development/hdf5-conventions.md @@ -20,7 +20,7 @@ These rules govern `gpec.h5` (and any future GPEC-produced HDF5 output); harness - **Data-driven tokens are stored verbatim**: coil-set names under `Input/RawInputs/Coils/`, KineticForces method tokens (`fgar`, …), NTV species labels under `KineticForces/PerSpecies/` (`ion_z1_m2`, `impurity_z6_m12`, `electron` — charge and mass identify the species, with a numeric discriminator appended only if a run repeats a `(z, m)` pair), scan indices (`Surface_`, `psi_`). - **Word-valued names and boolean flags**: multi-word dataset names are snake_case English (`resonance_psi`, `trajectory_offsets`, `layer_widths`), never CamelCase — CamelCase is reserved for groups. A boolean flag is named for the state it asserts when true, with an `is_` prefix only where the bare word would read as a noun or collide with a data family: `is_rational` (bare `rational` would clash with the `rational_*` coordinate family) versus `enabled`, `truncated`, `no_root`, which already read as predicates. - **Literature capitalization for physics symbols**: names match the standard literature — `D_I`, `D_R`, `Delta_prime`, `tau_R`, `tau_A` (not `di`, `dr`, `delta_prime`, `taur`); lowercase stays where the literature is lowercase (`alpha`, `q`, `beta*`, `delta_s`). -- **Scalar equilibrium parameters spell out the physics**: `R_axis`, `Z_axis`, `B_T_axis`, `a_mean`, `aspect_ratio`, `I_p`, `q_edge`, `beta_N`, `delta_upper`/`delta_lower`. The qualifier is a trailing subscript (`_axis`, `_edge`, `_wall`, `_min`, `_max`, `_upper`, `_lower`, `_extremum`), and a numbered literature definition keeps its number as the last subscript (`beta_p_1`, `l_i_2`). Fortran-era contractions (`bt0`, `amean`, `crnt`, `qa`, `betan`, `li1`) survive only as `Equilibrium.EquilibriumParameters` struct fields: `EQUIL_H5_NAMES` in `src/HDF5Schema.jl` maps each field to its dataset name, and `EQUIL_H5_SKIP` drops fields that duplicate another dataset or echo a control flag. +- **Scalar equilibrium parameters spell out the physics**: `R_axis`, `Z_axis`, `B_T_axis`, `a_mean`, `aspect_ratio`, `I_p`, `q_edge`, `beta_N`, `delta_upper`/`delta_lower`. The qualifier is a trailing subscript (`_axis`, `_edge`, `_wall`, `_min`, `_max`, `_upper`, `_lower`, `_extremum`), and a numbered literature definition keeps its number as the last subscript (`beta_p_1`, `l_i_2`). Fortran-era contractions (`bt0`, `amean`, `crnt`, `qa`, `betan`, `li1`) survive only as `Equilibrium.EquilibriumParameters` struct fields: `EQUIL_H5_NAMES` in `src/Utilities/HDF5Schema.jl` maps each field to its dataset name, and `EQUIL_H5_SKIP` drops fields that duplicate another dataset or echo a control flag. - **Derivatives are `ddpsi`** (`dTdpsi`, `dVdpsi`, `dqdpsi`, `dxidpsi`) — never Fortran `1` suffixes or `_deriv`. - **"rational" over "singular"** in dataset names (`rational_psi`, `rational_q`, `rational_m`, `rational_n`, `rational_index`, `rational_count`) — kinetic/resistive runs are not singular at the rationals. Specifier order is standardized specifier-first (`rational_psi`, never `psi_rational`). - **Vector components** follow `[d][_]_[dpsi]` — the variable always comes first and the coordinate is always the trailing subscript. A bare coordinate suffix is the **contravariant** component (`xi_psi` = ξ^ψ), `_cov_` marks the **covariant** one (`b_cov_theta` = b_θ), a leading `J` marks a **Jacobian-weighted** component (`Jxi_theta` = J·ξ^θ), and other representations sit in the same slot (`xi_clebsch_psi`, `dxi_clebsch_psidpsi`). Never drop the variable: `clebsch_psi` is wrong because it does not say *what* is being represented. There is no HDF5/netCDF standard for super- vs subscripts — flat `_` names are universal — so the typeset form always appears in the dataset's `long_name`. @@ -62,7 +62,7 @@ Every dataset outside `Input/` (raw snapshot) must answer "what is this, in what Root-level file attributes: `schema_version` (currently `"2.0"`; bump on breaking schema changes — readers dispatch on it), `Conventions = "GPEC-HDF5-2.0"`, `references`, `title` (run description), `date_created` (ISO 8601 UTC). The code version stays in `Info/git_version`. -Mechanism: writers stay table-driven — each writer keeps a `path => (; long_name, units, dims, scale, attach)` table next to it (`scale` marks a coordinate dataset as a dimension scale; `attach` binds axes to scales — see the `Utilities.HDF5Annotations.annotate!` docstring) (`src/HDF5Schema.jl` for the main writer; alongside `write_galerkin!`, the PerturbedEquilibrium writer, `KineticForces/Output.jl`, and `Tearing/Runner/HDF5Output.jl` for the rest) and applies it post-write via `Utilities.HDF5Annotations.annotate!`. Entries for conditionally-written datasets are simply skipped when absent. When adding a dataset, add its table entry in the same commit — the schema test fails otherwise. +Mechanism: writers stay table-driven — each writer keeps a `path => (; long_name, units, dims, scale, attach)` table next to it (`scale` marks a coordinate dataset as a dimension scale; `attach` binds axes to scales — see the `Utilities.HDF5Annotations.annotate!` docstring) (`src/Utilities/HDF5Schema.jl` for the main writer; alongside `write_galerkin!`, the PerturbedEquilibrium writer, `KineticForces/Output.jl`, and `Tearing/Runner/HDF5Output.jl` for the rest) and applies it post-write via `Utilities.HDF5Annotations.annotate!`. Entries for conditionally-written datasets are simply skipped when absent. When adding a dataset, add its table entry in the same commit — the schema test fails otherwise. ## File-wide conventions diff --git a/docs/development/naming.md b/docs/development/naming.md index 16763ac6d..1cfa8dd74 100644 --- a/docs/development/naming.md +++ b/docs/development/naming.md @@ -60,7 +60,7 @@ Modules, with the abbreviation to use in prose: | `ErrorFields` | EF | `src/ErrorFields/` | | `ForceFreeStates` | FFS | `src/ForceFreeStates/` | | `ForcingTerms` | FT | `src/ForcingTerms/` | -| `HDF5Schema` | — | `src/HDF5Schema.jl` | +| `HDF5Schema` | — | `src/Utilities/HDF5Schema.jl` | | `InnerLayer` | IL | `src/InnerLayer/` | | `KineticForces` | KF | `src/KineticForces/` | | `LocalStability` | LS | `src/LocalStability/` | diff --git a/docs/src/api.md b/docs/src/api.md index f6ffb9e03..b6a302652 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -24,8 +24,19 @@ problem type keeps `solve` unambiguous as other problem classes appear. ```julia prob = EulerLagrangeProblem(eq; nn=1, delta_mlow=8, delta_mhigh=8, vac_flag=true) ffs = solve(prob, Riccati()) +save_to_hdf5("gpec.h5", ffs) ``` +`solve` returns its result without writing a file. Call `save_to_hdf5(path, ffs)` +or pass an open HDF5 file or group to save it explicitly. The deck driver saves its +FFS result after the solve. + +The same `save_to_hdf5(target, result)` function saves an equilibrium, local-stability +result, perturbed-equilibrium state, kinetic-force state, tearing result, or error-field +result. Each module owns the datasets for its result. Paths open or create an HDF5 +file; an open file or group remains owned by the caller. Stage computations return +results, and the deck driver applies the configured output switches and filenames. + The objects map one-to-one onto the pipeline stages: - [`PlasmaEquilibrium`](@ref) reads and processes the equilibrium — the `[Equilibrium]` @@ -52,7 +63,7 @@ The objects map one-to-one onto the pipeline stages: The equilibrium carries the kinetic profiles. Name the file with the `kinetic_file` (and, for HDF5, `kinetic_group`) keyword, the same keys as the `[Equilibrium]` deck section, and it is read once into `equil.kinetic`; every kinetic stage derives its own view from it. -[`attach_kinetic_profiles!`](@ref) attaches a file or in-memory `KineticProfileData` to an +[`attach_kinetic_profiles!`](@ref GeneralizedPerturbedEquilibrium.Equilibrium.attach_kinetic_profiles!) attaches a file or in-memory `KineticProfileData` to an equilibrium built without one, such as an analytic equilibrium. ```julia diff --git a/docs/src/perturbed_equilibrium.md b/docs/src/perturbed_equilibrium.md index 942b65adb..2b510d9f6 100644 --- a/docs/src/perturbed_equilibrium.md +++ b/docs/src/perturbed_equilibrium.md @@ -17,9 +17,10 @@ GeneralizedPerturbedEquilibrium.PerturbedEquilibrium.PerturbedEquilibriumState ```@docs GeneralizedPerturbedEquilibrium.PerturbedEquilibrium.plasma_response GeneralizedPerturbedEquilibrium.PerturbedEquilibrium.compute_perturbed_equilibrium -GeneralizedPerturbedEquilibrium.PerturbedEquilibrium.write_outputs_to_HDF5 ``` +Save a computed state with [`save_to_hdf5`](@ref); computing the response does not write a file. + ## Several toroidal modes With `nn_low < nn_high` every `mode` axis in `PerturbedEquilibrium/` runs over the full diff --git a/src/Equilibrium/Equilibrium.jl b/src/Equilibrium/Equilibrium.jl index 014888cc6..7bbf040a1 100644 --- a/src/Equilibrium/Equilibrium.jl +++ b/src/Equilibrium/Equilibrium.jl @@ -71,4 +71,6 @@ export flux_surface_metric, flux_surface_area export wants_two_pass, refined_psi_grid, merge_mandatory_nodes, bracket_mandatory_nodes, enforce_min_spacing, implied_knot_count export compute_jac_delpsi, compute_sqrt_jac_delpsi, compute_sqrtamat, rootarea_to_area_weight, area_to_rootarea_weight +include("Output.jl") + end # module Equilibrium diff --git a/src/Equilibrium/Output.jl b/src/Equilibrium/Output.jl new file mode 100644 index 000000000..ee520f0e0 --- /dev/null +++ b/src/Equilibrium/Output.jl @@ -0,0 +1,40 @@ +import ..Utilities: save_to_hdf5 +import ..Utilities: EQUIL_H5_NAMES, EQUIL_H5_SKIP, MAIN_H5_ANNOTATIONS +import ..Utilities.HDF5Annotations: replace_group! +using HDF5 + +""" + save_to_hdf5(target, equil::PlasmaEquilibrium) + +Save the processed equilibrium profiles, geometry, and derived scalar quantities. +""" +function save_to_hdf5(out_h5::Union{HDF5.File,HDF5.Group}, equil::PlasmaEquilibrium) + replace_group!(out_h5, "Equilibrium") + for f in fieldnames(EquilibriumParameters) + f in EQUIL_H5_SKIP && continue + val = getfield(equil.params, f) + val === nothing && continue + out_h5["Equilibrium/$(get(EQUIL_H5_NAMES, f, String(f)))"] = val + end + out_h5["Equilibrium/psi_total"] = equil.psio + out_h5["Equilibrium/R_axis"] = equil.ro + out_h5["Equilibrium/Z_axis"] = equil.zo + + # Write equilibrium profile and geometry arrays (from the named splines) + profiles = equil.profiles + out_h5["Equilibrium/Profiles/psi"] = profiles.xs + out_h5["Equilibrium/Profiles/2piF"] = profiles.F_spline.y + out_h5["Equilibrium/Profiles/mu0p"] = profiles.P_spline.y + out_h5["Equilibrium/Profiles/dVdpsi"] = profiles.dVdpsi_spline.y + out_h5["Equilibrium/Profiles/q"] = profiles.q_spline.y + out_h5["Equilibrium/Geometry/psi"] = equil.rzphi_xs + out_h5["Equilibrium/Geometry/theta"] = equil.rzphi_ys + # Extract grid point values from interpolants for HDF5 output + out_h5["Equilibrium/Geometry/rcoords"] = equil.rzphi_rsquared.nodal_derivs.partials[1, :, :] + out_h5["Equilibrium/Geometry/offset"] = equil.rzphi_offset.nodal_derivs.partials[1, :, :] + out_h5["Equilibrium/Geometry/nu"] = equil.rzphi_nu.nodal_derivs.partials[1, :, :] + out_h5["Equilibrium/Geometry/jac"] = equil.rzphi_jac.nodal_derivs.partials[1, :, :] + + Utilities.HDF5Annotations.annotate!(out_h5, MAIN_H5_ANNOTATIONS) + return nothing +end diff --git a/src/ErrorFields/Output.jl b/src/ErrorFields/Output.jl index 4ff83f6f9..d00fc8f5b 100644 --- a/src/ErrorFields/Output.jl +++ b/src/ErrorFields/Output.jl @@ -1,3 +1,6 @@ +import ..Utilities: save_to_hdf5 +import ..Utilities.HDF5Annotations: ensure_group, replace_group!, write_fields! + """ Output @@ -34,16 +37,15 @@ const EF_H5_ANNOTATIONS = [ ] """ - write_to_hdf5!(h5file::HDF5.File, sens::CoilSensitivities, dom::DominantCoupling) + save_to_hdf5(h5file::Union{HDF5.File,HDF5.Group}, sens::CoilSensitivities; dom::DominantCoupling) Write the coil linearization to `ErrorFields/CoilSensitivities/` — the spectra and their derivatives, plus a `DominantMode/` summary projected onto mode 1 of `dom` (the run's full-window decomposition). An existing group is replaced. The mode labels are the file's `Info/mn_index` and the field normalization its `Equilibrium/B_T_axis`, neither duplicated here. """ -function write_to_hdf5!(h5file::HDF5.File, sens::CoilSensitivities, dom::DominantCoupling) - haskey(h5file, _H5_GROUP) && delete_object(h5file, _H5_GROUP) - g = create_group(h5file, _H5_GROUP) +function save_to_hdf5(h5file::Union{HDF5.File,HDF5.Group}, sens::CoilSensitivities; dom::DominantCoupling) + g = replace_group!(h5file, _H5_GROUP) g["coil_name"] = sens.coil_names g["field_as_designed"] = sens.field_as_designed g["shift_sensitivity_per_m"] = sens.shift_sensitivity_per_m @@ -99,7 +101,7 @@ const _TOLERANCE_SNAPSHOT = "Input/RawInputs/ErrorFields/tolerance_toml_raw" Echo the tolerance file's text into `Input/RawInputs/ErrorFields/tolerance_toml_raw`, the raw input snapshot a replay reads back with [`read_tolerance_snapshot`](@ref). Replaces an existing echo. """ -function write_tolerance_snapshot!(h5file::HDF5.File, ts::ToleranceSet) +function write_tolerance_snapshot!(h5file::Union{HDF5.File,HDF5.Group}, ts::ToleranceSet) haskey(h5file, _TOLERANCE_SNAPSHOT) && delete_object(h5file, _TOLERANCE_SNAPSHOT) h5file[_TOLERANCE_SNAPSHOT] = ts.raw return h5file @@ -137,16 +139,15 @@ const MC_H5_ANNOTATIONS = [ ] """ - write_to_hdf5!(h5file::HDF5.File, mc::MonteCarloResult) + save_to_hdf5(h5file::Union{HDF5.File,HDF5.Group}, mc::MonteCarloResult) Write the tolerance Monte Carlo histograms to `ErrorFields/MonteCarlo/`. The sampling settings (`nsample`, `nbatch`, `seed`) live in the run's `[ErrorFields.MonteCarlo]` table under `Input/gpec_toml_raw`; the tolerances in `Input/RawInputs/ErrorFields/tolerance_toml_raw`. An existing group is replaced. """ -function write_to_hdf5!(h5file::HDF5.File, mc::MonteCarloResult) - haskey(h5file, _MC_GROUP) && delete_object(h5file, _MC_GROUP) - g = create_group(h5file, _MC_GROUP) +function save_to_hdf5(h5file::Union{HDF5.File,HDF5.Group}, mc::MonteCarloResult) + g = replace_group!(h5file, _MC_GROUP) g["abs_delta_bin_edges"] = mc.abs_delta_bin_edges g["abs_delta_pdf"] = mc.abs_delta_pdf g["abs_delta_efc_pdf"] = mc.abs_delta_efc_pdf @@ -213,16 +214,15 @@ const RISK_H5_ANNOTATIONS = [ ] """ - write_to_hdf5!(h5file::HDF5.File, risk::RiskResult; scan=nothing) + save_to_hdf5(h5file::Union{HDF5.File,HDF5.Group}, risk::RiskResult; scan=nothing) Write the locking risk to `ErrorFields/Risk/`, with the tolerance scan under `ErrorFields/Risk/ToleranceScan/` when given. The threshold fit, scenario and sampling settings live in the run's `[ErrorFields.Risk]` and `[ErrorFields.scenario]` tables under `Input/gpec_toml_raw`. An existing group is replaced. """ -function write_to_hdf5!(h5file::HDF5.File, risk::RiskResult; scan::Union{Nothing,ToleranceScan}=nothing) - haskey(h5file, _RISK_GROUP) && delete_object(h5file, _RISK_GROUP) - g = create_group(h5file, _RISK_GROUP) +function save_to_hdf5(h5file::Union{HDF5.File,HDF5.Group}, risk::RiskResult; scan::Union{Nothing,ToleranceScan}=nothing) + g = replace_group!(h5file, _RISK_GROUP) g["threshold_pdf"] = risk.threshold_pdf g["locking_probability_given_delta"] = risk.locking_probability_given_delta g["threshold_fit"] = risk.threshold_fit @@ -302,7 +302,7 @@ const NTV_SCAN_H5_ANNOTATIONS = [ ] """ - write_to_hdf5!(h5file::HDF5.File, couplings::Vector{EFCCoupling}) + save_to_hdf5(h5file::Union{HDF5.File,HDF5.Group}, couplings::Vector{EFCCoupling}) Write the correction-coil couplings to `ErrorFields/NTV/`, and the torque-versus-rotation tables of the arrays that have one to `ErrorFields/NTV/RotationScan/` (one column per array, shorter @@ -310,9 +310,8 @@ scans NaN-padded). The torque budget, threshold, rotation exponent and safety fa them into a correction-current curve are analysis choices left to [`efc_current_curve`](@ref). An existing group is replaced. """ -function write_to_hdf5!(h5file::HDF5.File, couplings::Vector{EFCCoupling}) - haskey(h5file, _NTV_GROUP) && delete_object(h5file, _NTV_GROUP) - g = create_group(h5file, _NTV_GROUP) +function save_to_hdf5(h5file::Union{HDF5.File,HDF5.Group}, couplings::Vector{EFCCoupling}) + g = replace_group!(h5file, _NTV_GROUP) g["coil_name"] = [c.coil_name for c in couplings] g["delta_per_kat"] = [c.delta_per_kat for c in couplings] g["resonant_fraction_percent"] = [c.resonant_fraction_percent for c in couplings] @@ -325,7 +324,7 @@ function write_to_hdf5!(h5file::HDF5.File, couplings::Vector{EFCCoupling}) if !isempty(scanned) nmax = maximum(length(c.rotation_shift) for c in scanned) ψ = scanned[1].psi - all(c.psi == ψ for c in scanned) || error("write_to_hdf5!: the rotation scans of the arrays are on different ψ grids") + all(c.psi == ψ for c in scanned) || error("save_to_hdf5: the rotation scans of the arrays are on different ψ grids") pad(v) = vcat(Float64.(v), fill(NaN, nmax - length(v))) padm(m) = hcat(Float64.(m), fill(NaN, size(m, 1), nmax - size(m, 2))) sg = create_group(g, "RotationScan") diff --git a/src/ForceFreeStates/ForceFreeStates.jl b/src/ForceFreeStates/ForceFreeStates.jl index 3cd8c2020..27988277a 100644 --- a/src/ForceFreeStates/ForceFreeStates.jl +++ b/src/ForceFreeStates/ForceFreeStates.jl @@ -17,6 +17,7 @@ import CommonSolve import ..Equilibrium import ..Utilities +import ..Utilities: save_to_hdf5 import ..Vacuum import ..InnerLayer using Printf @@ -68,6 +69,8 @@ include("Result.jl") # Post-solve inner-layer matching: consumes and produces ForceFreeStatesResult, so it loads # after the result machinery. include("Matching/MatchProblem.jl") +include("InputSnapshot.jl") +include("Output.jl") # These are used for various small tolerances and root finders throughout ForceFreeStates global eps = 1e-10 diff --git a/src/ForceFreeStates/InputSnapshot.jl b/src/ForceFreeStates/InputSnapshot.jl new file mode 100644 index 000000000..cd5284f9f --- /dev/null +++ b/src/ForceFreeStates/InputSnapshot.jl @@ -0,0 +1,44 @@ +function save_input_snapshot!(out_h5, result::ForceFreeStatesResult; inputs=nothing, forcing_snapshot=nothing) + equil = result.equil + # Self-contained run snapshot: the full merged TOML (so a rerun can reconstruct every + # ForceFreeStates/Equilibrium/Wall/PE control struct), plus the equilibrium ingest + # arrays so a file-based rerun never needs the original g-file / CHEASE / IMAS source. + out_h5["Input/gpec_toml_raw"] = sprint(TOML.print, result_inputs(result, inputs)) + if equil.ingest !== nothing # analytic equilibria are regenerated from their TOML section + eq_group = "Input/RawInputs/Equilibrium" # read back by Rerun.read_equilibrium_ingest + out_h5["$eq_group/ingest_kind"] = equil.ingest isa Equilibrium.DirectIngest ? "direct" : "inverse" + for f in fieldnames(typeof(equil.ingest)) + out_h5["$eq_group/$f"] = getfield(equil.ingest, f) + end + end + if forcing_snapshot !== nothing # read back by Rerun.build_inputs_from_h5 + modes, normalization = forcing_snapshot + ForcingTerms.save_forcing_to_h5(modes, create_group(out_h5, "Input/RawInputs/ForcingTerms"); normalization) + end + + return nothing +end + +_input_fields(x) = Dict{String,Any}(String(f) => getfield(x, f) for f in fieldnames(typeof(x)) if getfield(x, f) !== nothing) + +function result_inputs(result::ForceFreeStatesResult, inputs) + snapshot = inputs === nothing ? Dict{String,Any}() : deepcopy(inputs) + snapshot["Equilibrium"] = _input_fields(result.equil.config) + for key in ("power_bp", "power_b", "power_r", "power_rc") + delete!(snapshot["Equilibrium"], key) + end + snapshot["ForceFreeStates"] = _input_fields(result.control) + snapshot["DEBUG"] = _input_fields(result.debug_settings) + if result.equil.analytic !== nothing + section = Equilibrium.ANALYTIC_EQ[result.equil.config.eq_type].section + snapshot[section] = _input_fields(result.equil.analytic) + end + wall = result.wall_settings + if wall === nothing + pop!(snapshot, "Wall", nothing) + else + snapshot["Wall"] = _input_fields(wall) + snapshot["Wall"]["shape"] = only(k for (k, T) in Vacuum.WALL_SHAPES if wall isa T) + end + return snapshot +end diff --git a/src/ForceFreeStates/Integrators.jl b/src/ForceFreeStates/Integrators.jl index 9a867fa86..64c4c36dc 100644 --- a/src/ForceFreeStates/Integrators.jl +++ b/src/ForceFreeStates/Integrators.jl @@ -127,3 +127,9 @@ function _apply_alg!(kwargs::Dict{Symbol,Any}, alg::Galerkin) end return kwargs end + +function algorithm_from_control(ctrl::ForceFreeStatesControl) + ctrl.integrator == "forward" && return Forward() + ctrl.integrator == "riccati" && return Riccati(; nchunks=ctrl.nchunks) + return Galerkin(; (f => getfield(ctrl, Symbol(:gal_, f)) for f in fieldnames(Galerkin) if f !== :cut_solution)...) +end diff --git a/src/ForceFreeStates/Matching/ResonantMatch.jl b/src/ForceFreeStates/Matching/ResonantMatch.jl index 8fc93cf65..e8d8d0f5b 100644 --- a/src/ForceFreeStates/Matching/ResonantMatch.jl +++ b/src/ForceFreeStates/Matching/ResonantMatch.jl @@ -73,7 +73,7 @@ function _match_system(dp_raw::AbstractMatrix, dp_coil::AbstractMatrix, deltar:: end # Write `m` under SingularSurfaces/Match/, annotated per the metadata contract. -function write_match!(out_h5, m::MatchResult) +function save_to_hdf5(out_h5::Union{HDF5.File,HDF5.Group}, m::MatchResult) g = "SingularSurfaces/Match" out_h5["$g/cout"] = m.cout out_h5["$g/cin"] = m.cin diff --git a/src/ForceFreeStates/Output.jl b/src/ForceFreeStates/Output.jl new file mode 100644 index 000000000..4eb26e6e4 --- /dev/null +++ b/src/ForceFreeStates/Output.jl @@ -0,0 +1,293 @@ +import ..Utilities: save_to_hdf5 +import ..LocalStability +import ..ForcingTerms +import ..Utilities: apply_main_h5_metadata! + +""" + save_to_hdf5(target, result::ForceFreeStatesResult; git_version="unknown", + inputs=nothing, forcing_snapshot=nothing, local_stability=nothing, layer_overlap=nothing) + +Save the FFS solutions, operators, singular-surface diagnostics, and vacuum products. +The associated equilibrium and optional local-stability result use their own save methods. +Input provenance is serialized separately; unavailable numerical products use empty datasets. +""" +function save_to_hdf5( + out_h5::Union{HDF5.File,HDF5.Group}, + result::ForceFreeStatesResult; + git_version::String="unknown", + inputs::Union{Nothing,Dict{String,Any}}=nothing, + forcing_snapshot::Union{Nothing,Tuple{Vector{ForcingTerms.ForcingMode},String}}=nothing, + local_stability::Union{LocalStability.LocalStabilityResult,Nothing}=nothing, + layer_overlap=nothing +) + + ctrl = result.control + equil = result.equil + mats = result.mats + free_energies = result.free_boundary + gal_data = result.galerkin + diag = result.diagnostics + msing = length(result.surfaces) + # Closed ξ profiles are written into the producing formalism's Solutions group with the + # same dataset names and (mode, solution, psi) axis order: ForwardIntegration for the + # axis-basis sweep, GalerkinIntegration for the matched gal solution (its own grid). + xi_solution = (result.solution !== nothing && result.solution.basis === :el_axis) ? result.solution : nothing + gal_solution = (result.solution !== nothing && result.solution.basis === :gal_native) ? result.solution : nothing + + + # File-level metadata contract (schema_version, Conventions, title, date). + Utilities.HDF5Annotations.write_root_attrs!(out_h5; title="GPEC output: $(basename(abspath(result.dir_path)))") + + # Store git version for reproducibility + out_h5["Info/git_version"] = git_version + + # Outer-region Galerkin solver outputs (RDCON), if it ran + if gal_data !== nothing + write_galerkin!(out_h5, gal_data; basis_output=result.debug_settings.gal_basis_output) + end + + if gal_solution !== nothing + gal = "ForceFreeStates/Solutions/GalerkinIntegration" + out_h5["$gal/psi"] = gal_solution.psi_store + out_h5["$gal/q"] = gal_solution.q_store + out_h5["$gal/xi_psi"] = gal_solution.u_store[:, :, 1, :] + out_h5["$gal/dxi_psidpsi"] = gal_solution.du_store + out_h5["$gal/xi_s"] = gal_solution.xi_s_store + end + + save_input_snapshot!(out_h5, result; inputs, forcing_snapshot) + + # Write derived run parameters + out_h5["Info/mpert"] = result.mpert + out_h5["Info/mlow"] = result.mlow + out_h5["Info/mhigh"] = result.mhigh + out_h5["Info/npert"] = result.npert + out_h5["Info/nlow"] = result.nlow + out_h5["Info/nhigh"] = result.nhigh + m = [(i - 1) % result.mpert + result.mlow for i in 1:(result.numpert_total)] + n = [(i - 1) ÷ result.mpert + result.nlow for i in 1:(result.numpert_total)] + out_h5["Info/mn_index"] = hcat(m, n) # (N, 2) matrix + out_h5["Info/psilim"] = result.psilim + out_h5["Info/qlim"] = result.qlim + out_h5["Info/dqdpsi_lim"] = result.q1lim + + save_to_hdf5(out_h5, equil) + + ls = local_stability + if ls !== nothing + save_to_hdf5(out_h5, ls) + else + for name in (:psi, :D_I, :D_R, :ballooning_Delta_prime, :ballooning_psi, :alpha, :alpha_critical) + out_h5["LocalStability/$name"] = Float64[] + end + end + out_h5["SingularSurfaces/D_I"] = (ls !== nothing && !isempty(result.surfaces)) ? + [LocalStability.mercier_D_I(ls, sing.psifac) for sing in result.surfaces] : Float64[] + + # Resistive-layer overlap scan. Written whenever the scan ran, whether or not it + # constrained the domain, so a run always shows where layer physics would have cut. + if layer_overlap !== nothing + lo = layer_overlap + out_h5["ForceFreeStates/LayerOverlap/m"] = lo.m + out_h5["ForceFreeStates/LayerOverlap/n"] = lo.n + out_h5["ForceFreeStates/LayerOverlap/psi"] = lo.psi + out_h5["ForceFreeStates/LayerOverlap/r_s"] = lo.rs + out_h5["ForceFreeStates/LayerOverlap/delta_s_abs"] = lo.delta_s_m + out_h5["ForceFreeStates/LayerOverlap/width_delta_s"] = lo.width_delta_s + out_h5["ForceFreeStates/LayerOverlap/width_visco"] = lo.width_visco + out_h5["ForceFreeStates/LayerOverlap/width_dr"] = lo.width_dr + out_h5["ForceFreeStates/LayerOverlap/extrapolated"] = Int.(lo.extrapolated) + out_h5["ForceFreeStates/LayerOverlap/psilim_overlap"] = + lo.psihigh === nothing ? NaN : lo.psihigh + out_h5["ForceFreeStates/LayerOverlap/first_overlap_index"] = + lo.first_overlap === nothing ? -1 : lo.first_overlap + # "applied" records that the bound was active going into sing_lim!, not that it set + # the final psilim -- rational_offset_fraction/qhigh may truncate deeper inside it. + out_h5["ForceFreeStates/LayerOverlap/applied"] = + Int(ctrl.psilim_from_layer_overlap && lo.psihigh !== nothing && + lo.psihigh < equil.params.psihigh_resolved) + end + + # Write integration data: the ψ trace and integrator diagnostics from the raw ODE state, + # the ξ profiles from the solution. Either may be absent (Galerkin has no ODE state; + # Riccati has no ξ solution), in which case the datasets are written empty. + fwd = "ForceFreeStates/Solutions/ForwardIntegration" + out_h5["$fwd/nstep"] = diag !== nothing ? diag.step : 0 # Number of saved solution snapshots + out_h5["$fwd/nstep_total"] = diag !== nothing ? diag.total_steps : 0 # Total ODE solver steps taken + out_h5["$fwd/psi"] = diag !== nothing ? diag.psi_store : Float64[] + out_h5["$fwd/q"] = diag !== nothing ? diag.q_store : Float64[] + out_h5["$fwd/xi_psi"] = xi_solution !== nothing ? xi_solution.u_store[:, :, 1, :] : ComplexF64[] + out_h5["$fwd/u2"] = xi_solution !== nothing ? xi_solution.u_store[:, :, 2, :] : ComplexF64[] # TODO: what to name this? These are the "conjugate momenta" of u1 + out_h5["$fwd/dxi_psidpsi"] = xi_solution !== nothing ? xi_solution.du_store : ComplexF64[] + out_h5["$fwd/xi_s"] = xi_solution !== nothing ? xi_solution.xi_s_store : ComplexF64[] + out_h5["$fwd/crit"] = diag !== nothing ? diag.crit_store : Float64[] + + # Write edge stability scan data (only present when dW_edge_scan_start < psilim). + # Generalized (W, N) pencil energies — power-normalized, Jacobian-invariant; these are + # the values findmax_dW_edge! uses to choose the truncation point. + if diag !== nothing && !isempty(diag.edge_scan.psi) + es = diag.edge_scan + out_h5["ForceFreeStates/EdgeScan/psi"] = es.psi + out_h5["ForceFreeStates/EdgeScan/q"] = es.q + out_h5["ForceFreeStates/EdgeScan/total_energy"] = es.total_eigenvalue + out_h5["ForceFreeStates/EdgeScan/plasma_energy"] = es.plasma_energy + out_h5["ForceFreeStates/EdgeScan/vacuum_energy"] = es.vacuum_energy + out_h5["ForceFreeStates/EdgeScan/vacuum_eigenvalue"] = es.vacuum_eigenvalue + end + + # Write singular surface data + out_h5["SingularSurfaces/rational_count"] = msing + out_h5["SingularSurfaces/rational_psi"] = [sing.psifac for sing in result.surfaces] + out_h5["SingularSurfaces/rational_q"] = [sing.q for sing in result.surfaces] + out_h5["SingularSurfaces/dqdpsi"] = [sing.q1 for sing in result.surfaces] + # Kinetic and galerkin-matched runs never populate ca_l/ca_r (only ideal surface + # crossings do); emit zero-extent sentinels instead of unpopulated arrays. + out_h5["SingularSurfaces/ca_left"] = (diag !== nothing && diag.ca_populated) ? diag.ca_l : zeros(ComplexF64, 0, 0, 0, 0) + out_h5["SingularSurfaces/ca_right"] = (diag !== nothing && diag.ca_populated) ? diag.ca_r : zeros(ComplexF64, 0, 0, 0, 0) + + if msing > 0 + # Mode numbers at each surface (jagged — pad with 0 to max_modes width) + max_modes = maximum(s -> length(s.m), result.surfaces) + m_matrix = zeros(Int, msing, max_modes) + n_matrix = zeros(Int, msing, max_modes) + for (s, sing) in enumerate(result.surfaces) + for i in 1:length(sing.m) + m_matrix[s, i] = sing.m[i] + n_matrix[s, i] = sing.n[i] + end + end + out_h5["SingularSurfaces/rational_m"] = m_matrix + out_h5["SingularSurfaces/rational_n"] = n_matrix + + # Glasser-Greene-Johnson geometric coefficients + surface averages + # (populated by ForceFreeStates.resist_eval_all! after sing_find!). + # Both kinetic-free (E, F, G, H, K, M) and geometry-only + # (avg_bsq_over_dpsisq, avg_bsq) quantities are written so + # downstream consumers (ForceFreeStates.ggj_parameters) + # can reconstruct τ_A / τ_R from any kinetic-profile source. + if all(s -> s.restype !== nothing, result.surfaces) + out_h5["SingularSurfaces/E"] = [s.restype.E for s in result.surfaces] + out_h5["SingularSurfaces/F"] = [s.restype.F for s in result.surfaces] + out_h5["SingularSurfaces/G"] = [s.restype.G for s in result.surfaces] + out_h5["SingularSurfaces/H"] = [s.restype.H for s in result.surfaces] + out_h5["SingularSurfaces/K"] = [s.restype.K for s in result.surfaces] + out_h5["SingularSurfaces/M"] = [s.restype.M for s in result.surfaces] + out_h5["SingularSurfaces/avg_bsq_over_dpsisq"] = [s.restype.avg_bsq_over_dpsisq for s in result.surfaces] + out_h5["SingularSurfaces/avg_bsq"] = [s.restype.avg_bsq for s in result.surfaces] + out_h5["SingularSurfaces/mu0p"] = [s.restype.p_local for s in result.surfaces] + out_h5["SingularSurfaces/dmu0pdpsi"] = [s.restype.p1_local for s in result.surfaces] + out_h5["SingularSurfaces/dVdpsi"] = [s.restype.v1_local for s in result.surfaces] + end + end + + # Per-surface ca-based Δ' (`sing.delta_prime`) is a stub; only the BVP matrix is emitted (see SingType.delta_prime docstring). + + # Write the Δ' payload on one set of canonical paths, whichever formalism produced it + # (Riccati BVP or Galerkin): both compute the same quantities in the same PEST-3 + # convention. Surface indexing follows the producing formalism's surface list, which for + # Galerkin is the in-domain in-band subset of `SingularSurfaces/`. + dp = result.delta_prime + if msing > 0 && dp !== nothing + # Inter-surface Δ' matrix, shape [msing × msing] — PEST3-convention deltap. + out_h5["SingularSurfaces/Delta_prime_matrix"] = dp.matrix + + # Edge coil-response matrix, stored (numpert_total × 2msing) = (edge mode, surface-side) + # so H5Web heatmaps read x = edge mode, y = surface-side. The carried matrix stays + # (2msing × numpert_total); transpose only at write. + isempty(dp.coil) || (out_h5["SingularSurfaces/Delta_coil"] = permutedims(dp.coil)) + + # Raw 2msing×2msing outer-region D' matrix in side-major ordering + # [L_s1, R_s1, L_s2, R_s2, …]. Byte-compatible with Fortran + # rdcon/gal.f::gal_write_delta top 2msing×2msing block of delta_gw.dat. + # Needed for the full det(D' − D(γ)) = 0 eigenvalue problem via + # pest3_decompose to recover (A', B', Γ', Δ'). + isempty(dp.raw) || (out_h5["SingularSurfaces/Delta_prime_raw"] = dp.raw) + + # Remaining PEST-3 parity blocks, when the formalism persisted them (Galerkin); + # Riccati recovers them from Delta_prime_raw via pest3_decompose. + dp.A === nothing || (out_h5["SingularSurfaces/pest3_A"] = dp.A) + dp.B === nothing || (out_h5["SingularSurfaces/pest3_B"] = dp.B) + dp.Gamma === nothing || (out_h5["SingularSurfaces/pest3_Gamma"] = dp.Gamma) + end + result.match === nothing || save_to_hdf5(out_h5, result.match) + + # Write kinetic singular surface data (det(F̄) near-zeros) and the cond(F̄) scan + # used to find them. Populated only when kinetic crossings were searched for. + out_h5["SingularSurfaces/Kinetic/rational_count"] = result.kinetic.kmsing + out_h5["SingularSurfaces/Kinetic/rational_psi"] = [s.psifac for s in result.kinetic.kinsing] + out_h5["SingularSurfaces/Kinetic/rational_q"] = [s.q for s in result.kinetic.kinsing] + out_h5["SingularSurfaces/Kinetic/dqdpsi"] = [s.q1 for s in result.kinetic.kinsing] + out_h5["SingularSurfaces/Kinetic/scan_psi"] = result.kinetic.scan_psi + out_h5["SingularSurfaces/Kinetic/scan_cond"] = result.kinetic.scan_cond + out_h5["SingularSurfaces/Kinetic/scan_threshold"] = result.kinetic.scan_threshold + + # Write free-boundary stability data. The eigenmode energies are the generalized + # eigenvalues of the pencil (W, N) with N the power-normalization (surface-norm) matrix: + # power-normalized mode energies (⟨|ξ|²⟩ = 1 metric) that are invariant to the choice of + # working (Jacobian) coordinate. W_freeboundary_eigenmodes holds the generalized + # eigenvectors, columns sorted most-unstable first, normalized to unit power norm with + # the largest-magnitude entry made real-positive. + fbs = "ForceFreeStates/FreeBoundaryStability" + out_h5["$fbs/W_freeboundary"] = free_energies !== nothing ? free_energies.wt0 : ComplexF64[] + out_h5["$fbs/W_plasma"] = free_energies !== nothing ? free_energies.wp : ComplexF64[] + out_h5["$fbs/W_vacuum"] = free_energies !== nothing ? free_energies.wv : ComplexF64[] + out_h5["$fbs/W_freeboundary_eigenmodes"] = free_energies !== nothing ? free_energies.wt : ComplexF64[] + out_h5["$fbs/eigenmode_energies"] = free_energies !== nothing ? free_energies.et : ComplexF64[] + out_h5["$fbs/eigenmode_plasma_energies"] = free_energies !== nothing ? free_energies.ep : ComplexF64[] + out_h5["$fbs/eigenmode_vacuum_energies"] = free_energies !== nothing ? free_energies.ev : ComplexF64[] + out_h5["$fbs/vacuum_eigenvalue"] = free_energies !== nothing ? free_energies.vacuum_eigenvalue : NaN + + # Cartesian surface point clouds used downstream for visualisation and + # perturbed-equilibrium plotting. + out_h5["SurfaceGeometries/Plasma/x"] = free_energies !== nothing ? free_energies.plasma_pts[:, 1] : Float64[] + out_h5["SurfaceGeometries/Plasma/y"] = free_energies !== nothing ? free_energies.plasma_pts[:, 2] : Float64[] + out_h5["SurfaceGeometries/Plasma/z"] = free_energies !== nothing ? free_energies.plasma_pts[:, 3] : Float64[] + out_h5["SurfaceGeometries/Wall/x"] = free_energies !== nothing ? free_energies.wall_pts[:, 1] : Float64[] + out_h5["SurfaceGeometries/Wall/y"] = free_energies !== nothing ? free_energies.wall_pts[:, 2] : Float64[] + out_h5["SurfaceGeometries/Wall/z"] = free_energies !== nothing ? free_energies.wall_pts[:, 3] : Float64[] + + # Write fundamental matrices on the ψ grid + xs = equil.rzphi_xs + npsi = length(xs) + np = result.numpert_total + + # Helper: evaluate a matrix spline on the psi grid → (npsi, np, np) array + function _eval_mat_spline(spline) + arr = zeros(ComplexF64, npsi, np, np) + hint = Ref(1) + for i in 1:npsi + arr[i, :, :] .= reshape(spline(xs[i]; hint=hint), np, np) + end + return arr + end + + elm = "ForceFreeStates/EulerLagrangeMatrices" + out_h5["$elm/psi"] = xs + # Ideal primitive matrices (A, B, C, D, E, H) + out_h5["$elm/Ideal/A"] = _eval_mat_spline(mats.ideal.A_spline) + out_h5["$elm/Ideal/B"] = _eval_mat_spline(mats.ideal.B_spline) + out_h5["$elm/Ideal/C"] = _eval_mat_spline(mats.ideal.C_spline) + out_h5["$elm/Ideal/D"] = _eval_mat_spline(mats.ideal.D_spline_prim) + out_h5["$elm/Ideal/E"] = _eval_mat_spline(mats.ideal.E_spline_prim) + out_h5["$elm/Ideal/H"] = _eval_mat_spline(mats.ideal.H_spline) + + # Ideal derived matrices (F, K, G) + out_h5["$elm/Ideal/F"] = _eval_mat_spline(mats.ideal.F_spline_lower) + out_h5["$elm/Ideal/K"] = _eval_mat_spline(mats.ideal.K_spline) + out_h5["$elm/Ideal/G"] = _eval_mat_spline(mats.ideal.G_spline) + + # Kinetic-modified matrices + kin = mats.kinetic + if kin !== nothing + out_h5["$elm/Kinetic/A"] = _eval_mat_spline(kin.A_spline) + out_h5["$elm/Kinetic/B"] = _eval_mat_spline(kin.B_spline) + out_h5["$elm/Kinetic/C"] = _eval_mat_spline(kin.C_spline) + out_h5["$elm/Kinetic/f0"] = _eval_mat_spline(kin.F0_spline) + out_h5["$elm/Kinetic/K"] = _eval_mat_spline(kin.Kk_spline) + out_h5["$elm/Kinetic/G"] = _eval_mat_spline(kin.G_spline_adj) + end + + # Self-describing metadata pass (long_name/units/dims + dimension scales). + apply_main_h5_metadata!(out_h5) + return nothing +end diff --git a/src/ForceFreeStates/Result.jl b/src/ForceFreeStates/Result.jl index 2469e8a54..bf0b92ba7 100644 --- a/src/ForceFreeStates/Result.jl +++ b/src/ForceFreeStates/Result.jl @@ -53,6 +53,7 @@ so bpen and closure are always present. - `mlow`, `mhigh`, `mpert`, `nlow`, `nhigh`, `npert`, `numpert_total` - Resolved (m, n) mode space; see `ModeSpace`. - `psilow`, `psilim`, `qlim`, `q1lim` - Integration bounds, and q with its ψ-derivative at `psilim`. - `dir_path::String` - Working directory of the run. + - `problem`, `algorithm` - Problem and integrator retained by the public solve for reproducible output. - `wall_settings::Union{Nothing,Vacuum.WallSettings}` - Wall shape used by the vacuum calculation. - `debug_settings::DebugSettings` - Diagnostic dump settings (the `[DEBUG]` deck section / `debug=` API keyword). - `metric::MetricData`, `mats::MatrixSplines` - Metric data and Euler-Lagrange matrix interpolants. @@ -78,7 +79,9 @@ so bpen and closure are always present. - `galerkin::Union{Nothing,GalerkinResult}` - RDCON Galerkin solver internals and FEM diagnostics. Its Δ′ payload lives in `delta_prime`, not here. """ -struct ForceFreeStatesResult{E<:Equilibrium.PlasmaEquilibrium,F<:MatrixSplines} <: ModeSpace +struct ForceFreeStatesResult{E<:Equilibrium.PlasmaEquilibrium,F<:MatrixSplines,P,A} <: ModeSpace + problem::P + algorithm::A integrator::Symbol control::ForceFreeStatesControl equil::E @@ -241,7 +244,7 @@ function build_result( end return ForceFreeStatesResult( - integrator, ctrl, equil, + nothing, nothing, integrator, ctrl, equil, intr.mlow, intr.mhigh, intr.mpert, intr.nlow, intr.nhigh, intr.npert, intr.numpert_total, intr.psilow, intr.psilim, intr.qlim, intr.q1lim, intr.dir_path, intr.wall_settings, intr.debug_settings, metric, mats, intr.sing, kinetic, diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 3722575ad..de03db3c5 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -18,6 +18,8 @@ const _SECTION = "-"^40 include("Utilities/Utilities.jl") import .Utilities as Utilities export Utilities +import .Utilities: save_to_hdf5 +export save_to_hdf5 include("Equilibrium/Equilibrium.jl") import .Equilibrium as Equilibrium @@ -38,6 +40,10 @@ include("InnerLayer/InnerLayer.jl") import .InnerLayer as InnerLayer export InnerLayer +include("ForcingTerms/ForcingTerms.jl") +import .ForcingTerms as ForcingTerms +export ForcingTerms + include("ForceFreeStates/ForceFreeStates.jl") import .ForceFreeStates as ForceFreeStates export ForceFreeStates @@ -51,10 +57,6 @@ import .Tearing.Dispersion as Dispersion import .Tearing.Runner as Runner export Dispersion, Runner -include("ForcingTerms/ForcingTerms.jl") -import .ForcingTerms as ForcingTerms -export ForcingTerms - include("PerturbedEquilibrium/PerturbedEquilibrium.jl") import .PerturbedEquilibrium as PerturbedEquilibrium export PerturbedEquilibrium @@ -71,7 +73,6 @@ include("Analysis/Analysis.jl") import .Analysis as Analysis export Analysis -include("HDF5Schema.jl") include("Rerun.jl") # Import ForceFreeStates types and functions needed for main @@ -81,7 +82,7 @@ using .ForceFreeStates: sing_lim!, sing_min!, sing_find!, remove_singular_surfs! using .ForceFreeStates: make_metric, build_matrix_splines, build_kinetic_matrix_splines using .ForceFreeStates: find_kinetic_singular_surfaces! using .ForceFreeStates: eulerlagrange_integration, free_run, normalize_eigenfunctions! -using .ForceFreeStates: galerkin_solve, write_galerkin!, write_match! +using .ForceFreeStates: galerkin_solve, write_galerkin! # Scripting-API surface: the integrator selectors, the published result, the equilibrium # constructor and the forcing description, re-exported so a user needs one `using`. @@ -293,16 +294,25 @@ function main_from_inputs( metric, mats, layer_overlap = prepare_force_free_states!(intr, ctrl, equil, kf_ctrl; overlap_inputs=_overlap_slayer_inputs(inputs, equil.kinetic)) ffs_result = run_deck_match(run_force_free_states(ctrl, equil, mats, intr, metric), ctrl) + alg = ForceFreeStates.algorithm_from_control(ctrl) + algorithm_keys = Set([:integrator]) + alg isa Riccati && push!(algorithm_keys, :nchunks) + alg isa Galerkin && union!(algorithm_keys, (Symbol(:gal_, f) for f in fieldnames(Galerkin) if f !== :cut_solution)) + problem_kwargs = Dict(f => getfield(ctrl, f) for f in fieldnames(typeof(ctrl)) if !(f in (:nn_low, :nn_high)) && !(f in algorithm_keys)) + problem = EulerLagrangeProblem(equil; nn=ctrl.nn_low:ctrl.nn_high, wall=intr.wall_settings, dir_path=intr.dir_path, + debug=intr.debug_settings, problem_kwargs...) + ffs_result = ForceFreeStates._with(ffs_result; problem=problem, algorithm=alg) if ctrl.write_outputs_to_HDF5 - write_outputs_to_HDF5( - ffs_result; - git_version=git_version, - inputs=inputs, - forcing_snapshot=forcing_snapshot, - local_stability=local_stability, - layer_overlap=layer_overlap - ) + h5open(joinpath(ffs_result.dir_path, ctrl.HDF5_filename), "w") do target + save_to_hdf5(target, ffs_result; + git_version=git_version, + inputs=inputs, + forcing_snapshot=forcing_snapshot, + local_stability=local_stability, + layer_overlap=layer_overlap + ) + end written_h5 = ctrl.HDF5_filename @info "Results written to $(ctrl.HDF5_filename)" end @@ -337,41 +347,54 @@ function main_from_inputs( pe_state = _timed_stage(runtimes, "perturbed_equilibrium", "Perturbed Equilibrium") do run_perturbed_equilibrium(ffs_result, inputs, preloaded_coil_sets) end - # Mirrors the write gate inside `perturbed_equilibrium` (write flag defaults to true). if get(inputs["PerturbedEquilibrium"], "write_outputs_to_HDF5", true) + save_to_hdf5(joinpath(ffs_result.dir_path, pe_file), pe_state) written_h5 = pe_file end end if run_response && "KineticForces" in keys(inputs) - _timed_stage(runtimes, "kinetic_forces", "KineticForces") do + kinetic = _timed_stage(runtimes, "kinetic_forces", "KineticForces") do run_kinetic_forces(inputs, ffs_result, pe_state, kf_ctrl) end - # Mirrors the write gate inside `run_kinetic_forces` (needs a PE state to contract against). - if pe_state !== nothing && kf_ctrl.write_outputs_to_HDF5 + if kinetic !== nothing && kf_ctrl.write_outputs_to_HDF5 + h5open(joinpath(ffs_result.dir_path, kf_ctrl.HDF5_filename), "cw") do target + for (state, label) in zip(kinetic.states, kinetic.labels) + save_to_hdf5(target, state; dVdpsi_spline=ffs_result.equil.profiles.dVdpsi_spline, species_label=label) + end + save_to_hdf5(target, kinetic.total; dVdpsi_spline=ffs_result.equil.profiles.dVdpsi_spline) + end written_h5 = kf_ctrl.HDF5_filename end end coil_sensitivities, monte_carlo, locking_risk, efc_couplings = nothing, nothing, nothing, nothing if run_response && "ErrorFields" in keys(inputs) - coil_sensitivities, monte_carlo, locking_risk, efc_couplings = _timed_stage(runtimes, "error_fields", "ErrorFields") do + error_fields = _timed_stage(runtimes, "error_fields", "ErrorFields") do run_error_fields(inputs, ffs_result, pe_state, preloaded_coil_sets, kf_ctrl) end - # Mirrors the write gate inside `run_error_fields` (write flag defaults to true, filename - # falls back to the ForceFreeStates file since the result carries this same control). + coil_sensitivities, monte_carlo = error_fields.sens, error_fields.monte_carlo + locking_risk, efc_couplings = error_fields.risk, error_fields.couplings ef_raw = inputs["ErrorFields"] if get(ef_raw, "write_outputs_to_HDF5", true) ef_out = get(ef_raw, "output_filename", "") written_h5 = isempty(ef_out) ? ctrl.HDF5_filename : ef_out + h5open(joinpath(ffs_result.dir_path, written_h5), "cw") do target + save_to_hdf5(target, error_fields.sens; dom=error_fields.dom) + error_fields.tolerances === nothing || ErrorFields.write_tolerance_snapshot!(target, error_fields.tolerances) + monte_carlo === nothing || save_to_hdf5(target, monte_carlo) + locking_risk === nothing || save_to_hdf5(target, locking_risk; scan=error_fields.scan) + efc_couplings === nothing || save_to_hdf5(target, efc_couplings) + end end end tearing_start = time() - slayer_result = run_slayer_stage(ffs_result, inputs, pe_file) + slayer_result = run_slayer_stage(ffs_result, inputs) tearing_dt = time() - tearing_start - # A non-nothing result means the Tearing/ group was appended. + # Save a tearing result only when that stage ran. if slayer_result !== nothing + save_to_hdf5(joinpath(ffs_result.dir_path, pe_file), slayer_result) written_h5 = pe_file push!(runtimes, "tearing" => tearing_dt) @info "SLAYER completed in $(@sprintf("%.3f", tearing_dt)) s" @@ -934,11 +957,7 @@ function solve(prob::EulerLagrangeProblem, alg::ForceFreeStates.AbstractIntegrat metric, mats, _ = prepare_force_free_states!(intr, ctrl, equil, kf_ctrl) result = run_force_free_states(ctrl, equil, mats, intr, metric; cut_solution=alg isa ForceFreeStates.Galerkin && alg.cut_solution) - - if ctrl.write_outputs_to_HDF5 - write_outputs_to_HDF5(result) - @info "Results written to $(ctrl.HDF5_filename)" - end + result = ForceFreeStates._with(result; problem=prob, algorithm=alg) @info "Force-Free States completed in $(@sprintf("%.3f", time() - total_start)) s" @@ -957,8 +976,7 @@ solve(equil::Equilibrium.PlasmaEquilibrium, alg::ForceFreeStates.AbstractIntegra """ run_perturbed_equilibrium(result, inputs, preloaded_coil_sets) -> pe_state -Run the perturbed-equilibrium stage against a published force-free-states `result` and write its -outputs. Returns `nothing` when the deck carries no `[PerturbedEquilibrium]` section. +Run the perturbed-equilibrium stage against a published force-free-states `result`. Returns `nothing` when the deck carries no `[PerturbedEquilibrium]` section. """ function run_perturbed_equilibrium( result::ForceFreeStatesResult, @@ -977,25 +995,23 @@ function run_perturbed_equilibrium( resp = plasma_response(result; (Symbol(k) => v for (k, v) in section if k in response_keys || k == "verbose")...) pe_state = perturbed_equilibrium(resp, ForcingTerms.RMPField(ft_ctrl); coil_sets=preloaded_coil_sets, - (Symbol(k) => v for (k, v) in section if !(k in response_keys))...) + (Symbol(k) => v for (k, v) in section if !(k in (response_keys..., "write_outputs_to_HDF5", "output_filename")))...) end return pe_state end """ - perturbed_equilibrium(resp, rmp; regularization_width=resp.regularization_width, verbose=true, - write_outputs_to_HDF5=true, output_filename="", coil_sets=nothing) + perturbed_equilibrium(resp, rmp; regularization_width=resp.regularization_width, verbose=true, coil_sets=nothing) -> PerturbedEquilibriumState Drive the plasma response `resp` (from [`plasma_response`](@ref)) with the external field `rmp`, an -[`RMPField`](@ref), and write the perturbed-equilibrium outputs. One response serves any number of +[`RMPField`](@ref), and return the perturbed-equilibrium result. One response serves any number of fields, so a scan rebuilds no operator. `regularization_width` smooths the ideal 1/(m−nq) divergence in the profile reconstruction (Fortran -`reg_spot`); its default is 0, and 0.05 is typical for driving KineticForces with an ideal solve. `write_outputs_to_HDF5` appends -the outputs to `output_filename`, or to the force-free-states file when that is empty. `coil_sets` -injects already-built coil geometry (the gpec.h5 replay path), bypassing the geometry read. +`reg_spot`); its default is 0, and 0.05 is typical for driving KineticForces with an ideal solve. +Call `save_to_hdf5(target, pe)` to save the result. `coil_sets` injects already-built coil geometry (the gpec.h5 replay path), bypassing the geometry read. ```julia resp = plasma_response(ffs) @@ -1007,39 +1023,23 @@ function perturbed_equilibrium( rmp::ForcingTerms.RMPField; regularization_width::Real=resp.regularization_width, verbose::Bool=true, - write_outputs_to_HDF5::Bool=true, - output_filename::String="", coil_sets::Union{Nothing,Vector{ForcingTerms.CoilSet}}=nothing ) - ffs = resp.ffs - ctrl = ffs.control # Injected coil geometry (gpec.h5 replay with `--coil-source coils`) lets the coil field # be recomputed from stored geometry without the .dat/.h5 files. pe_state = PerturbedEquilibrium.compute_perturbed_equilibrium(resp, rmp; regularization_width, verbose, preloaded_coil_sets=coil_sets === nothing ? ForcingTerms.CoilSet[] : copy(coil_sets)) - # Write perturbed equilibrium outputs to same HDF5 file - if write_outputs_to_HDF5 - output_file = isempty(output_filename) ? ctrl.HDF5_filename : output_filename - PerturbedEquilibrium.write_outputs_to_HDF5(pe_state, joinpath(ffs.dir_path, output_file)) - @info "Results written to $output_file" - end - - # Snapshot the coil geometry actually used into the gpec.h5 output so the run - # is replayable from the output file alone (see `main_from_h5 --coil-source coils`). - if ctrl.write_outputs_to_HDF5 && !isempty(pe_state.coil_sets) - _write_coil_snapshot!(joinpath(ffs.dir_path, ctrl.HDF5_filename), pe_state.coil_sets) - end - return pe_state end """ run_kinetic_forces(inputs, result, pe_state, kf_ctrl) -Compute and write the neoclassical toroidal viscosity torque diagnostics when the deck carries a +Compute the neoclassical toroidal viscosity torque diagnostics when the deck carries a `[KineticForces]` section, from the kinetic profiles attached to `result.equil`. No-op when the perturbed-equilibrium state the operators contract -against is missing. +against is missing. Returns `(; total, states, labels)` for the summed and per-species results, +or `nothing` when no torque is computed. """ function run_kinetic_forces( inputs::Dict{String,Any}, @@ -1070,12 +1070,7 @@ function run_kinetic_forces( kf_state = KineticForces.KineticForcesState() KineticForces.compute_torque_all_methods!(kf_state, kf_intr, kf_ctrl, result.equil, kinetic_profiles; axis_psi_c=axis_psi_c) - if kf_ctrl.write_outputs_to_HDF5 - h5open(joinpath(result.dir_path, kf_ctrl.HDF5_filename), "cw") do h5file - KineticForces.write_to_hdf5!(h5file, kf_state; - dVdpsi_spline=result.equil.profiles.dVdpsi_spline) - end - end + return (; total=kf_state, states=KineticForces.KineticForcesState[], labels=String[]) else states = KineticForces.KineticForcesState[] labels = String[] @@ -1092,16 +1087,7 @@ function run_kinetic_forces( push!(labels, sp.label) end kf_state = KineticForces.combine_species_states(states) - if kf_ctrl.write_outputs_to_HDF5 - h5open(joinpath(result.dir_path, kf_ctrl.HDF5_filename), "cw") do h5file - for (st, label) in zip(states, labels) - KineticForces.write_to_hdf5!(h5file, st; - dVdpsi_spline=result.equil.profiles.dVdpsi_spline, species_label=label) - end - KineticForces.write_to_hdf5!(h5file, kf_state; - dVdpsi_spline=result.equil.profiles.dVdpsi_spline) - end - end + return (; total=kf_state, states, labels) end end @@ -1124,20 +1110,12 @@ function forcing_terms_control(inputs::Dict{String,Any}) end """ - run_error_fields(inputs, result, pe_state, preloaded_coil_sets, kf_ctrl) -> (sensitivities, monte_carlo, risk, couplings) or nothing - -Linearize every coil set's resonant drive with respect to its rigid shifts and tilts and write -`ErrorFields/CoilSensitivities/` when the deck carries an `[ErrorFields]` section. When the -section names a `tolerance_file`, read, validate and echo it, then run the tolerance Monte -Carlo on the full-window dominant mode with the `[ErrorFields.MonteCarlo]` settings and write -`ErrorFields/MonteCarlo/`; with an `[ErrorFields.scenario]` table as well, evaluate the locking -risk (and the tolerance scan when `[ErrorFields.Risk]` names `scan_scales`) and write -`ErrorFields/Risk/`; with `[ErrorFields.NTV]` naming correction arrays, evaluate their overlap and NTV -torque couplings per kilo-ampere-turn (needs the equilibrium's kinetic profiles) and write `ErrorFields/NTV/`. -Stages not requested return `nothing`. Needs the -perturbed-equilibrium state's singular-coupling matrix and coil-format forcing, and errors -otherwise: a deck asking for error-field sensitivities without them is a misconfiguration, not a -case to skip silently. Coil geometry is rebuilt from the deck unless a replay injected it. + run_error_fields(inputs, result, pe_state, preloaded_coil_sets, kf_ctrl) + +Compute the requested coil sensitivities, tolerance Monte Carlo, locking risk, and NTV +correction-coil couplings. Return `(; sens, monte_carlo, risk, couplings, dom, tolerances, scan)`; +optional computations are `nothing` when omitted. The driver saves each product after this +function returns. Requires coil-format forcing and a perturbed-equilibrium response. """ function run_error_fields( inputs::Dict{String,Any}, @@ -1228,20 +1206,8 @@ function run_error_fields( @info "NTV couplings of $(length(couplings)) correction arrays in $(@sprintf("%.1f", time() - ntv_start)) s" end - if ef_ctrl.write_outputs_to_HDF5 - output_file = isempty(ef_ctrl.output_filename) ? result.control.HDF5_filename : ef_ctrl.output_filename - h5open(joinpath(result.dir_path, output_file), "cw") do h5file - ErrorFields.write_to_hdf5!(h5file, sens, dom) - # Raw echo of the tolerance input for replay (read back by ErrorFields.read_tolerance_snapshot). - tolerances === nothing || ErrorFields.write_tolerance_snapshot!(h5file, tolerances) - monte_carlo === nothing || ErrorFields.write_to_hdf5!(h5file, monte_carlo) - risk === nothing || ErrorFields.write_to_hdf5!(h5file, risk; scan) - couplings === nothing || ErrorFields.write_to_hdf5!(h5file, couplings) - end - @info "Results written to $output_file" - end + return (; sens, monte_carlo, risk, couplings, dom, tolerances, scan) - return sens, monte_carlo, risk, couplings end """ @@ -1392,396 +1358,21 @@ function efc_couplings( end """ - run_slayer_stage(result, inputs, pe_file) -> slayer_result + run_slayer_stage(result, inputs) -> slayer_result -Run the SLAYER tearing-mode analysis off the force-free-states `result`, appending its group to -`pe_file` (or the force-free-states output when PE did not run). Needs only the result, so it -runs in both the `force_termination = true` path and the full pipeline. Errors propagate; every -earlier stage has already written its output. +Run the SLAYER tearing-mode analysis off the force-free-states `result` and return its result. +It runs in both the `force_termination = true` path and the full pipeline. """ -function run_slayer_stage(result::ForceFreeStatesResult, inputs::Dict{String,Any}, pe_file::Union{String,Nothing}) +function run_slayer_stage(result::ForceFreeStatesResult, inputs::Dict{String,Any}) ("SLAYER" in keys(inputs)) || return nothing slayer_ctrl = Runner.slayer_control_from_toml(inputs["SLAYER"]) slayer_ctrl.enabled || return nothing @info "\n SLAYER\n$_SECTION" model = Runner._build_inner_model(slayer_ctrl.inner_model) slayer_result = solve(Runner.TearingProblem(result, slayer_ctrl), model) - h5_filename = pe_file === nothing ? result.control.HDF5_filename : pe_file - h5_path = joinpath(result.dir_path, h5_filename) - # Append the Tearing/ group; create the file if no prior stage wrote - # it (e.g. write_outputs_to_HDF5 disabled) rather than failing on "r+". - HDF5.h5open(h5_path, isfile(h5_path) ? "r+" : "w") do f - Runner.write_slayer_hdf5!(f, slayer_result) - end - @info "SLAYER results written to $h5_filename" return slayer_result end -""" - write_outputs_to_HDF5(result::ForceFreeStatesResult; git_version, inputs, forcing_snapshot, - local_stability) - -Write the HDF5 output file with the run, equilibrium and stability products carried by a -force-free-states `result`. This combines the functionality of several pieces of the Fortran -code in `ode_output.f`, primarily `ode_output_open` and the various `bin_euler` writes that -occur throughout the integration. Groups fed by an optional result field fall back to empty -datasets when the producing integrator did not supply it, so the schema is the same shape -whatever ran. - -The solution-adjacent datasets come from two independent sources: the dense -`Solutions/ForwardIntegration/xi_*` arrays from `result.solution` when it is in the forward -`:el_axis` basis (a matched Galerkin solution is persisted in full under the Galerkin group -instead), and ψ, q, the step counts, `crit`, the asymptotic coefficients and the edge scan -from `result.diagnostics`. Either may be absent, and is then written empty. - -`forcing_snapshot` is the `(modes, normalization)` pair from `snapshot_forcing_modes`, -written to `Input/RawInputs/ForcingTerms/`. `local_stability` is the `LocalStabilityResult` -of the separate LocalStability stage, or `nothing` when it did not run. - -### TODOs - -Combine spline unpacking if possible, too many extra lines -""" -function write_outputs_to_HDF5( - result::ForceFreeStatesResult; - git_version::String="unknown", - inputs::Union{Nothing,Dict{String,Any}}=nothing, - forcing_snapshot::Union{Nothing,Tuple{Vector{ForcingTerms.ForcingMode},String}}=nothing, - local_stability::Union{LocalStabilityResult,Nothing}=nothing, - layer_overlap=nothing -) - - ctrl = result.control - equil = result.equil - mats = result.mats - free_energies = result.free_boundary - gal_data = result.galerkin - diag = result.diagnostics - msing = length(result.surfaces) - # Closed ξ profiles are written into the producing formalism's Solutions group with the - # same dataset names and (mode, solution, psi) axis order: ForwardIntegration for the - # axis-basis sweep, GalerkinIntegration for the matched gal solution (its own grid). - xi_solution = (result.solution !== nothing && result.solution.basis === :el_axis) ? result.solution : nothing - gal_solution = (result.solution !== nothing && result.solution.basis === :gal_native) ? result.solution : nothing - - h5open(joinpath(result.dir_path, ctrl.HDF5_filename), "w") do out_h5 - - # File-level metadata contract (schema_version, Conventions, title, date). - Utilities.HDF5Annotations.write_root_attrs!(out_h5; title="GPEC output: $(basename(abspath(result.dir_path)))") - - # Store git version for reproducibility - out_h5["Info/git_version"] = git_version - - # Outer-region Galerkin solver outputs (RDCON), if it ran - if gal_data !== nothing - write_galerkin!(out_h5, gal_data; basis_output=result.debug_settings.gal_basis_output) - end - - if gal_solution !== nothing - gal = "ForceFreeStates/Solutions/GalerkinIntegration" - out_h5["$gal/psi"] = gal_solution.psi_store - out_h5["$gal/q"] = gal_solution.q_store - out_h5["$gal/xi_psi"] = gal_solution.u_store[:, :, 1, :] - out_h5["$gal/dxi_psidpsi"] = gal_solution.du_store - out_h5["$gal/xi_s"] = gal_solution.xi_s_store - end - - # Self-contained run snapshot: the full merged TOML (so a rerun can reconstruct every - # ForceFreeStates/Equilibrium/Wall/PE control struct), plus the equilibrium ingest - # arrays so a file-based rerun never needs the original g-file / CHEASE / IMAS source. - if inputs !== nothing - out_h5["Input/gpec_toml_raw"] = sprint(TOML.print, inputs) - end - if equil.ingest !== nothing # analytic equilibria are regenerated from their TOML section - eq_group = "Input/RawInputs/Equilibrium" # read back by Rerun.read_equilibrium_ingest - out_h5["$eq_group/ingest_kind"] = equil.ingest isa Equilibrium.DirectIngest ? "direct" : "inverse" - for f in fieldnames(typeof(equil.ingest)) - out_h5["$eq_group/$f"] = getfield(equil.ingest, f) - end - end - if forcing_snapshot !== nothing # read back by Rerun.build_inputs_from_h5 - modes, normalization = forcing_snapshot - ForcingTerms.save_forcing_to_h5(modes, create_group(out_h5, "Input/RawInputs/ForcingTerms"); normalization) - end - - # Write derived run parameters - out_h5["Info/mpert"] = result.mpert - out_h5["Info/mlow"] = result.mlow - out_h5["Info/mhigh"] = result.mhigh - out_h5["Info/npert"] = result.npert - out_h5["Info/nlow"] = result.nlow - out_h5["Info/nhigh"] = result.nhigh - m = [(i - 1) % result.mpert + result.mlow for i in 1:(result.numpert_total)] - n = [(i - 1) ÷ result.mpert + result.nlow for i in 1:(result.numpert_total)] - out_h5["Info/mn_index"] = hcat(m, n) # (N, 2) matrix - out_h5["Info/psilim"] = result.psilim - out_h5["Info/qlim"] = result.qlim - out_h5["Info/dqdpsi_lim"] = result.q1lim - - # Write derived equilibrium parameters. The struct keeps its legacy field spellings; - # EQUIL_H5_NAMES maps them to literature dataset names and EQUIL_H5_SKIP drops - # duplicates and control-flag echoes. Fields left `nothing` are not written. - for f in fieldnames(Equilibrium.EquilibriumParameters) - f in EQUIL_H5_SKIP && continue - val = getfield(equil.params, f) - val === nothing && continue - out_h5["Equilibrium/$(get(EQUIL_H5_NAMES, f, String(f)))"] = val - end - out_h5["Equilibrium/psi_total"] = equil.psio - out_h5["Equilibrium/R_axis"] = equil.ro - out_h5["Equilibrium/Z_axis"] = equil.zo - - # Write equilibrium profile and geometry arrays (from the named splines) - profiles = equil.profiles - out_h5["Equilibrium/Profiles/psi"] = profiles.xs - out_h5["Equilibrium/Profiles/2piF"] = profiles.F_spline.y - out_h5["Equilibrium/Profiles/mu0p"] = profiles.P_spline.y - out_h5["Equilibrium/Profiles/dVdpsi"] = profiles.dVdpsi_spline.y - out_h5["Equilibrium/Profiles/q"] = profiles.q_spline.y - out_h5["Equilibrium/Geometry/psi"] = equil.rzphi_xs - out_h5["Equilibrium/Geometry/theta"] = equil.rzphi_ys - # Extract grid point values from interpolants for HDF5 output - out_h5["Equilibrium/Geometry/rcoords"] = equil.rzphi_rsquared.nodal_derivs.partials[1, :, :] - out_h5["Equilibrium/Geometry/offset"] = equil.rzphi_offset.nodal_derivs.partials[1, :, :] - out_h5["Equilibrium/Geometry/nu"] = equil.rzphi_nu.nodal_derivs.partials[1, :, :] - out_h5["Equilibrium/Geometry/jac"] = equil.rzphi_jac.nodal_derivs.partials[1, :, :] - - # Write local stability data; always write all entries, using empty arrays when not computed. - # LocalStability/D_I = Mercier D_I (det(d0bar)); LocalStability/D_R = resistive interchange D_R; - # LocalStability/ballooning_Delta_prime = high-n ballooning Δ' (distinct from the Riccati - # tearing Δ' under PerturbedEquilibrium/SingularCoupling/Delta_prime). - # The α boundary (ballooning_psi, alpha, alpha_critical) is on its own scan grid, distinct from LocalStability/psi. - ls = local_stability - for name in (:psi, :D_I, :D_R, :ballooning_Delta_prime, :ballooning_psi, :alpha, :alpha_critical) - out_h5["LocalStability/$name"] = ls === nothing ? Float64[] : getfield(ls, name) - end - out_h5["SingularSurfaces/D_I"] = (ls !== nothing && !isempty(result.surfaces)) ? - [LocalStability.mercier_D_I(ls, sing.psifac) for sing in result.surfaces] : Float64[] - - # Resistive-layer overlap scan. Written whenever the scan ran, whether or not it - # constrained the domain, so a run always shows where layer physics would have cut. - if layer_overlap !== nothing - lo = layer_overlap - out_h5["ForceFreeStates/LayerOverlap/m"] = lo.m - out_h5["ForceFreeStates/LayerOverlap/n"] = lo.n - out_h5["ForceFreeStates/LayerOverlap/psi"] = lo.psi - out_h5["ForceFreeStates/LayerOverlap/r_s"] = lo.rs - out_h5["ForceFreeStates/LayerOverlap/delta_s_abs"] = lo.delta_s_m - out_h5["ForceFreeStates/LayerOverlap/width_delta_s"] = lo.width_delta_s - out_h5["ForceFreeStates/LayerOverlap/width_visco"] = lo.width_visco - out_h5["ForceFreeStates/LayerOverlap/width_dr"] = lo.width_dr - out_h5["ForceFreeStates/LayerOverlap/extrapolated"] = Int.(lo.extrapolated) - out_h5["ForceFreeStates/LayerOverlap/psilim_overlap"] = - lo.psihigh === nothing ? NaN : lo.psihigh - out_h5["ForceFreeStates/LayerOverlap/first_overlap_index"] = - lo.first_overlap === nothing ? -1 : lo.first_overlap - # "applied" records that the bound was active going into sing_lim!, not that it set - # the final psilim -- rational_offset_fraction/qhigh may truncate deeper inside it. - out_h5["ForceFreeStates/LayerOverlap/applied"] = - Int(ctrl.psilim_from_layer_overlap && lo.psihigh !== nothing && - lo.psihigh < equil.params.psihigh_resolved) - end - - # Write integration data: the ψ trace and integrator diagnostics from the raw ODE state, - # the ξ profiles from the solution. Either may be absent (Galerkin has no ODE state; - # Riccati has no ξ solution), in which case the datasets are written empty. - fwd = "ForceFreeStates/Solutions/ForwardIntegration" - out_h5["$fwd/nstep"] = diag !== nothing ? diag.step : 0 # Number of saved solution snapshots - out_h5["$fwd/nstep_total"] = diag !== nothing ? diag.total_steps : 0 # Total ODE solver steps taken - out_h5["$fwd/psi"] = diag !== nothing ? diag.psi_store : Float64[] - out_h5["$fwd/q"] = diag !== nothing ? diag.q_store : Float64[] - out_h5["$fwd/xi_psi"] = xi_solution !== nothing ? xi_solution.u_store[:, :, 1, :] : ComplexF64[] - out_h5["$fwd/u2"] = xi_solution !== nothing ? xi_solution.u_store[:, :, 2, :] : ComplexF64[] # TODO: what to name this? These are the "conjugate momenta" of u1 - out_h5["$fwd/dxi_psidpsi"] = xi_solution !== nothing ? xi_solution.du_store : ComplexF64[] - out_h5["$fwd/xi_s"] = xi_solution !== nothing ? xi_solution.xi_s_store : ComplexF64[] - out_h5["$fwd/crit"] = diag !== nothing ? diag.crit_store : Float64[] - - # Write edge stability scan data (only present when dW_edge_scan_start < psilim). - # Generalized (W, N) pencil energies — power-normalized, Jacobian-invariant; these are - # the values findmax_dW_edge! uses to choose the truncation point. - if diag !== nothing && !isempty(diag.edge_scan.psi) - es = diag.edge_scan - out_h5["ForceFreeStates/EdgeScan/psi"] = es.psi - out_h5["ForceFreeStates/EdgeScan/q"] = es.q - out_h5["ForceFreeStates/EdgeScan/total_energy"] = es.total_eigenvalue - out_h5["ForceFreeStates/EdgeScan/plasma_energy"] = es.plasma_energy - out_h5["ForceFreeStates/EdgeScan/vacuum_energy"] = es.vacuum_energy - out_h5["ForceFreeStates/EdgeScan/vacuum_eigenvalue"] = es.vacuum_eigenvalue - end - - # Write singular surface data - out_h5["SingularSurfaces/rational_count"] = msing - out_h5["SingularSurfaces/rational_psi"] = [sing.psifac for sing in result.surfaces] - out_h5["SingularSurfaces/rational_q"] = [sing.q for sing in result.surfaces] - out_h5["SingularSurfaces/dqdpsi"] = [sing.q1 for sing in result.surfaces] - # Kinetic and galerkin-matched runs never populate ca_l/ca_r (only ideal surface - # crossings do); emit zero-extent sentinels instead of unpopulated arrays. - out_h5["SingularSurfaces/ca_left"] = (diag !== nothing && diag.ca_populated) ? diag.ca_l : zeros(ComplexF64, 0, 0, 0, 0) - out_h5["SingularSurfaces/ca_right"] = (diag !== nothing && diag.ca_populated) ? diag.ca_r : zeros(ComplexF64, 0, 0, 0, 0) - - if msing > 0 - # Mode numbers at each surface (jagged — pad with 0 to max_modes width) - max_modes = maximum(s -> length(s.m), result.surfaces) - m_matrix = zeros(Int, msing, max_modes) - n_matrix = zeros(Int, msing, max_modes) - for (s, sing) in enumerate(result.surfaces) - for i in 1:length(sing.m) - m_matrix[s, i] = sing.m[i] - n_matrix[s, i] = sing.n[i] - end - end - out_h5["SingularSurfaces/rational_m"] = m_matrix - out_h5["SingularSurfaces/rational_n"] = n_matrix - - # Glasser-Greene-Johnson geometric coefficients + surface averages - # (populated by ForceFreeStates.resist_eval_all! after sing_find!). - # Both kinetic-free (E, F, G, H, K, M) and geometry-only - # (avg_bsq_over_dpsisq, avg_bsq) quantities are written so - # downstream consumers (ForceFreeStates.ggj_parameters) - # can reconstruct τ_A / τ_R from any kinetic-profile source. - if all(s -> s.restype !== nothing, result.surfaces) - out_h5["SingularSurfaces/E"] = [s.restype.E for s in result.surfaces] - out_h5["SingularSurfaces/F"] = [s.restype.F for s in result.surfaces] - out_h5["SingularSurfaces/G"] = [s.restype.G for s in result.surfaces] - out_h5["SingularSurfaces/H"] = [s.restype.H for s in result.surfaces] - out_h5["SingularSurfaces/K"] = [s.restype.K for s in result.surfaces] - out_h5["SingularSurfaces/M"] = [s.restype.M for s in result.surfaces] - out_h5["SingularSurfaces/avg_bsq_over_dpsisq"] = [s.restype.avg_bsq_over_dpsisq for s in result.surfaces] - out_h5["SingularSurfaces/avg_bsq"] = [s.restype.avg_bsq for s in result.surfaces] - out_h5["SingularSurfaces/mu0p"] = [s.restype.p_local for s in result.surfaces] - out_h5["SingularSurfaces/dmu0pdpsi"] = [s.restype.p1_local for s in result.surfaces] - out_h5["SingularSurfaces/dVdpsi"] = [s.restype.v1_local for s in result.surfaces] - end - end - - # Per-surface ca-based Δ' (`sing.delta_prime`) is a stub; only the BVP matrix is emitted (see SingType.delta_prime docstring). - - # Write the Δ' payload on one set of canonical paths, whichever formalism produced it - # (Riccati BVP or Galerkin): both compute the same quantities in the same PEST-3 - # convention. Surface indexing follows the producing formalism's surface list, which for - # Galerkin is the in-domain in-band subset of `SingularSurfaces/`. - dp = result.delta_prime - if msing > 0 && dp !== nothing - # Inter-surface Δ' matrix, shape [msing × msing] — PEST3-convention deltap. - out_h5["SingularSurfaces/Delta_prime_matrix"] = dp.matrix - - # Edge coil-response matrix, stored (numpert_total × 2msing) = (edge mode, surface-side) - # so H5Web heatmaps read x = edge mode, y = surface-side. The carried matrix stays - # (2msing × numpert_total); transpose only at write. - isempty(dp.coil) || (out_h5["SingularSurfaces/Delta_coil"] = permutedims(dp.coil)) - - # Raw 2msing×2msing outer-region D' matrix in side-major ordering - # [L_s1, R_s1, L_s2, R_s2, …]. Byte-compatible with Fortran - # rdcon/gal.f::gal_write_delta top 2msing×2msing block of delta_gw.dat. - # Needed for the full det(D' − D(γ)) = 0 eigenvalue problem via - # pest3_decompose to recover (A', B', Γ', Δ'). - isempty(dp.raw) || (out_h5["SingularSurfaces/Delta_prime_raw"] = dp.raw) - - # Remaining PEST-3 parity blocks, when the formalism persisted them (Galerkin); - # Riccati recovers them from Delta_prime_raw via pest3_decompose. - dp.A === nothing || (out_h5["SingularSurfaces/pest3_A"] = dp.A) - dp.B === nothing || (out_h5["SingularSurfaces/pest3_B"] = dp.B) - dp.Gamma === nothing || (out_h5["SingularSurfaces/pest3_Gamma"] = dp.Gamma) - end - result.match === nothing || write_match!(out_h5, result.match) - - # Write kinetic singular surface data (det(F̄) near-zeros) and the cond(F̄) scan - # used to find them. Populated only when kinetic crossings were searched for. - out_h5["SingularSurfaces/Kinetic/rational_count"] = result.kinetic.kmsing - out_h5["SingularSurfaces/Kinetic/rational_psi"] = [s.psifac for s in result.kinetic.kinsing] - out_h5["SingularSurfaces/Kinetic/rational_q"] = [s.q for s in result.kinetic.kinsing] - out_h5["SingularSurfaces/Kinetic/dqdpsi"] = [s.q1 for s in result.kinetic.kinsing] - out_h5["SingularSurfaces/Kinetic/scan_psi"] = result.kinetic.scan_psi - out_h5["SingularSurfaces/Kinetic/scan_cond"] = result.kinetic.scan_cond - out_h5["SingularSurfaces/Kinetic/scan_threshold"] = result.kinetic.scan_threshold - - # Write free-boundary stability data. The eigenmode energies are the generalized - # eigenvalues of the pencil (W, N) with N the power-normalization (surface-norm) matrix: - # power-normalized mode energies (⟨|ξ|²⟩ = 1 metric) that are invariant to the choice of - # working (Jacobian) coordinate. W_freeboundary_eigenmodes holds the generalized - # eigenvectors, columns sorted most-unstable first, normalized to unit power norm with - # the largest-magnitude entry made real-positive. - fbs = "ForceFreeStates/FreeBoundaryStability" - out_h5["$fbs/W_freeboundary"] = free_energies !== nothing ? free_energies.wt0 : ComplexF64[] - out_h5["$fbs/W_plasma"] = free_energies !== nothing ? free_energies.wp : ComplexF64[] - out_h5["$fbs/W_vacuum"] = free_energies !== nothing ? free_energies.wv : ComplexF64[] - out_h5["$fbs/W_freeboundary_eigenmodes"] = free_energies !== nothing ? free_energies.wt : ComplexF64[] - out_h5["$fbs/eigenmode_energies"] = free_energies !== nothing ? free_energies.et : ComplexF64[] - out_h5["$fbs/eigenmode_plasma_energies"] = free_energies !== nothing ? free_energies.ep : ComplexF64[] - out_h5["$fbs/eigenmode_vacuum_energies"] = free_energies !== nothing ? free_energies.ev : ComplexF64[] - out_h5["$fbs/vacuum_eigenvalue"] = free_energies !== nothing ? free_energies.vacuum_eigenvalue : NaN - - # Cartesian surface point clouds used downstream for visualisation and - # perturbed-equilibrium plotting. - out_h5["SurfaceGeometries/Plasma/x"] = free_energies !== nothing ? free_energies.plasma_pts[:, 1] : Float64[] - out_h5["SurfaceGeometries/Plasma/y"] = free_energies !== nothing ? free_energies.plasma_pts[:, 2] : Float64[] - out_h5["SurfaceGeometries/Plasma/z"] = free_energies !== nothing ? free_energies.plasma_pts[:, 3] : Float64[] - out_h5["SurfaceGeometries/Wall/x"] = free_energies !== nothing ? free_energies.wall_pts[:, 1] : Float64[] - out_h5["SurfaceGeometries/Wall/y"] = free_energies !== nothing ? free_energies.wall_pts[:, 2] : Float64[] - out_h5["SurfaceGeometries/Wall/z"] = free_energies !== nothing ? free_energies.wall_pts[:, 3] : Float64[] - - # Write fundamental matrices on the ψ grid - xs = equil.rzphi_xs - npsi = length(xs) - np = result.numpert_total - - # Helper: evaluate a matrix spline on the psi grid → (npsi, np, np) array - function _eval_mat_spline(spline) - arr = zeros(ComplexF64, npsi, np, np) - hint = Ref(1) - for i in 1:npsi - arr[i, :, :] .= reshape(spline(xs[i]; hint=hint), np, np) - end - return arr - end - - elm = "ForceFreeStates/EulerLagrangeMatrices" - out_h5["$elm/psi"] = xs - # Ideal primitive matrices (A, B, C, D, E, H) - out_h5["$elm/Ideal/A"] = _eval_mat_spline(mats.ideal.A_spline) - out_h5["$elm/Ideal/B"] = _eval_mat_spline(mats.ideal.B_spline) - out_h5["$elm/Ideal/C"] = _eval_mat_spline(mats.ideal.C_spline) - out_h5["$elm/Ideal/D"] = _eval_mat_spline(mats.ideal.D_spline_prim) - out_h5["$elm/Ideal/E"] = _eval_mat_spline(mats.ideal.E_spline_prim) - out_h5["$elm/Ideal/H"] = _eval_mat_spline(mats.ideal.H_spline) - - # Ideal derived matrices (F, K, G) - out_h5["$elm/Ideal/F"] = _eval_mat_spline(mats.ideal.F_spline_lower) - out_h5["$elm/Ideal/K"] = _eval_mat_spline(mats.ideal.K_spline) - out_h5["$elm/Ideal/G"] = _eval_mat_spline(mats.ideal.G_spline) - - # Kinetic-modified matrices - kin = mats.kinetic - if kin !== nothing - out_h5["$elm/Kinetic/A"] = _eval_mat_spline(kin.A_spline) - out_h5["$elm/Kinetic/B"] = _eval_mat_spline(kin.B_spline) - out_h5["$elm/Kinetic/C"] = _eval_mat_spline(kin.C_spline) - out_h5["$elm/Kinetic/f0"] = _eval_mat_spline(kin.F0_spline) - out_h5["$elm/Kinetic/K"] = _eval_mat_spline(kin.Kk_spline) - out_h5["$elm/Kinetic/G"] = _eval_mat_spline(kin.G_spline_adj) - end - - # Self-describing metadata pass (long_name/units/dims + dimension scales). - apply_main_h5_metadata!(out_h5, result) - end -end - -""" - _write_coil_snapshot!(h5_path::String, coil_sets::Vector{CoilSet}) - -Append the coil geometry used by a run into `Input/RawInputs/Coils/` of an existing -gpec.h5 file (opened in append mode), so the run can be replayed from the output alone. -One subgroup per coil set; see `ForcingTerms.save_coils_to_h5`. -""" -function _write_coil_snapshot!(h5_path::String, coil_sets::Vector{ForcingTerms.CoilSet}) - isfile(h5_path) || return nothing - h5open(h5_path, "r+") do out_h5 - haskey(out_h5, "Input/RawInputs/Coils") && return nothing - ForcingTerms.save_coils_to_h5(coil_sets, create_group(out_h5, "Input/RawInputs/Coils")) - end - return nothing -end - """ _write_runtimes!(h5_path::String, runtimes) @@ -1800,7 +1391,7 @@ function _write_runtimes!(h5_path::String, runtimes) for (stage, dt) in runtimes out_h5["Info/Runtimes/$stage"] = dt end - Utilities.HDF5Annotations.annotate!(out_h5, RUNTIME_H5_ANNOTATIONS) + Utilities.HDF5Annotations.annotate!(out_h5, Utilities.RUNTIME_H5_ANNOTATIONS) end return nothing end diff --git a/src/KineticForces/Output.jl b/src/KineticForces/Output.jl index ee454ed0f..e52d375f6 100644 --- a/src/KineticForces/Output.jl +++ b/src/KineticForces/Output.jl @@ -1,3 +1,6 @@ +import ..Utilities: save_to_hdf5 +import ..Utilities.HDF5Annotations: ensure_group, replace_group!, write_fields! + """ Output @@ -7,14 +10,14 @@ then write to gpec.h5 in a single pass. """ """ - write_to_hdf5!(h5file::HDF5.File, state::KineticForcesState; dVdpsi_spline=nothing, + save_to_hdf5(h5file::Union{HDF5.File,HDF5.Group}, state::KineticForcesState; dVdpsi_spline=nothing, species_label=nothing) Write KineticForces results to the "KineticForces" group in gpec.h5. # Arguments - - `h5file::HDF5.File`: Open HDF5 file handle + - `h5file::Union{HDF5.File,HDF5.Group}`: Open HDF5 file handle - `state::KineticForcesState`: Accumulated computation results - `dVdpsi_spline`: Optional dV/dψ_N profile interpolant; when given, dV/dψ_N is written at the quadrature points so the torque density dT/dV = (dT/dψ)/(dV/dψ) @@ -24,9 +27,9 @@ Write KineticForces results to the "KineticForces" group in gpec.h5. `KineticForces/PerSpecies/