From c37fc8b245568f12dd48983625ca60c8001f93f3 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Fri, 6 Nov 2026 23:48:30 -0500 Subject: [PATCH 01/26] Tearing.ModifiedRutherford - FEATURE - Add switchable modified Rutherford terms and F(w) output Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- src/Deck.jl | 30 ++- src/GeneralizedPerturbedEquilibrium.jl | 19 +- src/Tearing/ModifiedRutherford/Geometry.jl | 129 +++++++++++ src/Tearing/ModifiedRutherford/HDF5Output.jl | 71 ++++++ .../ModifiedRutherford/ModifiedRutherford.jl | 26 +++ src/Tearing/ModifiedRutherford/Problem.jl | 212 ++++++++++++++++++ src/Tearing/ModifiedRutherford/Terms.jl | 68 ++++++ src/Tearing/Tearing.jl | 8 +- src/Utilities/Output.jl | 8 +- test/runtests_modified_rutherford.jl | 63 ++++++ test/runtests_solve_api.jl | 37 +++ test/suites.jl | 1 + 12 files changed, 662 insertions(+), 10 deletions(-) create mode 100644 src/Tearing/ModifiedRutherford/Geometry.jl create mode 100644 src/Tearing/ModifiedRutherford/HDF5Output.jl create mode 100644 src/Tearing/ModifiedRutherford/ModifiedRutherford.jl create mode 100644 src/Tearing/ModifiedRutherford/Problem.jl create mode 100644 src/Tearing/ModifiedRutherford/Terms.jl create mode 100644 test/runtests_modified_rutherford.jl diff --git a/src/Deck.jl b/src/Deck.jl index ea0f4b17c..bd83498a0 100644 --- a/src/Deck.jl +++ b/src/Deck.jl @@ -360,7 +360,9 @@ end """ function tearing_from_deck(inputs::AbstractDict) haskey(inputs, "Tearing") || return nothing - table = inputs["Tearing"] + # A [Tearing] holding only [Tearing.ModifiedRutherford] evaluates the MRE without a layer solve. + table = _without(inputs["Tearing"], "ModifiedRutherford") + isempty(table) && return nothing get(table, "omega_E_kHz", Dict{String,Any}()) isa AbstractDict || throw(ArgumentError("[Tearing] omega_E_kHz must be a table keyed by m/n, e.g. omega_E_kHz = { \"2/1\" = 3.0 }")) fields = _struct_fields(TearingProblem, (:ffs, :critical_resonant_field)) @@ -377,6 +379,16 @@ function tearing_from_deck(inputs::AbstractDict) return (; problem, model=inner_model == "SLAYER" ? slayer_model(inputs) : ggj_model(inputs), config) end +""" + modified_rutherford_from_deck(inputs) -> Union{Nothing,Dict{Symbol,Any}} + +The `ModifiedRutherfordProblem` keywords of `[Tearing.ModifiedRutherford]`; `nothing` without that section. +""" +function modified_rutherford_from_deck(inputs::AbstractDict) + haskey(inputs, "Tearing") && haskey(inputs["Tearing"], "ModifiedRutherford") || return nothing + return section_keywords(inputs["Tearing"]["ModifiedRutherford"], "Tearing.ModifiedRutherford", _struct_fields(ModifiedRutherfordProblem, (:ffs,))) +end + """ perturbed_equilibrium_from_deck(inputs) -> Union{Nothing,NamedTuple} @@ -471,6 +483,17 @@ function parse_deck(inputs::AbstractDict, path::AbstractString) error("[Match] needs Δ′: use integrator = \"Galerkin\" with [Galerkin] rpec_flag = true, or \"Riccati\" with vac_flag = true") end tearing = tearing_from_deck(inputs) + modified_rutherford = modified_rutherford_from_deck(inputs) + # The ModifiedRutherfordProblem guards, checked before the force-free-states solve rather than after it. + if modified_rutherford !== nothing + has_ffs || error("[Tearing.ModifiedRutherford] needs a [ForceFreeStates] solve") + (; keywords, algorithm) = force_balance + first(keywords[:nn]) == last(keywords[:nn]) || error("[Tearing.ModifiedRutherford] is single-n: set [ForceFreeStates] nn to one toroidal mode number") + (algorithm isa Galerkin && algorithm.rpec_flag) || (algorithm isa Riccati && get(keywords, :vac_flag, false)) || + error("[Tearing.ModifiedRutherford] needs Δ′: use integrator = \"Riccati\" with vac_flag = true, or \"Galerkin\" with [Galerkin] rpec_flag = true") + isempty(get(get(inputs, "Equilibrium", Dict{String,Any}()), "kinetic_file", "")) && + error("[Tearing.ModifiedRutherford] needs kinetic profiles: set kinetic_file in [Equilibrium]") + end perturbed_equilibrium = perturbed_equilibrium_from_deck(inputs) forcing = forcing_terms_control(inputs, path) if haskey(inputs, "ErrorFields") @@ -483,7 +506,7 @@ function parse_deck(inputs::AbstractDict, path::AbstractString) error("[GGJ] is set but neither [Match] nor [Tearing] inner_model = \"GGJ\" uses it") haskey(inputs, "SLAYER") && tearing_model != "SLAYER" && !(has_ffs && get(inputs["ForceFreeStates"], "psilim_from_layer_overlap", false) === true) && error("[SLAYER] is set but neither [Tearing] inner_model = \"SLAYER\" nor [ForceFreeStates] psilim_from_layer_overlap = true uses it") - return (; run=run_settings(inputs, path), force_balance, local_stability=local_stability_from_deck(inputs), match, tearing, + return (; run=run_settings(inputs, path), force_balance, local_stability=local_stability_from_deck(inputs), match, tearing, modified_rutherford, kinetic_forces=haskey(inputs, "KineticForces") ? kinetic_forces_control(inputs) : nothing, perturbed_equilibrium, forcing, error_fields=error_fields_from_deck(inputs, path)) end @@ -578,6 +601,8 @@ function deck_inputs(deck::NamedTuple, config::Equilibrium.EquilibriumConfig, an merge!(inputs, _tearing_sections(filter(p -> p.first != :critical_resonant_field, problem), get(problem, :critical_resonant_field, nothing), model, config)) end + mre = deck.modified_rutherford + mre === nothing || (get!(inputs, "Tearing", Dict{String,Any}())["ModifiedRutherford"] = _deck_table(mre)) deck.kinetic_forces === nothing || merge!(inputs, deck_sections(deck.kinetic_forces)) pe = deck.perturbed_equilibrium pe === nothing || (inputs["PerturbedEquilibrium"] = _deck_table(merge(pe.response, pe.drive))) @@ -626,6 +651,7 @@ deck_sections(m::ForceFreeStates.MatchResult) = merge(deck_sections(m.model), Dict{String,Any}("Match" => merge(_deck_table(m.problem, (:ffs,)), Dict{String,Any}("inner_model" => "GGJ")))) deck_sections(kf::KineticForcesResult) = deck_sections(kf.control) deck_sections(t::TearingResult) = t.model === nothing ? Dict{String,Any}() : _tearing_sections(t.problem, t.problem.critical_resonant_field, t.model, t.config) +deck_sections(r::ModifiedRutherfordResult) = Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => _deck_table(r.problem, (:ffs,)))) deck_sections(ef::ErrorFieldsResult) = _error_fields_sections(ef.settings, ef.tolerances === nothing ? "" : _RECORDED_TOLERANCES) function deck_sections(ffs::ForceFreeStatesResult) diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 76c5515b5..adfa9b2a5 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -50,6 +50,7 @@ include("Tearing/Tearing.jl") import .Tearing as Tearing export Tearing import .Tearing.CriticalResonantField as CriticalResonantField +import .Tearing.ModifiedRutherford as ModifiedRutherford include("PerturbedEquilibrium/PerturbedEquilibrium.jl") import .PerturbedEquilibrium as PerturbedEquilibrium @@ -74,7 +75,7 @@ include("Rerun.jl") using .ForceFreeStates: AbstractIntegrator, Forward, Riccati, Galerkin, TestKineticMatrices, KineticMatrices, ForceFreeStatesResult using .ForceFreeStates: MatchProblem using .InnerLayer: GGJModel, SLAYERModel -using .Tearing: TearingProblem, TearingConfig, TearingResult, CriticalResonantFieldControl +using .Tearing: TearingProblem, TearingConfig, TearingResult, CriticalResonantFieldControl, ModifiedRutherfordProblem, ModifiedRutherfordResult using .LocalStability: LocalStabilityProblem, Ballooning, LocalStabilityResult using .Equilibrium: PlasmaEquilibrium, KineticProfileData, IonSpecies, attach_kinetic_profiles! using .ForcingTerms: RMPField @@ -139,7 +140,7 @@ equilibrium and writes nothing. The raw forcing file contents are captured in `Input/RawInputs/ForcingTerms/`, and `preloaded_coil_sets` lets the rerun path inject the coil geometry stored in `Input/RawInputs/Coils/`. -Returns `(; equil, local_stability, ffs, match, resp, pe, kinetic, error_fields, tearing)`, each +Returns `(; equil, local_stability, ffs, match, resp, pe, kinetic, error_fields, tearing, modified_rutherford)`, each `nothing` when its section is absent. `equil` is the equilibrium the stages used (the solve's, on the two-pass grid), `ffs` the result the later stages read (the matched one when the deck has `[Match]`), and `match` its `MatchResult`. @@ -157,7 +158,7 @@ function main_from_inputs( runtimes = Vector{Pair{String,Float64}}() deck = parse_deck(inputs, path) (; output_file, verbose) = deck.run - local_stability = ffs = match = resp = pe = kinetic = error_fields = tearing = nothing + local_stability = ffs = match = resp = pe = kinetic = error_fields = tearing = modified_rutherford = nothing equil = _timed_stage(runtimes, "equilibrium", "Equilibrium") do Equilibrium.PlasmaEquilibrium(eq_config, additional_input) @@ -223,12 +224,20 @@ function main_from_inputs( save_to_hdf5(output_file, tearing) end + # After the tearing stage, which replaces the whole Tearing/ group. + if deck.modified_rutherford !== nothing + modified_rutherford = _timed_stage(runtimes, "modified_rutherford", "Modified Rutherford") do + solve(ModifiedRutherfordProblem(ffs; deck.modified_rutherford...)) + end + save_to_hdf5(output_file, modified_rutherford) + end + total_dt = time() - total_start push!(runtimes, "total" => total_dt) ffs === nothing || _write_runtimes!(output_file, runtimes) @info "\n$_BANNER\n GPEC completed successfully in $(@sprintf("%.3f", total_dt)) s\n$_BANNER" - return (; equil, local_stability, ffs, match, resp, pe, kinetic, error_fields, tearing) + return (; equil, local_stability, ffs, match, resp, pe, kinetic, error_fields, tearing, modified_rutherford) end """ @@ -304,7 +313,7 @@ export main, write_imas export solve, plasma_response, perturbed_equilibrium, kinetic_torque, error_fields export KineticProfileData, IonSpecies, attach_kinetic_profiles! export PlasmaEquilibrium, ForceBalanceProblem, Forward, Riccati, Galerkin, TestKineticMatrices, KineticMatrices, kinetic_matrices, ForceFreeStatesResult, RMPField, PlasmaResponse -export MatchProblem, TearingProblem, TearingConfig, TearingResult, GGJModel, SLAYERModel +export MatchProblem, TearingProblem, TearingConfig, TearingResult, GGJModel, SLAYERModel, ModifiedRutherfordProblem, ModifiedRutherfordResult export LocalStabilityProblem, Ballooning, LocalStabilityResult export KineticForcesControl, KineticForcesResult, ErrorFieldsResult diff --git a/src/Tearing/ModifiedRutherford/Geometry.jl b/src/Tearing/ModifiedRutherford/Geometry.jl new file mode 100644 index 000000000..c35eafdaf --- /dev/null +++ b/src/Tearing/ModifiedRutherford/Geometry.jl @@ -0,0 +1,129 @@ +# Geometry.jl +# +# Flux-surface averages the modified Rutherford terms need beyond `ResistGeometry`: the Hegna 1999 R̄², +# poloidal-field averages, the mean minor radius and the Sauter 2002 trapped fraction, as in Fortran RDCON `mercier.f`. +# +# θ-integrands, each weighted by `jac / v1` before integration: +# 1: B²/|∇ψ|² 2: B² 3: |∇ψ|² 4: R² v1/jac 5: B_pol 6: B_pol² 7: r (minor radius) + +""" + MRESurfaceGeometry + +Per-surface geometry of the modified Rutherford terms. Averages ⟨·⟩ are normalized flux-surface averages (⟨1⟩ = 1) +and `ψ_N` is normalized poloidal flux, as in [`ResistGeometry`](@ref GeneralizedPerturbedEquilibrium.ForceFreeStates.ResistGeometry). + +| field | meaning | +|:--------------------- |:------------------------------------------------------------------------ | +| `q`, `q1` | safety factor and dq/dψ_N | +| `p1` | μ₀ dp/dψ_N | +| `v1` | dV/dψ_N [m³] | +| `psio` | poloidal flux per radian at the boundary [Wb] | +| `avg_bsq_over_dpsisq` | ⟨B²/‖∇ψ_N‖²⟩ | +| `avg_bsq` | ⟨B²⟩ [T²] | +| `avg_dpsisq` | ⟨‖∇ψ_N‖²⟩ | +| `overbar_Rsq` | Hegna 1999 R̄² = ⟨R² V′/𝒥⟩ [m²] | +| `avg_bpol`, `avg_bpolsq` | ⟨B_pol⟩ [T] and ⟨B_pol²⟩ [T²] | +| `r_minor` | ⟨r⟩, mean distance from the magnetic axis [m] | +| `R_major`, `eps` | (R_max + R_min)/2 [m] and (R_max − R_min)/(R_max + R_min) | +| `delta` | triangularity, mean of the upper and lower values | +| `f_trap` | Sauter 2002 trapped fraction with the triangularity-corrected ε_eff | +| `Wc_prefac` | Schlutt & Hegna 2012 w_d⁴ prefactor in ψ_N⁴, before χ⊥/χ∥ and 1/m² | +""" +struct MRESurfaceGeometry + q::Float64 + q1::Float64 + p1::Float64 + v1::Float64 + psio::Float64 + avg_bsq_over_dpsisq::Float64 + avg_bsq::Float64 + avg_dpsisq::Float64 + overbar_Rsq::Float64 + avg_bpol::Float64 + avg_bpolsq::Float64 + r_minor::Float64 + R_major::Float64 + eps::Float64 + delta::Float64 + f_trap::Float64 + Wc_prefac::Float64 +end + +""" + sauter2002_trapped_fraction(eps, delta) -> Float64 + +Trapped fraction of Sauter 2002 (as Fortran RDCON uses it): ε_eff = max(0, 0.67(1 − 1.4δ|δ|)ε) and +`f_t = 1 − √((1 − ε)/(1 + ε)) (1 − ε_eff)/(1 + 2√ε_eff)`, capped at 1. +""" +function sauter2002_trapped_fraction(eps::Real, delta::Real) + eps_eff = max(0.0, 0.67 * (1 - 1.4 * delta * abs(delta)) * eps) + return min(1 - sqrt((1 - eps) / (1 + eps)) * (1 - eps_eff) / (1 + 2 * sqrt(eps_eff)), 1.0) +end + +""" + bootstrap_coefficient(g::MRESurfaceGeometry, zeff) -> (D_nc, mu_ratio) + +Bootstrap drive coefficient of Hegna 1999 as Fortran RDCON evaluates it, +`D_nc = −(q/q′)(μ₀p′/⟨B²⟩) R̄² ⟨B²/‖∇ψ_N‖²⟩ ψ_o⁻² · μ_e/(ν_e + μ_e)`, with the banana-limit viscosity ratio +`μ_e/(ν_e + μ_e) = f_t a/((1 − f_t) + f_t a)`, `a = 1 + 0.533/Z_eff`. +""" +function bootstrap_coefficient(g::MRESurfaceGeometry, zeff::Real) + a = 1 + 0.533 / zeff + mu_ratio = g.f_trap * a / ((1 - g.f_trap) + g.f_trap * a) + return -g.q * (g.p1 / (g.q1 * g.avg_bsq)) * g.overbar_Rsq * g.avg_bsq_over_dpsisq / g.psio^2 * mu_ratio, mu_ratio +end + +""" + mre_geometry(equil, psifac, q1) -> MRESurfaceGeometry + +Flux-surface geometry of the modified Rutherford terms at the interior surface `psifac` with dq/dψ_N `q1`. +""" +function mre_geometry(equil, psifac::Real, q1::Real) + profiles = equil.profiles + chi1 = 2π * equil.psio + psi = Float64(psifac) + twopif = profiles.F_spline(psi) + p1 = profiles.P_deriv(psi) + v1 = profiles.dVdpsi_spline(psi) + q = profiles.q_spline(psi) + + ys = equil.rzphi_ys + ff = zeros(Float64, length(ys), 7) + R_max, R_min, Z_max, Z_min = -Inf, Inf, -Inf, Inf + R_at_Zmax = R_at_Zmin = equil.ro + for (i, theta) in enumerate(ys) + rfac = sqrt(equil.rzphi_rsquared((psi, theta))) + jac = equil.rzphi_jac((psi, theta)) + fy1 = FastInterpolations.deriv_view(equil.rzphi_rsquared, (0, 1))((psi, theta)) + fy2 = FastInterpolations.deriv_view(equil.rzphi_offset, (0, 1))((psi, theta)) + fy3 = FastInterpolations.deriv_view(equil.rzphi_nu, (0, 1))((psi, theta)) + eta = 2π * (theta + equil.rzphi_offset((psi, theta))) + r = equil.ro + rfac * cos(eta) + z = equil.zo + rfac * sin(eta) + + v21 = fy1 / (2 * rfac * jac) + v22 = (1 + fy2) * 2π * rfac / jac + v23 = fy3 * r / jac + v33 = 2π * r / jac + bsq = chi1^2 * (v21^2 + v22^2 + (v23 + q * v33)^2) + dpsisq = (2π * r)^2 * (v21^2 + v22^2) + bpolsq = max(bsq - (twopif / (2π * r))^2, 0.0) + + R_max, R_min = max(R_max, r), min(R_min, r) + z > Z_max && ((Z_max, R_at_Zmax) = (z, r)) + z < Z_min && ((Z_min, R_at_Zmin) = (z, r)) + ff[i, :] .= (bsq / dpsisq, bsq, dpsisq, r^2 * v1 / jac, sqrt(bpolsq), bpolsq, rfac) + @views ff[i, :] .*= jac / v1 + end + @views ff[end, :] .= ff[1, :] + avg = FastInterpolations.integrate(cubic_interp(ys, Series(ff); bc=PeriodicBC())) + + R_major = 0.5 * (R_max + R_min) + a_minor = 0.5 * (R_max - R_min) + eps = a_minor / R_major + delta = 0.5 * ((R_major - R_at_Zmax) + (R_major - R_at_Zmin)) / a_minor + # Schlutt & Hegna 2012 ⟨𝒥B²⟩⟨𝒥|∇ψ|²⟩/(dι/dψ)² in toroidal flux, converted to ψ_N⁴. + Wc_prefac = (v1 * avg[2] / (q * equil.psio)) * v1 * avg[3] * (q * equil.psio) * (q^6 / (q1 / equil.psio)^2) / (q * equil.psio)^4 + return MRESurfaceGeometry(q, q1, p1, v1, equil.psio, avg[1], avg[2], avg[3], avg[4], avg[5], avg[6], avg[7], + R_major, eps, delta, sauter2002_trapped_fraction(eps, delta), Wc_prefac) +end diff --git a/src/Tearing/ModifiedRutherford/HDF5Output.jl b/src/Tearing/ModifiedRutherford/HDF5Output.jl new file mode 100644 index 000000000..4855ee3b6 --- /dev/null +++ b/src/Tearing/ModifiedRutherford/HDF5Output.jl @@ -0,0 +1,71 @@ +# HDF5Output.jl +# +# Writer for a `ModifiedRutherfordResult`, under the caller's parent group: +# +# Tearing/ +# └── ModifiedRutherford/ +# ├── w -- width grid (ψ_N) +# ├── PerSurface/ -- surface identity, indices and term inputs +# └── Terms/ -- F(w), each term and w_d(w), surface × width + +""" + save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::ModifiedRutherfordResult) + +Write `result` into `Tearing/ModifiedRutherford/` of `parent`, replacing any existing one and keeping the rest of +`Tearing/`, and record its deck section. +""" +function save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::ModifiedRutherfordResult) + tearing = haskey(parent, "Tearing") ? parent["Tearing"] : create_group(parent, "Tearing") + g = replace_group!(tearing, "ModifiedRutherford") + g["w"] = result.w + for key in _MRE_PER_SURFACE + g["PerSurface/$key"] = getfield(result, key) + end + for key in _MRE_TERMS + g["Terms/$key"] = getfield(result, key) + end + Utilities.HDF5Annotations.annotate!(g, MRE_H5_ANNOTATIONS) + Utilities.record_inputs!(parent, result) + return g +end + +const _MRE_PER_SURFACE = (:psi, :q, :m, :n, :delta_prime, :D_I, :D_R, :H, :D_nc, :f_trap, :mu_ratio, :zeff, :Wc_prefac, :chi_perp, + :v_te, :R0, :omega_star_i, :omega_hat, :omega_hat_pi, :beta_p, :rho_theta, :w_ib, :eps) +const _MRE_TERMS = (:F, :classical, :bootstrap, :curvature, :polarization, :w_d) + +# Julia's surface × width matrices land in HDF5 as (width, surface). +const _MRE_TERM_DIMS = ("w", "surface") + +const MRE_H5_ANNOTATIONS = [ + "w" => (; long_name="full island width grid", units="psi_N", dims=("w",)), + "PerSurface/psi" => (; long_name="normalized poloidal flux of the rational surface", units="psi_N", dims=("surface",)), + "PerSurface/q" => (; long_name="safety factor at the surface", dims=("surface",)), + "PerSurface/m" => (; long_name="resonant poloidal mode number", dims=("surface",)), + "PerSurface/n" => (; long_name="resonant toroidal mode number", dims=("surface",)), + "PerSurface/delta_prime" => (; long_name="real part of the diagonal Delta' (PEST-3 convention)", dims=("surface",)), + "PerSurface/D_I" => (; long_name="Mercier index D_I = E + F + H - 1/4", dims=("surface",)), + "PerSurface/D_R" => (; long_name="resistive interchange index D_R = E + F + H^2", dims=("surface",)), + "PerSurface/H" => (; long_name="Glasser-Greene-Johnson coefficient H", dims=("surface",)), + "PerSurface/D_nc" => (; long_name="bootstrap drive coefficient D_nc (Hegna 1999)", dims=("surface",)), + "PerSurface/f_trap" => (; long_name="Sauter 2002 trapped fraction", dims=("surface",)), + "PerSurface/mu_ratio" => (; long_name="banana-limit viscosity ratio mu_e/(nu_e + mu_e)", dims=("surface",)), + "PerSurface/zeff" => (; long_name="effective charge Z_eff", dims=("surface",)), + "PerSurface/Wc_prefac" => (; long_name="transport-width prefactor W_c (Schlutt & Hegna 2012), before chi_perp/chi_par and 1/m^2", units="psi_N^4", + dims=("surface",)), + "PerSurface/chi_perp" => (; long_name="perpendicular electron heat diffusivity used in w_d", units="m^2/s", dims=("surface",)), + "PerSurface/v_te" => (; long_name="electron thermal speed sqrt(2T_e/m_e)", units="m/s", dims=("surface",)), + "PerSurface/R0" => (; long_name="major radius (R_max + R_min)/2 of the surface, used in lambda_par", units="m", dims=("surface",)), + "PerSurface/omega_star_i" => (; long_name="ion diamagnetic frequency from the density gradient", units="rad/s", dims=("surface",)), + "PerSurface/omega_hat" => (; long_name="island frequency normalized to the ion diamagnetic frequency", dims=("surface",)), + "PerSurface/omega_hat_pi" => (; long_name="omega_*pi/omega_*i = 1 + eta_i", dims=("surface",)), + "PerSurface/beta_p" => (; long_name="poloidal ion beta 2 mu0 n_i T_i/", dims=("surface",)), + "PerSurface/rho_theta" => (; long_name="poloidal ion Larmor radius", units="m", dims=("surface",)), + "PerSurface/w_ib" => (; long_name="ion banana width", units="psi_N", dims=("surface",)), + "PerSurface/eps" => (; long_name="inverse aspect ratio of the surface", dims=("surface",)), + "Terms/F" => (; long_name="modified Rutherford F(w), the sum of the switched-on terms", dims=_MRE_TERM_DIMS), + "Terms/classical" => (; long_name="classical term Delta'(w)", dims=_MRE_TERM_DIMS), + "Terms/bootstrap" => (; long_name="bootstrap term Delta_nc(w)", dims=_MRE_TERM_DIMS), + "Terms/curvature" => (; long_name="curvature term Delta_GGJ(w)", dims=_MRE_TERM_DIMS), + "Terms/polarization" => (; long_name="ion polarization term Delta_pol(w), including pol_sign", dims=_MRE_TERM_DIMS), + "Terms/w_d" => (; long_name="transport threshold width w_d(w)", units="psi_N", dims=_MRE_TERM_DIMS) +] diff --git a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl new file mode 100644 index 000000000..ff7b7fb07 --- /dev/null +++ b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl @@ -0,0 +1,26 @@ +# The steady modified Rutherford equation: switchable terms and their sum F(w) per rational surface, from the +# force-balance Δ′, the resistive-layer geometry and the equilibrium's kinetic data. + +module ModifiedRutherford + +using HDF5 +import CommonSolve +import FastInterpolations +using FastInterpolations: cubic_interp, Series, PeriodicBC, DerivOp + +import ...Utilities +import ...Utilities: save_to_hdf5 +using ...Utilities.HDF5Annotations: replace_group! +using ...Utilities.PhysicalConstants: M_E, M_P, E_CHG, MU_0 +using ...InnerLayer.SLAYER: _slayer_profiles + +include("Geometry.jl") +include("Terms.jl") +include("Problem.jl") +include("HDF5Output.jl") + +export MRESurfaceGeometry, mre_geometry, sauter2002_trapped_fraction, bootstrap_coefficient +export classical_term, bootstrap_term, curvature_term, polarization_term, reduced_polarization_term, transport_width +export ModifiedRutherfordProblem, ModifiedRutherfordResult + +end # module ModifiedRutherford diff --git a/src/Tearing/ModifiedRutherford/Problem.jl b/src/Tearing/ModifiedRutherford/Problem.jl new file mode 100644 index 000000000..beb6eb8ef --- /dev/null +++ b/src/Tearing/ModifiedRutherford/Problem.jl @@ -0,0 +1,212 @@ +# Problem.jl +# +# `solve(ModifiedRutherfordProblem(ffs; ...))`: the terms of the steady modified Rutherford equation and their sum F(w) +# on a log-spaced grid of full island widths, per rational surface. Diagnostic only: no roots. + +""" + ModifiedRutherfordProblem(ffs; kwargs...) + +The steady modified Rutherford equation (MRE) on every rational surface of a finished force-balance solve, + + F(w) = s_Δ Δ′(w) + s_nc Δ_nc(w) + s_GGJ Δ_GGJ(w) + s_pol Δ_pol(w), + +with `w` the full island width in normalized poloidal flux ψ_N. Each term is evaluated on the grid whether or not its +switch is on; the switches select which terms `F` sums. Δ′ is the real part of the diagonal of `ffs.delta_prime`, +the Mercier, resistive-interchange and Pfirsch-Schlüter indices D_I, D_R = E + F + H², H come from the surfaces' +`ResistGeometry`, and the plasma (densities, temperatures, Z_eff, χ⊥,e, E×B rotation, ion species) is the +equilibrium's kinetic data, as every tearing layer reads it. A deck states it as `[Tearing.ModifiedRutherford]`. + +## Fields + + - `ffs` - Outer solution with a Δ′ matrix (Riccati with `vac_flag=true`, or Galerkin with `rpec_flag=true`), single n. + - `classical`, `bootstrap`, `curvature`, `polarization` - Term switches s_Δ, s_nc, s_GGJ, s_pol. + - `delta_prime_model` - `:mercier_power` (Benjamin Eq. 10) or `:constant` (Δ′(w) = Δ′_mn). + - `k1` - Bootstrap and curvature coefficient k₁. + - `c_wd` - Prefactor of the transport threshold width w_d. + - `chi_perp_factor` - Multiplier on χ⊥,e (the kinetic `chi_e` profile, else its `chi_e_default`). + - `pol_model` - `:full` (Benjamin Eq. C.1) or `:reduced` (−c_pol w_pol²/w³, w_pol = C_pol w_ib). + - `c_pol`, `C_pol` - Polarization coefficients. + - `pol_sign` - ±1, multiplies the polarization term. + - `omega_factor` - Multiplier on the island frequency ω = n Ω_E from the kinetic `omega_E`; 0 is a stalled island. + - `omega_ratio` - ω/ω_*i on every surface, replacing the one from `omega_E`; `nothing` (default) uses `omega_E`. + - `w_min`, `w_max`, `nw` - Log-spaced width grid in ψ_N. +""" +@kwdef struct ModifiedRutherfordProblem{R} + ffs::R + classical::Bool = true + bootstrap::Bool = true + curvature::Bool = true + polarization::Bool = false + delta_prime_model::Symbol = :mercier_power + k1::Float64 = 1.7 + c_wd::Float64 = 1.0 + chi_perp_factor::Float64 = 1.0 + pol_model::Symbol = :full + c_pol::Float64 = 1.0 + C_pol::Float64 = 1.9 + pol_sign::Float64 = 1.0 + omega_factor::Float64 = 1.0 + omega_ratio::Union{Nothing,Float64} = nothing + w_min::Float64 = 1e-5 + w_max::Float64 = 0.2 + nw::Int = 200 +end + +ModifiedRutherfordProblem(ffs; kwargs...) = ModifiedRutherfordProblem{typeof(ffs)}(; ffs=ffs, kwargs...) + +""" + ModifiedRutherfordResult + +Output of `solve(::ModifiedRutherfordProblem)`. Per-surface vectors run core to edge; matrices are surface × width. + +## Fields + + - `problem` - The producing problem. + - `w` - Width grid in ψ_N. + - `psi`, `q`, `m`, `n` - Surface identity. + - `delta_prime`, `D_I`, `D_R`, `H` - Outer-region and layer indices. + - `D_nc`, `f_trap`, `mu_ratio`, `zeff` - Bootstrap coefficient, Sauter 2002 trapped fraction, μ_e/(ν_e + μ_e), Z_eff. + - `Wc_prefac`, `chi_perp`, `v_te`, `R0` - Inputs of w_d. + - `omega_star_i`, `omega_hat`, `omega_hat_pi` - Ion diamagnetic frequency [rad/s], ω/ω_*i and ω_*pi/ω_*i. + - `beta_p`, `rho_theta`, `w_ib`, `eps` - Polarization inputs: poloidal ion beta, poloidal ion Larmor radius [m], + banana width in ψ_N, inverse aspect ratio. + - `F`, `classical`, `bootstrap`, `curvature`, `polarization`, `w_d` - F(w), each term, and w_d(w). +""" +struct ModifiedRutherfordResult{P} + problem::P + w::Vector{Float64} + psi::Vector{Float64} + q::Vector{Float64} + m::Vector{Int} + n::Vector{Int} + delta_prime::Vector{Float64} + D_I::Vector{Float64} + D_R::Vector{Float64} + H::Vector{Float64} + D_nc::Vector{Float64} + f_trap::Vector{Float64} + mu_ratio::Vector{Float64} + zeff::Vector{Float64} + Wc_prefac::Vector{Float64} + chi_perp::Vector{Float64} + v_te::Vector{Float64} + R0::Vector{Float64} + omega_star_i::Vector{Float64} + omega_hat::Vector{Float64} + omega_hat_pi::Vector{Float64} + beta_p::Vector{Float64} + rho_theta::Vector{Float64} + w_ib::Vector{Float64} + eps::Vector{Float64} + F::Matrix{Float64} + classical::Matrix{Float64} + bootstrap::Matrix{Float64} + curvature::Matrix{Float64} + polarization::Matrix{Float64} + w_d::Matrix{Float64} +end + +function validate(prob::ModifiedRutherfordProblem) + ffs = prob.ffs + ffs.npert == 1 || throw(ArgumentError("ModifiedRutherfordProblem is single-n; the solve spans n = $(ffs.nlow):$(ffs.nhigh)")) + dp = ffs.delta_prime + dp === nothing && throw(ArgumentError("ModifiedRutherfordProblem needs Δ′: solve with Riccati() and vac_flag=true, or Galerkin(; rpec_flag=true)")) + size(dp.matrix, 1) == length(ffs.surfaces) || + throw(ArgumentError("ModifiedRutherfordProblem: the Δ′ matrix is $(size(dp.matrix, 1))×$(size(dp.matrix, 2)) for $(length(ffs.surfaces)) surfaces")) + ffs.equil.kinetic === nothing && throw(ArgumentError("ModifiedRutherfordProblem needs kinetic profiles; set kinetic_file in [Equilibrium]")) + prob.delta_prime_model in (:mercier_power, :constant) || throw(ArgumentError("delta_prime_model must be :mercier_power or :constant")) + prob.pol_model in (:full, :reduced) || throw(ArgumentError("pol_model must be :full or :reduced")) + abs(prob.pol_sign) == 1 || throw(ArgumentError("pol_sign must be +1 or -1")) + 0 < prob.w_min < prob.w_max && prob.nw >= 2 || throw(ArgumentError("the width grid needs 0 < w_min < w_max and nw ≥ 2")) + prob.k1 > 0 && prob.c_wd > 0 && prob.chi_perp_factor > 0 || throw(ArgumentError("k1, c_wd and chi_perp_factor must be positive")) + return nothing +end + +# Value and ψ-derivative of a kinetic profile column at ψ. +function _profile_at(psi_xs, values, psi) + itp = cubic_interp(psi_xs, collect(Float64, values)) + return Float64(itp(psi)), Float64(itp(psi; deriv=DerivOp(1))) +end + +function CommonSolve.solve(prob::ModifiedRutherfordProblem) + validate(prob) + ffs = prob.ffs + equil = ffs.equil + data = equil.kinetic + surfaces = ffs.surfaces + ns = length(surfaces) + w = exp.(range(log(prob.w_min), log(prob.w_max); length=prob.nw)) + + # One plasma for every tearing consumer: the SLAYER view supplies n_e, T_e, T_i, Ω_E and the Z_eff closure. + loaded = _slayer_profiles(data, nothing) + psi_xs = collect(Float64, data.psi) + usable(v) = v !== nothing && !all(iszero, v) + usable(data.chi_e) || data.chi_e_default !== nothing || + throw(ArgumentError("ModifiedRutherfordProblem needs χ⊥,e: a chi_e profile or chi_e_default in the kinetic data")) + species = data.ion_species + m_i = sum(s.fraction * s.m for s in species) / sum(s.fraction for s in species) * M_P + z_i = Float64(first(species).z) + + vec0() = zeros(ns) + out = (; psi=vec0(), q=vec0(), m=zeros(Int, ns), n=zeros(Int, ns), delta_prime=vec0(), D_I=vec0(), D_R=vec0(), H=vec0(), + D_nc=vec0(), f_trap=vec0(), mu_ratio=vec0(), zeff=vec0(), Wc_prefac=vec0(), chi_perp=vec0(), v_te=vec0(), R0=vec0(), + omega_star_i=vec0(), omega_hat=vec0(), omega_hat_pi=vec0(), beta_p=vec0(), rho_theta=vec0(), w_ib=vec0(), eps=vec0()) + mats = Dict(k => zeros(ns, prob.nw) for k in (:F, :classical, :bootstrap, :curvature, :polarization, :w_d)) + + for (k, s) in enumerate(surfaces) + rg = s.restype + rg === nothing && throw(ArgumentError("ModifiedRutherfordProblem: the surface at ψ = $(s.psifac) has no ResistGeometry")) + psi = s.psifac + m, n = s.m[1], s.n[1] + g = mre_geometry(equil, psi, s.q1) + D_I = rg.E + rg.F + rg.H - 0.25 + D_R = rg.E + rg.F + rg.H^2 + D_I < 0 || @warn "ModifiedRutherfordProblem: D_I = $D_I ≥ 0 at the $m/$n surface; its terms are NaN" + + # Plasma at the surface; the ion density falls back to n_e where the data have no n_i. + n_e = Float64(loaded.profiles.n_e(psi)) + T_e = Float64(loaded.profiles.T_e(psi)) + T_i, dT_i = Float64(loaded.profiles.T_i(psi)), Float64(loaded.profiles.T_i(psi; deriv=DerivOp(1))) + n_i, dn_i = usable(data.n_i) ? _profile_at(psi_xs, data.n_i, psi) : (n_e, Float64(loaded.profiles.n_e(psi; deriv=DerivOp(1)))) + zeff = loaded.zeff === nothing ? data.zeff_default : Float64(loaded.zeff(psi)) + chi_perp = prob.chi_perp_factor * (usable(data.chi_e) ? first(_profile_at(psi_xs, data.chi_e, psi)) : data.chi_e_default) + + D_nc, mu_ratio = bootstrap_coefficient(g, zeff) + + # Polarization inputs (Benjamin App. C); widths in metres map to ψ_N by w/(2ψ_s) ↔ ρ/r_s. + v_te = sqrt(2 * E_CHG * T_e / M_E) + v_ti = sqrt(2 * E_CHG * T_i / m_i) + rho_theta = m_i * v_ti / (g.avg_bpol * E_CHG) + w_ib = sqrt(g.eps) * rho_theta * 2 * psi / g.r_minor + beta_p = 2 * MU_0 * n_i * E_CHG * T_i / g.avg_bpolsq + gradient_ratio = (dn_i / n_i) / (g.q1 / g.q) + # Ion diamagnetic frequency from the density gradient, in SLAYER's sign convention; ω_*pi adds the temperature gradient. + omega_star_i = -n * T_i * (dn_i / n_i) / (z_i * g.psio) + omega_hat_pi = 1 + (dT_i / T_i) / (dn_i / n_i) + omega = prob.omega_factor * n * (loaded.has_omega_E ? Float64(loaded.profiles.omega_E(psi)) : 0.0) + omega_hat = something(prob.omega_ratio, omega / omega_star_i) + + dp_mn = real(ffs.delta_prime.matrix[k, k]) + for (j, wj) in enumerate(w) + wd = transport_width(wj; prob.c_wd, chi_perp, v_te, R0=g.R_major, g.q, g.q1, n, m, g.Wc_prefac) + cl = D_I < 0 ? classical_term(dp_mn, D_I, wj; model=prob.delta_prime_model) : NaN + bs = bootstrap_term(D_nc, wd, wj; prob.k1) + cv = D_I < 0 ? curvature_term(D_R, rg.H, D_I, wd, wj; prob.k1) : NaN + pl = prob.pol_sign * (prob.pol_model === :full ? + polarization_term(wj; prob.c_pol, g.eps, rho_theta_over_rs=rho_theta / g.r_minor, psi_s=psi, beta_p, + gradient_ratio, omega_hat, omega_hat_pi, w_ib) : + reduced_polarization_term(wj; prob.c_pol, prob.C_pol, w_ib)) + mats[:w_d][k, j] = wd + mats[:classical][k, j], mats[:bootstrap][k, j], mats[:curvature][k, j], mats[:polarization][k, j] = cl, bs, cv, pl + mats[:F][k, j] = prob.classical * cl + prob.bootstrap * bs + prob.curvature * cv + prob.polarization * pl + end + for (key, val) in pairs((; psi, q=s.q, m, n, delta_prime=dp_mn, D_I, D_R, H=rg.H, D_nc, f_trap=g.f_trap, mu_ratio, zeff, + g.Wc_prefac, chi_perp, v_te, R0=g.R_major, omega_star_i, omega_hat, omega_hat_pi, beta_p, rho_theta, w_ib, g.eps)) + getfield(out, key)[k] = val + end + end + return ModifiedRutherfordResult(prob, collect(w), out.psi, out.q, out.m, out.n, out.delta_prime, out.D_I, out.D_R, out.H, + out.D_nc, out.f_trap, out.mu_ratio, out.zeff, out.Wc_prefac, out.chi_perp, out.v_te, out.R0, out.omega_star_i, + out.omega_hat, out.omega_hat_pi, out.beta_p, out.rho_theta, out.w_ib, out.eps, + mats[:F], mats[:classical], mats[:bootstrap], mats[:curvature], mats[:polarization], mats[:w_d]) +end diff --git a/src/Tearing/ModifiedRutherford/Terms.jl b/src/Tearing/ModifiedRutherford/Terms.jl new file mode 100644 index 000000000..eb0dc23f6 --- /dev/null +++ b/src/Tearing/ModifiedRutherford/Terms.jl @@ -0,0 +1,68 @@ +# Terms.jl +# +# The terms of the steady modified Rutherford equation F(w) as pure functions of the full island width w in ψ_N +# (Benjamin et al. 2026 Eqs. 7, 10-14 and C.1; Leuthold et al. 2026 Eq. 4.6). Surface quantities are arguments, +# so each term can be checked alone. + +""" + classical_term(delta_prime, D_I, w; model=:mercier_power) -> Float64 + +Classical drive with the finite-pressure correction, `Δ′(w) = Δ′_mn (w/2)^(−2α₋) (−4D_I)^(1/2)` with +`α₋ = ½ − (−D_I)^(1/2)` (Benjamin Eq. 10); it reduces to `Δ′_mn` at `D_I = −¼`. `model = :constant` returns `Δ′_mn`. +""" +function classical_term(delta_prime::Real, D_I::Real, w::Real; model::Symbol=:mercier_power) + model === :constant && return Float64(delta_prime) + alpha_minus = 0.5 - sqrt(-D_I) + return delta_prime * (w / 2)^(-2 * alpha_minus) * sqrt(-4 * D_I) +end + +""" + bootstrap_term(D_nc, w_d, w; k1=1.7) -> Float64 + +Bootstrap drive with the transport threshold, `k₁ D_nc w / (w² + (k₁/0.5) w_d²)` (Benjamin Eq. 11): tends to +`k₁ D_nc / w` for `w ≫ w_d` and to `0.5 D_nc w / w_d²` for `w ≪ w_d`. +""" +bootstrap_term(D_nc::Real, w_d::Real, w::Real; k1::Real=1.7) = k1 * D_nc * w / (w^2 + (k1 / 0.5) * w_d^2) + +""" + curvature_term(D_R, H, D_I, w_d, w; k1=1.7) -> Float64 + +Glasser-Greene-Johnson curvature term, `[k₁ D_R/(α₊ − H)] / [w + k₁ w_d/(0.3(1 + α₊))]` with +`α₊ = ½ + (−D_I)^(1/2)` (Benjamin Eq. 12); stabilizing for `D_R < 0`. +""" +function curvature_term(D_R::Real, H::Real, D_I::Real, w_d::Real, w::Real; k1::Real=1.7) + alpha_plus = 0.5 + sqrt(-D_I) + return (k1 * D_R / (alpha_plus - H)) / (w + k1 * w_d / (0.3 * (1 + alpha_plus))) +end + +""" + polarization_term(w; c_pol, eps, rho_theta_over_rs, psi_s, beta_p, gradient_ratio, omega_hat, omega_hat_pi, w_ib) -> Float64 + +Ion-polarization term of Benjamin Eq. C.1, +`−c_pol 3.28 ε^(3/2) I₁ [(ρ_iθ/r_s)/(w/(2ψ_s))]² (β_p/w) G² ω̂(ω̂ − ω̂_pi) w⁴/(w⁴ + w_ib⁴)`, with `I₁ = 0.71`, +`G = (n_i⁻¹ dn_i/dψ)/(q⁻¹ dq/dψ)` and the island frequency `ω̂ = ω/ω_*i` and `ω̂_pi = ω_*pi/ω_*i` normalized to the +ion diamagnetic frequency. Zero at `ω̂ = 0` and at `ω̂ = ω̂_pi`; stabilizing for `ω̂(ω̂ − ω̂_pi) > 0`. +""" +function polarization_term(w::Real; c_pol::Real, eps::Real, rho_theta_over_rs::Real, psi_s::Real, beta_p::Real, + gradient_ratio::Real, omega_hat::Real, omega_hat_pi::Real, w_ib::Real) + return -c_pol * 3.28 * eps^1.5 * 0.71 * (rho_theta_over_rs / (w / (2 * psi_s)))^2 * (beta_p / w) * gradient_ratio^2 * + omega_hat * (omega_hat - omega_hat_pi) * w^4 / (w^4 + w_ib^4) +end + +""" + reduced_polarization_term(w; c_pol, C_pol, w_ib) -> Float64 + +Reduced polarization term `−c_pol w_pol²/w³` with `w_pol = C_pol w_ib`; it carries no island frequency. +""" +reduced_polarization_term(w::Real; c_pol::Real, C_pol::Real, w_ib::Real) = -c_pol * (C_pol * w_ib)^2 / w^3 + +""" + transport_width(w; c_wd, chi_perp, v_te, R0, q, q1, n, m, Wc_prefac) -> Float64 + +Transport threshold width `w_d = c_wd [(χ⊥/χ∥(w)) W_c/m²]^(1/4)` in ψ_N (Benjamin Eq. 14 in the Schlutt & Hegna flux +form, `W_c` from [`mre_geometry`](@ref)), with `χ∥(w) = v_Te λ∥(w)` and `λ∥(w) = R₀ q/(n |dq/dψ| w)` (Benjamin p. 4). +""" +function transport_width(w::Real; c_wd::Real, chi_perp::Real, v_te::Real, R0::Real, q::Real, q1::Real, n::Integer, m::Integer, Wc_prefac::Real) + chi_par = v_te * R0 * abs(q) / (abs(n * q1) * w) + return c_wd * ((chi_perp / chi_par) * abs(Wc_prefac) / m^2)^0.25 +end diff --git a/src/Tearing/Tearing.jl b/src/Tearing/Tearing.jl index 0055cb273..a3cdf56b1 100644 --- a/src/Tearing/Tearing.jl +++ b/src/Tearing/Tearing.jl @@ -6,6 +6,8 @@ # extraction (consumes any InnerLayerModel) # CriticalResonantField -- real-Q torque-balance scan for the critical # resonant field of error-field penetration +# ModifiedRutherford -- terms of the steady modified Rutherford equation and +# F(w) per surface, `solve(ModifiedRutherfordProblem(ffs; ...))` # TearingProblem / TearingConfig / TearingResult -- the public solve, # `solve(TearingProblem(ffs; ...), model; config)`, with one # method per inner-layer model (SLAYERModel, GGJModel) @@ -39,9 +41,12 @@ using .InnerLayer.SLAYER: _slayer_profiles, _layer_zeff, _layer_ion_mass, _layer include("LayerOverlap.jl") include("Dispersion/Dispersion.jl") include("CriticalResonantField/CriticalResonantField.jl") +include("ModifiedRutherford/ModifiedRutherford.jl") import .Dispersion as Dispersion import .CriticalResonantField as CriticalResonantField +import .ModifiedRutherford as ModifiedRutherford +using .ModifiedRutherford: ModifiedRutherfordProblem, ModifiedRutherfordResult using .Dispersion: SurfaceCoupling, surface_coupling, MultiSurfaceCoupling, multi_surface_coupling, ScanResult, brute_force_scan, @@ -57,7 +62,8 @@ include("SLAYERTearing.jl") include("GGJTearing.jl") include("HDF5Output.jl") -export InnerLayer, Dispersion, CriticalResonantField +export InnerLayer, Dispersion, CriticalResonantField, ModifiedRutherford +export ModifiedRutherfordProblem, ModifiedRutherfordResult export resistive_layer_overlap, layer_overlap_scan, LayerOverlapScan export TearingProblem, TearingConfig, TearingResult export run_slayer_from_inputs diff --git a/src/Utilities/Output.jl b/src/Utilities/Output.jl index bf75c28f8..c3f9f11a5 100644 --- a/src/Utilities/Output.jl +++ b/src/Utilities/Output.jl @@ -29,12 +29,16 @@ function deck_sections end record_inputs!(target, result) Merge the deck sections of `result` into the run record `Input/gpec_toml_raw` of `target`, replacing any section of -the same name, so every saved result adds the deck it was computed from. +the same name (a section stated only as sub-tables joins it instead), so every saved result adds the deck it was computed from. """ function record_inputs!(target::Union{HDF5.File,HDF5.Group}, result) path = "Input/gpec_toml_raw" deck = haskey(target, path) ? TOML.parse(read(target[path])) : Dict{String,Any}() - merge!(deck, deck_sections(result)) + # A section stated only as sub-tables (e.g. [Tearing.ModifiedRutherford]) joins the recorded section; any other replaces it. + for (k, v) in deck_sections(result) + subtables_only = v isa AbstractDict && all(x -> x isa AbstractDict, values(v)) + deck[k] = subtables_only && get(deck, k, nothing) isa AbstractDict ? merge(deck[k], v) : v + end haskey(target, path) && HDF5.delete_object(target, path) target[path] = sprint(TOML.print, deck) return nothing diff --git a/test/runtests_modified_rutherford.jl b/test/runtests_modified_rutherford.jl new file mode 100644 index 000000000..4fa055a79 --- /dev/null +++ b/test/runtests_modified_rutherford.jl @@ -0,0 +1,63 @@ +using TOML + +# The modified Rutherford terms (physics.md of the mre-islands work order): the limits and signs each term must show +# alone, and the surface geometry against Fortran RDCON (MRE_flag output) on the DIII-D-like example. +@testset "ModifiedRutherford terms" begin + MR = GeneralizedPerturbedEquilibrium.ModifiedRutherford + + @testset "classical term: Mercier power and its zero-pressure limit" begin + @test MR.classical_term(-3.0, -0.25, 0.01) ≈ -3.0 + @test MR.classical_term(-3.0, -0.3, 0.01; model=:constant) == -3.0 + # Slope of log Δ′(w) is −2α₋ = 2√(−D_I) − 1. + D_I = -0.3 + slope = log(MR.classical_term(2.0, D_I, 0.02) / MR.classical_term(2.0, D_I, 0.01)) / log(2) + @test slope ≈ 2 * sqrt(-D_I) - 1 rtol = 1e-12 + end + + @testset "bootstrap term: positive, peaked, k1·D_nc/w far above w_d" begin + D_nc, w_d, k1 = 0.4, 1e-3, 1.7 + w = exp.(range(log(1e-5), log(0.2); length=400)) + b = MR.bootstrap_term.(D_nc, w_d, w; k1) + @test all(>(0), b) + @test w[argmax(b)] ≈ sqrt(k1 / 0.5) * w_d rtol = 0.02 + @test MR.bootstrap_term(D_nc, w_d, 1e3 * w_d; k1) ≈ k1 * D_nc / (1e3 * w_d) rtol = 1e-5 + @test MR.bootstrap_term(D_nc, w_d, 1e-3 * w_d; k1) ≈ 0.5 * D_nc * 1e-3 * w_d / w_d^2 rtol = 1e-5 + end + + @testset "curvature term: stabilizing for D_R < 0" begin + @test MR.curvature_term(-0.02, 0.01, -0.27, 1e-3, 0.01) < 0 + @test MR.curvature_term(0.02, 0.01, -0.27, 1e-3, 0.01) > 0 + end + + @testset "polarization term: sign follows ω̂(ω̂ − ω̂_pi), zero at ω̂ = 0" begin + args = (; c_pol=1.0, eps=0.2, rho_theta_over_rs=0.01, psi_s=0.6, beta_p=0.5, gradient_ratio=-0.8, omega_hat_pi=1.5, w_ib=1e-3) + pol(ω̂) = MR.polarization_term(0.02; args..., omega_hat=ω̂) + @test pol(0.0) == 0 + @test pol(1.5) == 0 + @test pol(0.7) > 0 # ω̂ inside (0, ω̂_pi): destabilizing + @test pol(2.0) < 0 && pol(-1.0) < 0 + @test MR.reduced_polarization_term(0.01; c_pol=1.0, C_pol=1.9, w_ib=1e-3) ≈ -(1.9e-3)^2 / 1e-6 + end + + @testset "transport width grows as w^(1/4)" begin + kw = (; c_wd=1.0, chi_perp=1.0, v_te=5e7, R0=1.7, q=2.0, q1=3.0, n=1, m=2, Wc_prefac=3e4) + @test MR.transport_width(16e-3; kw...) / MR.transport_width(1e-3; kw...) ≈ 2 rtol = 1e-12 + end + + @testset "surface geometry matches Fortran RDCON on the DIII-D-like example" begin + # Fortran values at q = 2 (ψ_N = 0.5183), interpolated from rdcon_output_n1.nc with MRE_flag = t and Z_eff = 1.52. + dir = joinpath(@__DIR__, "..", "examples", "DIIID-like_gal_resistive_example") + deck = TOML.parsefile(joinpath(dir, "gpec.toml")) + equil = GeneralizedPerturbedEquilibrium.Equilibrium.PlasmaEquilibrium(GeneralizedPerturbedEquilibrium.equilibrium_config(deck, dir), + GeneralizedPerturbedEquilibrium.analytic_config(deck)) + psi = 0.51830565 + g = MR.mre_geometry(equil, psi, equil.profiles.q_deriv(psi)) + @test g.f_trap ≈ 0.61419 rtol = 2e-3 + @test g.overbar_Rsq ≈ 3.0113 rtol = 2e-3 + @test g.avg_bpol ≈ 0.28709 rtol = 2e-3 + @test g.Wc_prefac ≈ 33565 rtol = 2e-3 + D_nc, mu_ratio = MR.bootstrap_coefficient(g, 1.52) + @test mu_ratio ≈ 0.68255 rtol = 2e-3 + @test D_nc ≈ 0.38065 rtol = 2e-3 + end +end diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index 0b03b4c30..2b17e2828 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -560,6 +560,43 @@ using HDF5 end end + @testset "modified Rutherford under [Tearing.ModifiedRutherford]" begin + # The MRE alone: a [Tearing] holding only the sub-table runs no layer solve, and its deck replays. + settings = (; polarization=true, omega_factor=0.5, nw=40) + mktempdir() do dir + psi = collect(range(0.0, 1.0; length=101)) + h5open(joinpath(dir, "kinetic.h5"), "w") do f + f["psi"] = psi + f["n_e"] = f["n_i"] = 5e19 .* (1 .- 0.8 .* psi) + f["T_e"] = f["T_i"] = 2e3 .* (1 .- 0.9 .* psi) + f["omega_E"] = 1e4 .* (1 .- psi) + end + ric = solve(attach_kinetic_profiles!(deepcopy(equil), joinpath(dir, "kinetic.h5")), riccati; nn=1, wall=wall, ffs_kwargs...) + api = solve(ModifiedRutherfordProblem(ric; settings...)) + deck_dir = _stage_deck(dir; eq_overrides=Dict{String,Any}("kinetic_file" => "kinetic.h5"), + ffs_overrides=Dict{String,Any}("integrator" => "Riccati"), + extra_sections=Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => Dict{String,Any}(String(k) => v for (k, v) in pairs(settings))))) + reference = GPEC.main([deck_dir]) + + @test reference.tearing === nothing + @test api.F ≈ reference.modified_rutherford.F rtol = 1e-10 + @test api.polarization ≈ reference.modified_rutherford.polarization rtol = 1e-10 + @test all(<(0), api.D_R) && all(isfinite, api.F) + h5open(joinpath(dir, "gpec.h5")) do f + @test read(f["Tearing/ModifiedRutherford/Terms/F"]) ≈ api.F rtol = 1e-12 + recorded = TOML.parse(read(f["Input/gpec_toml_raw"])) + @test recorded["Tearing"] == Dict{String,Any}("ModifiedRutherford" => recorded["Tearing"]["ModifiedRutherford"]) + @test recorded["Tearing"]["ModifiedRutherford"]["omega_factor"] == 0.5 + end + end + + # A forward solve has no Δ′, so the deck stops at parse time. + mktempdir() do dir + deck_dir = _stage_deck(dir; extra_sections=Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => Dict{String,Any}()))) + @test_throws "needs Δ′" GPEC.parse_deck(TOML.parsefile(joinpath(deck_dir, "gpec.toml")), deck_dir) + end + end + @testset "kinetic_format" begin # A rates-v1 kinetic file read through the deck's [Equilibrium] keys and through the API keyword. mktempdir() do dir diff --git a/test/suites.jl b/test/suites.jl index dfe79f9e4..f584bf4cf 100644 --- a/test/suites.jl +++ b/test/suites.jl @@ -47,6 +47,7 @@ const TEST_SUITES = [ "runtests_dispersion_winding.jl" => "errorfields", "runtests_dispersion_ggj.jl" => "errorfields", "runtests_critical_resonant_field.jl" => "errorfields", + "runtests_modified_rutherford.jl" => "errorfields", "runtests_slayer_runner.jl" => "errorfields", "runtests_kinetic.jl" => "core", "runtests_kinetic_validity.jl" => "core", From acc86617d922d6c724ed5ca71fd8eb452f1941a4 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Fri, 6 Nov 2026 23:59:48 -0500 Subject: [PATCH 02/26] Tearing.ModifiedRutherford - DOCS - Document the modified Rutherford diagnostics and format the submodule Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- docs/src/inner_layer.md | 17 ++++++++++++ src/Tearing/ModifiedRutherford/Geometry.jl | 32 +++++++++++----------- src/Tearing/ModifiedRutherford/Problem.jl | 11 +++++--- 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/docs/src/inner_layer.md b/docs/src/inner_layer.md index e67b984ff..37295c45a 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -103,6 +103,17 @@ The DIII-D-like kinetic file of the examples carries analytic χ profiles of the | `Tearing/Diagnostics/ValidRoots`, `Poles`, `FilteredRoots` | the root classification of the scan | | `Tearing/Scan/Surface_/` | the scanned Δ(Q) with `store_scan = true` | | `Tearing/CriticalResonantField/br_crit`, `br_crit_tesla`, `Q_peak`, `Q0` | the penetration threshold as ``b_r/B_\phi`` and in tesla, the Q of the balance maximum and the natural rotation | +| `Tearing/ModifiedRutherford/w`, `Terms/F`, `Terms/`, `Terms/w_d`, `PerSurface/` | the modified Rutherford width grid, ``F(w)`` and each switched term per surface, the transport width, and the surface inputs | + +## Modified Rutherford diagnostics + +`[Tearing.ModifiedRutherford]` evaluates the steady modified Rutherford equation on a grid of full island widths ``w`` (in ``\psi_N``) for each rational surface of a single-``n`` Riccati or Galerkin run with Δ′: + +```math +F(w) = \Delta'(w) + \Delta_\mathrm{nc}(w) + \Delta_\mathrm{GGJ}(w) + \Delta_\mathrm{pol}(w; \omega), +``` + +the classical drive with its finite-pressure power law, the bootstrap drive with the transport threshold ``w_d``, the Glasser–Greene–Johnson curvature term and the ion-polarization term (Benjamin et al. 2026, Eqs. 7, 10–14 and C.1). Each term has a switch and a coefficient; the polarization term is off by default. The surface geometry follows the Fortran RDCON `mercier.f` averages, and the densities, temperatures, ``\omega_E``, ``Z_\mathrm{eff}`` and ``\chi_\perp`` come from the equilibrium's kinetic profiles. A `[Tearing]` section holding only this sub-table runs no layer solve. In a script, `solve(ModifiedRutherfordProblem(ffs; polarization=true))` returns the same [`ModifiedRutherfordResult`](@ref Tearing.ModifiedRutherford.ModifiedRutherfordResult). ## Numerical notes @@ -175,3 +186,9 @@ Modules = [GeneralizedPerturbedEquilibrium.Tearing.Dispersion] ```@autodocs Modules = [GeneralizedPerturbedEquilibrium.Tearing.CriticalResonantField] ``` + +### ModifiedRutherford + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Tearing.ModifiedRutherford] +``` diff --git a/src/Tearing/ModifiedRutherford/Geometry.jl b/src/Tearing/ModifiedRutherford/Geometry.jl index c35eafdaf..79937ffa6 100644 --- a/src/Tearing/ModifiedRutherford/Geometry.jl +++ b/src/Tearing/ModifiedRutherford/Geometry.jl @@ -12,22 +12,22 @@ Per-surface geometry of the modified Rutherford terms. Averages ⟨·⟩ are normalized flux-surface averages (⟨1⟩ = 1) and `ψ_N` is normalized poloidal flux, as in [`ResistGeometry`](@ref GeneralizedPerturbedEquilibrium.ForceFreeStates.ResistGeometry). -| field | meaning | -|:--------------------- |:------------------------------------------------------------------------ | -| `q`, `q1` | safety factor and dq/dψ_N | -| `p1` | μ₀ dp/dψ_N | -| `v1` | dV/dψ_N [m³] | -| `psio` | poloidal flux per radian at the boundary [Wb] | -| `avg_bsq_over_dpsisq` | ⟨B²/‖∇ψ_N‖²⟩ | -| `avg_bsq` | ⟨B²⟩ [T²] | -| `avg_dpsisq` | ⟨‖∇ψ_N‖²⟩ | -| `overbar_Rsq` | Hegna 1999 R̄² = ⟨R² V′/𝒥⟩ [m²] | -| `avg_bpol`, `avg_bpolsq` | ⟨B_pol⟩ [T] and ⟨B_pol²⟩ [T²] | -| `r_minor` | ⟨r⟩, mean distance from the magnetic axis [m] | -| `R_major`, `eps` | (R_max + R_min)/2 [m] and (R_max − R_min)/(R_max + R_min) | -| `delta` | triangularity, mean of the upper and lower values | -| `f_trap` | Sauter 2002 trapped fraction with the triangularity-corrected ε_eff | -| `Wc_prefac` | Schlutt & Hegna 2012 w_d⁴ prefactor in ψ_N⁴, before χ⊥/χ∥ and 1/m² | +| field | meaning | +|:------------------------ |:------------------------------------------------------------------- | +| `q`, `q1` | safety factor and dq/dψ_N | +| `p1` | μ₀ dp/dψ_N | +| `v1` | dV/dψ_N [m³] | +| `psio` | poloidal flux per radian at the boundary [Wb] | +| `avg_bsq_over_dpsisq` | ⟨B²/‖∇ψ_N‖²⟩ | +| `avg_bsq` | ⟨B²⟩ [T²] | +| `avg_dpsisq` | ⟨‖∇ψ_N‖²⟩ | +| `overbar_Rsq` | Hegna 1999 R̄² = ⟨R² V′/𝒥⟩ [m²] | +| `avg_bpol`, `avg_bpolsq` | ⟨B_pol⟩ [T] and ⟨B_pol²⟩ [T²] | +| `r_minor` | ⟨r⟩, mean distance from the magnetic axis [m] | +| `R_major`, `eps` | (R_max + R_min)/2 [m] and (R_max − R_min)/(R_max + R_min) | +| `delta` | triangularity, mean of the upper and lower values | +| `f_trap` | Sauter 2002 trapped fraction with the triangularity-corrected ε_eff | +| `Wc_prefac` | Schlutt & Hegna 2012 w_d⁴ prefactor in ψ_N⁴, before χ⊥/χ∥ and 1/m² | """ struct MRESurfaceGeometry q::Float64 diff --git a/src/Tearing/ModifiedRutherford/Problem.jl b/src/Tearing/ModifiedRutherford/Problem.jl index beb6eb8ef..9884d8974 100644 --- a/src/Tearing/ModifiedRutherford/Problem.jl +++ b/src/Tearing/ModifiedRutherford/Problem.jl @@ -192,10 +192,13 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) cl = D_I < 0 ? classical_term(dp_mn, D_I, wj; model=prob.delta_prime_model) : NaN bs = bootstrap_term(D_nc, wd, wj; prob.k1) cv = D_I < 0 ? curvature_term(D_R, rg.H, D_I, wd, wj; prob.k1) : NaN - pl = prob.pol_sign * (prob.pol_model === :full ? - polarization_term(wj; prob.c_pol, g.eps, rho_theta_over_rs=rho_theta / g.r_minor, psi_s=psi, beta_p, - gradient_ratio, omega_hat, omega_hat_pi, w_ib) : - reduced_polarization_term(wj; prob.c_pol, prob.C_pol, w_ib)) + pl = + prob.pol_sign * ( + prob.pol_model === :full ? + polarization_term(wj; prob.c_pol, g.eps, rho_theta_over_rs=rho_theta / g.r_minor, psi_s=psi, beta_p, + gradient_ratio, omega_hat, omega_hat_pi, w_ib) : + reduced_polarization_term(wj; prob.c_pol, prob.C_pol, w_ib) + ) mats[:w_d][k, j] = wd mats[:classical][k, j], mats[:bootstrap][k, j], mats[:curvature][k, j], mats[:polarization][k, j] = cl, bs, cv, pl mats[:F][k, j] = prob.classical * cl + prob.bootstrap * bs + prob.curvature * cv + prob.polarization * pl From 98967899bf33e3097ff83274901d4c13ac517345 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 7 Nov 2026 00:06:55 -0500 Subject: [PATCH 03/26] Deck - MINOR - Skip the layer solve only when [Tearing] holds just the ModifiedRutherford sub-table Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- src/Deck.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Deck.jl b/src/Deck.jl index bd83498a0..c3c0a7715 100644 --- a/src/Deck.jl +++ b/src/Deck.jl @@ -362,7 +362,7 @@ function tearing_from_deck(inputs::AbstractDict) haskey(inputs, "Tearing") || return nothing # A [Tearing] holding only [Tearing.ModifiedRutherford] evaluates the MRE without a layer solve. table = _without(inputs["Tearing"], "ModifiedRutherford") - isempty(table) && return nothing + isempty(table) && haskey(inputs["Tearing"], "ModifiedRutherford") && return nothing get(table, "omega_E_kHz", Dict{String,Any}()) isa AbstractDict || throw(ArgumentError("[Tearing] omega_E_kHz must be a table keyed by m/n, e.g. omega_E_kHz = { \"2/1\" = 3.0 }")) fields = _struct_fields(TearingProblem, (:ffs, :critical_resonant_field)) From 5d03e6ec489d2f38b5068b82fd56f823fb4903e7 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 7 Nov 2026 15:37:49 -0500 Subject: [PATCH 04/26] Tearing.ModifiedRutherford - FEATURE - Add MRE roots, validity flags, linear gate and Callen preset The modified Rutherford solve now returns the seed threshold w_m and saturated width w_sat per surface (grid brackets refined by Brent), their minimum w_m_star, and validity flags (fully stabilized, unstable to any seed, resistive interchange, linear tearing, below w_ib, large Delta' w, complex Delta'). A [Tearing] layer solve in the same deck gates the roots and sets the lower end of the search from its layer width (API keyword `tearing`). The island frequency defaults to omega_*e (1 + eta_e/4) (omega_model), and delta_prime_factor, D_nc_factor and rho_theta_factor support scans. [Tearing.Callen] / CallenProblem(pe) adds Callen's reduced equation for RMP-driven islands on a coil-driven perturbed equilibrium: critical vacuum half-width, critical resonant field and the cubic roots, with the vacuum resonant field from the coil harmonic on each rational surface. The roots solve the cubic exactly, twice the Fortran 1.6.0 values. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- docs/src/inner_layer.md | 12 ++ src/Deck.jl | 32 +++- src/GeneralizedPerturbedEquilibrium.jl | 19 ++- src/Tearing/ModifiedRutherford/Callen.jl | 154 +++++++++++++++++ src/Tearing/ModifiedRutherford/HDF5Output.jl | 72 +++++++- .../ModifiedRutherford/ModifiedRutherford.jl | 5 +- src/Tearing/ModifiedRutherford/Problem.jl | 160 +++++++++++++++--- src/Tearing/Tearing.jl | 4 +- test/runtests_modified_rutherford.jl | 24 +++ test/runtests_solve_api.jl | 29 +++- 10 files changed, 464 insertions(+), 47 deletions(-) create mode 100644 src/Tearing/ModifiedRutherford/Callen.jl diff --git a/docs/src/inner_layer.md b/docs/src/inner_layer.md index 37295c45a..e5f3e2239 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -104,6 +104,8 @@ The DIII-D-like kinetic file of the examples carries analytic χ profiles of the | `Tearing/Scan/Surface_/` | the scanned Δ(Q) with `store_scan = true` | | `Tearing/CriticalResonantField/br_crit`, `br_crit_tesla`, `Q_peak`, `Q0` | the penetration threshold as ``b_r/B_\phi`` and in tesla, the Q of the balance maximum and the natural rotation | | `Tearing/ModifiedRutherford/w`, `Terms/F`, `Terms/`, `Terms/w_d`, `PerSurface/` | the modified Rutherford width grid, ``F(w)`` and each switched term per surface, the transport width, and the surface inputs | +| `Tearing/ModifiedRutherford/w_m_star`, `PerSurface/w_m`, `PerSurface/w_sat`, `PerSurface/` | the seed threshold and saturated widths per surface, their minimum over the surfaces, and the validity and gate flags (0/1) | +| `Tearing/Callen/` | Callen's critical vacuum island width, critical resonant field and cubic roots per resonant row | ## Modified Rutherford diagnostics @@ -115,6 +117,16 @@ F(w) = \Delta'(w) + \Delta_\mathrm{nc}(w) + \Delta_\mathrm{GGJ}(w) + \Delta_\mat the classical drive with its finite-pressure power law, the bootstrap drive with the transport threshold ``w_d``, the Glasser–Greene–Johnson curvature term and the ion-polarization term (Benjamin et al. 2026, Eqs. 7, 10–14 and C.1). Each term has a switch and a coefficient; the polarization term is off by default. The surface geometry follows the Fortran RDCON `mercier.f` averages, and the densities, temperatures, ``\omega_E``, ``Z_\mathrm{eff}`` and ``\chi_\perp`` come from the equilibrium's kinetic profiles. A `[Tearing]` section holding only this sub-table runs no layer solve. In a script, `solve(ModifiedRutherfordProblem(ffs; polarization=true))` returns the same [`ModifiedRutherfordResult`](@ref Tearing.ModifiedRutherford.ModifiedRutherfordResult). +The roots of ``F`` are bracketed on the grid and refined by Brent's method: the seed threshold ``w_m`` is the smallest crossing from ``F<0`` to ``F>0`` and the saturated width ``w_\mathrm{sat}`` the largest from ``F>0`` to ``F<0``. +When the deck also has a `[Tearing]` layer solve, its uncoupled growth rates gate the roots (a surface with ``\gamma>0`` is linearly unstable and gets none) and its resistive layer width sets the lower end of the search; in a script, pass it as `ModifiedRutherfordProblem(ffs; tearing)`. +Surfaces with ``D_R>0`` are excluded as resistive-interchange unstable, and roots below the ion banana width or with ``|\Delta'|w\ge1`` are flagged. +The island frequency in ``\Delta_\mathrm{pol}`` is ``\omega_{*e}(1+\eta_e/4)`` (Wilson 1996) by default, or the lab-frame ``n\omega_E`` with `omega_model = "exb"`. + +`[Tearing.Callen]` evaluates Callen's reduced equation for RMP-driven islands on a single-``n`` perturbed equilibrium driven by coils, in the form of Fortran GPEC: the critical vacuum island half-width, the critical vacuum resonant field and the two positive roots of the cubic ``\Delta_c x^3 + \Delta'_\mathrm{RMP}(2h_v)^2x - w_\mathrm{pol}^2 = 0``. +The vacuum resonant field is the coils' resonant harmonic on the rational surface over its area. +The roots are the exact roots of the cubic; Fortran GPEC 1.6.0 reports half of each, from a trigonometric solution missing its factor 2. +In a script, `solve(CallenProblem(pe))` returns the same [`CallenResult`](@ref Tearing.ModifiedRutherford.CallenResult). + ## Numerical notes The `GGJ` model exposes three solvers through the `solver` type parameter of [`GGJModel`](@ref InnerLayer.GGJModel), all returning ``\Delta`` in the same convention: diff --git a/src/Deck.jl b/src/Deck.jl index c3c0a7715..19d17bffe 100644 --- a/src/Deck.jl +++ b/src/Deck.jl @@ -360,9 +360,9 @@ end """ function tearing_from_deck(inputs::AbstractDict) haskey(inputs, "Tearing") || return nothing - # A [Tearing] holding only [Tearing.ModifiedRutherford] evaluates the MRE without a layer solve. - table = _without(inputs["Tearing"], "ModifiedRutherford") - isempty(table) && haskey(inputs["Tearing"], "ModifiedRutherford") && return nothing + # A [Tearing] holding only [Tearing.ModifiedRutherford] or [Tearing.Callen] runs those without a layer solve. + table = _without(inputs["Tearing"], "ModifiedRutherford", "Callen") + isempty(table) && (haskey(inputs["Tearing"], "ModifiedRutherford") || haskey(inputs["Tearing"], "Callen")) && return nothing get(table, "omega_E_kHz", Dict{String,Any}()) isa AbstractDict || throw(ArgumentError("[Tearing] omega_E_kHz must be a table keyed by m/n, e.g. omega_E_kHz = { \"2/1\" = 3.0 }")) fields = _struct_fields(TearingProblem, (:ffs, :critical_resonant_field)) @@ -386,7 +386,18 @@ The `ModifiedRutherfordProblem` keywords of `[Tearing.ModifiedRutherford]`; `not """ function modified_rutherford_from_deck(inputs::AbstractDict) haskey(inputs, "Tearing") && haskey(inputs["Tearing"], "ModifiedRutherford") || return nothing - return section_keywords(inputs["Tearing"]["ModifiedRutherford"], "Tearing.ModifiedRutherford", _struct_fields(ModifiedRutherfordProblem, (:ffs,))) + return section_keywords(inputs["Tearing"]["ModifiedRutherford"], "Tearing.ModifiedRutherford", _struct_fields(ModifiedRutherfordProblem, (:ffs, :tearing))) +end + +""" + callen_from_deck(inputs) -> Bool + +Whether `[Tearing.Callen]` is present; it takes no keys. +""" +function callen_from_deck(inputs::AbstractDict) + haskey(inputs, "Tearing") && haskey(inputs["Tearing"], "Callen") || return false + section_keywords(inputs["Tearing"]["Callen"], "Tearing.Callen", _struct_fields(CallenProblem, (:pe,))) + return true end """ @@ -496,6 +507,13 @@ function parse_deck(inputs::AbstractDict, path::AbstractString) end perturbed_equilibrium = perturbed_equilibrium_from_deck(inputs) forcing = forcing_terms_control(inputs, path) + callen = callen_from_deck(inputs) + if callen + perturbed_equilibrium === nothing && error("[Tearing.Callen] needs a [PerturbedEquilibrium] section") + forcing.forcing_data_format == "coil" || error("[Tearing.Callen] needs a coil forcing: set [ForcingTerms] forcing_data_format = \"coil\"") + isempty(get(get(inputs, "Equilibrium", Dict{String,Any}()), "kinetic_file", "")) && + error("[Tearing.Callen] needs kinetic profiles: set kinetic_file in [Equilibrium]") + end if haskey(inputs, "ErrorFields") perturbed_equilibrium === nothing && error("[ErrorFields] needs a [PerturbedEquilibrium] section") forcing.forcing_data_format == "coil" || @@ -506,7 +524,7 @@ function parse_deck(inputs::AbstractDict, path::AbstractString) error("[GGJ] is set but neither [Match] nor [Tearing] inner_model = \"GGJ\" uses it") haskey(inputs, "SLAYER") && tearing_model != "SLAYER" && !(has_ffs && get(inputs["ForceFreeStates"], "psilim_from_layer_overlap", false) === true) && error("[SLAYER] is set but neither [Tearing] inner_model = \"SLAYER\" nor [ForceFreeStates] psilim_from_layer_overlap = true uses it") - return (; run=run_settings(inputs, path), force_balance, local_stability=local_stability_from_deck(inputs), match, tearing, modified_rutherford, + return (; run=run_settings(inputs, path), force_balance, local_stability=local_stability_from_deck(inputs), match, tearing, modified_rutherford, callen, kinetic_forces=haskey(inputs, "KineticForces") ? kinetic_forces_control(inputs) : nothing, perturbed_equilibrium, forcing, error_fields=error_fields_from_deck(inputs, path)) end @@ -603,6 +621,7 @@ function deck_inputs(deck::NamedTuple, config::Equilibrium.EquilibriumConfig, an end mre = deck.modified_rutherford mre === nothing || (get!(inputs, "Tearing", Dict{String,Any}())["ModifiedRutherford"] = _deck_table(mre)) + deck.callen && (get!(inputs, "Tearing", Dict{String,Any}())["Callen"] = Dict{String,Any}()) deck.kinetic_forces === nothing || merge!(inputs, deck_sections(deck.kinetic_forces)) pe = deck.perturbed_equilibrium pe === nothing || (inputs["PerturbedEquilibrium"] = _deck_table(merge(pe.response, pe.drive))) @@ -651,7 +670,8 @@ deck_sections(m::ForceFreeStates.MatchResult) = merge(deck_sections(m.model), Dict{String,Any}("Match" => merge(_deck_table(m.problem, (:ffs,)), Dict{String,Any}("inner_model" => "GGJ")))) deck_sections(kf::KineticForcesResult) = deck_sections(kf.control) deck_sections(t::TearingResult) = t.model === nothing ? Dict{String,Any}() : _tearing_sections(t.problem, t.problem.critical_resonant_field, t.model, t.config) -deck_sections(r::ModifiedRutherfordResult) = Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => _deck_table(r.problem, (:ffs,)))) +deck_sections(r::ModifiedRutherfordResult) = Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => _deck_table(r.problem, (:ffs, :tearing)))) +deck_sections(::CallenResult) = Dict{String,Any}("Tearing" => Dict{String,Any}("Callen" => Dict{String,Any}())) deck_sections(ef::ErrorFieldsResult) = _error_fields_sections(ef.settings, ef.tolerances === nothing ? "" : _RECORDED_TOLERANCES) function deck_sections(ffs::ForceFreeStatesResult) diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index adfa9b2a5..7aee845fd 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -75,7 +75,8 @@ include("Rerun.jl") using .ForceFreeStates: AbstractIntegrator, Forward, Riccati, Galerkin, TestKineticMatrices, KineticMatrices, ForceFreeStatesResult using .ForceFreeStates: MatchProblem using .InnerLayer: GGJModel, SLAYERModel -using .Tearing: TearingProblem, TearingConfig, TearingResult, CriticalResonantFieldControl, ModifiedRutherfordProblem, ModifiedRutherfordResult +using .Tearing: TearingProblem, TearingConfig, TearingResult, CriticalResonantFieldControl, ModifiedRutherfordProblem, ModifiedRutherfordResult, CallenProblem, + CallenResult using .LocalStability: LocalStabilityProblem, Ballooning, LocalStabilityResult using .Equilibrium: PlasmaEquilibrium, KineticProfileData, IonSpecies, attach_kinetic_profiles! using .ForcingTerms: RMPField @@ -158,7 +159,7 @@ function main_from_inputs( runtimes = Vector{Pair{String,Float64}}() deck = parse_deck(inputs, path) (; output_file, verbose) = deck.run - local_stability = ffs = match = resp = pe = kinetic = error_fields = tearing = modified_rutherford = nothing + local_stability = ffs = match = resp = pe = kinetic = error_fields = tearing = modified_rutherford = callen = nothing equil = _timed_stage(runtimes, "equilibrium", "Equilibrium") do Equilibrium.PlasmaEquilibrium(eq_config, additional_input) @@ -227,17 +228,25 @@ function main_from_inputs( # After the tearing stage, which replaces the whole Tearing/ group. if deck.modified_rutherford !== nothing modified_rutherford = _timed_stage(runtimes, "modified_rutherford", "Modified Rutherford") do - solve(ModifiedRutherfordProblem(ffs; deck.modified_rutherford...)) + # With [Tearing], its linear solve gates the roots on the same Δ′. + solve(ModifiedRutherfordProblem(ffs; tearing, deck.modified_rutherford...)) end save_to_hdf5(output_file, modified_rutherford) end + if deck.callen + callen = _timed_stage(runtimes, "callen", "Callen threshold") do + solve(CallenProblem(pe)) + end + save_to_hdf5(output_file, callen) + end + total_dt = time() - total_start push!(runtimes, "total" => total_dt) ffs === nothing || _write_runtimes!(output_file, runtimes) @info "\n$_BANNER\n GPEC completed successfully in $(@sprintf("%.3f", total_dt)) s\n$_BANNER" - return (; equil, local_stability, ffs, match, resp, pe, kinetic, error_fields, tearing, modified_rutherford) + return (; equil, local_stability, ffs, match, resp, pe, kinetic, error_fields, tearing, modified_rutherford, callen) end """ @@ -313,7 +322,7 @@ export main, write_imas export solve, plasma_response, perturbed_equilibrium, kinetic_torque, error_fields export KineticProfileData, IonSpecies, attach_kinetic_profiles! export PlasmaEquilibrium, ForceBalanceProblem, Forward, Riccati, Galerkin, TestKineticMatrices, KineticMatrices, kinetic_matrices, ForceFreeStatesResult, RMPField, PlasmaResponse -export MatchProblem, TearingProblem, TearingConfig, TearingResult, GGJModel, SLAYERModel, ModifiedRutherfordProblem, ModifiedRutherfordResult +export MatchProblem, TearingProblem, TearingConfig, TearingResult, GGJModel, SLAYERModel, ModifiedRutherfordProblem, ModifiedRutherfordResult, CallenProblem, CallenResult export LocalStabilityProblem, Ballooning, LocalStabilityResult export KineticForcesControl, KineticForcesResult, ErrorFieldsResult diff --git a/src/Tearing/ModifiedRutherford/Callen.jl b/src/Tearing/ModifiedRutherford/Callen.jl new file mode 100644 index 000000000..d758d9487 --- /dev/null +++ b/src/Tearing/ModifiedRutherford/Callen.jl @@ -0,0 +1,154 @@ +# Callen.jl +# +# `solve(CallenProblem(pe))`: Callen's reduced modified Rutherford equation for RMP-driven islands (UW-CPTC 16-4, 2016), +# in the Fortran GPEC 1.6.0 form, on every resonant row of a single-n perturbed equilibrium driven by coils. + +""" + CallenProblem(pe) + +Callen's reduced MRE for the vacuum-island threshold on every resonant row of the perturbed equilibrium `pe` +(a single-n state from `perturbed_equilibrium` with a coil forcing). Half-widths are in metres of the toroidal-flux +radius ρ = (Φ_tor/πB₀)^(1/2) inside, and in ψ_N on output, as in Fortran GPEC 1.6.0: + + - w_pol = ½ q ρ_i/(ρ/R₀)^(1/2) with ρ_i = (2T_i/m_i)^(1/2)/(Z_i e B₀/m_i), R₀ and B₀ on the axis; + - Δ_RMP = |Δ|/(2πR₀q) B₀/|B_res|, Δ the singular-current jump over 2πχ₁ and B_res the total resonant field; + - Δ_c = −2m/ρ; + - critical vacuum half-width h_v,crit = ½ w_pol^(2/3) ((27/4)|Δ_c|)^(1/6)/Δ_RMP^(1/2); + - the cubic Δ_c x³ + Δ_RMP (2h_v)² x − w_pol² = 0 for the half-width x, with h_v the vacuum island half-width; its + positive roots are h_min < h_sat; + - critical resonant field B_crit = |B_vac| (h_v,crit/h_v)². + +The vacuum resonant field B_vac is the coils' resonant harmonic on the rational surface over its area. + +## Fields + + - `pe` - Perturbed-equilibrium state, single n, from a coil forcing. +""" +struct CallenProblem{S} + pe::S +end + +""" + CallenResult + +Output of `solve(::CallenProblem)`, one entry per resonant row. Half-widths are in ψ_N; `NaN` marks a root that +does not exist. + +## Fields + + - `problem` - The producing problem. + - `psi`, `q`, `m`, `n` - Surface identity. + - `rho_tor`, `drho_dpsi` - Toroidal-flux radius ρ [m] and dρ/dψ_N [m]. + - `rho_i`, `w_pol` - Ion gyroradius and polarization width [m]. + - `delta_rmp`, `delta_c` - Δ_RMP and Δ_c [1/m]. + - `b_res`, `b_vac` - |total| and |vacuum| resonant field [T]. + - `hw_isl`, `hw_v` - Total-field and vacuum island half-widths. + - `hw_v_crit` - Critical vacuum half-width. + - `hw_min`, `hw_sat` - Smaller and larger positive cubic roots. + - `n_roots` - Number of positive cubic roots (0 or 2). + - `b_crit` - Critical vacuum resonant field [T]. +""" +struct CallenResult{P} + problem::P + psi::Vector{Float64} + q::Vector{Float64} + m::Vector{Int} + n::Vector{Int} + rho_tor::Vector{Float64} + drho_dpsi::Vector{Float64} + rho_i::Vector{Float64} + w_pol::Vector{Float64} + delta_rmp::Vector{Float64} + delta_c::Vector{Float64} + b_res::Vector{Float64} + b_vac::Vector{Float64} + hw_isl::Vector{Float64} + hw_v::Vector{Float64} + hw_v_crit::Vector{Float64} + hw_min::Vector{Float64} + hw_sat::Vector{Float64} + n_roots::Vector{Int} + b_crit::Vector{Float64} +end + +""" + callen_cubic_roots(a, b, c) -> (x_min, x_sat) + +The positive roots of `a x³ + b x + c = 0` with `a < 0`, `b ≥ 0`, `c < 0`, by the trigonometric solution +x_k = 2R cos((θ + 2πk)/3), R = (−p/3)^(1/2), cos θ = −q/(2R³) of the depressed cubic x³ + px + q; `(NaN, NaN)` +when it has no positive root. +""" +function callen_cubic_roots(a::Real, b::Real, c::Real) + p, q = b / a, c / a + p < 0 || return (NaN, NaN) + R = sqrt(-p / 3) + arg = -q / (2 * R^3) + abs(arg) <= 1 || return (NaN, NaN) + theta = acos(arg) + roots = sort(filter(>(0), [2R * cos((theta + 2π * k) / 3) for k in 0:2])) + length(roots) == 2 || return (NaN, NaN) + return (roots[1], roots[2]) +end + +# Vacuum resonant field |B_vac| [T] of the coil forcing on the surface ψ, harmonic m. +function _vacuum_resonant_field(pe, equil, psi, n, m, area) + rmp = pe.forcing + rmp isa ForcingTerms.RMPSource && rmp.ctrl.forcing_data_format == "coil" || + throw(ArgumentError("CallenProblem needs a single coil forcing (forcing_data_format = \"coil\") for the vacuum resonant field")) + cfg = ForcingTerms.CoilConfig(rmp.ctrl) + grid = ForcingTerms.CoilForcingGrid(equil, cfg, n; psi) + amplitude = only(ForcingTerms.coil_forcing_modes(pe.coil_sets, grid, n, m, m)).amplitude + return abs(rmp.scale * amplitude) / area +end + +function CommonSolve.solve(prob::CallenProblem) + pe = prob.pe + resp = pe.response + ffs = resp.ffs + equil = ffs.equil + ffs.npert == 1 || throw(ArgumentError("CallenProblem is single-n; the solve spans n = $(ffs.nlow):$(ffs.nhigh)")) + data = equil.kinetic + data === nothing && throw(ArgumentError("CallenProblem needs kinetic profiles; set kinetic_file in [Equilibrium]")) + isempty(pe.island_half_width) && throw(ArgumentError("CallenProblem needs the singular coupling of the perturbed equilibrium")) + + T_i_of = _slayer_profiles(data, nothing).profiles.T_i + species = data.ion_species + m_i = sum(s.fraction * s.m for s in species) / sum(s.fraction for s in species) * M_P + z_i = Float64(first(species).z) + R0, B0 = equil.ro, equil.params.bt0 + chi1 = 2π * equil.psio + qspl = equil.profiles.q_spline + psi0 = first(equil.profiles.xs) + + nrow = length(resp.rational_psi) + out = Dict( + k => fill(NaN, nrow) for k in (:psi, :q, :rho_tor, :drho_dpsi, :rho_i, :w_pol, :delta_rmp, :delta_c, :b_res, :b_vac, + :hw_isl, :hw_v, :hw_v_crit, :hw_min, :hw_sat, :b_crit) + ) + m_out, n_out, n_roots = zeros(Int, nrow), zeros(Int, nrow), zeros(Int, nrow) + for row in 1:nrow + psi, q, m, n, area = resp.rational_psi[row], resp.rational_q[row], resp.rational_m_res[row], resp.rational_n[row], resp.rational_area[row] + q1 = ffs.surfaces[resp.rational_surface_idx[row]].q1 + # ρ = (Φ_tor/πB₀)^(1/2) with Φ_tor = 2πψ₀ ∫q dψ_N; dρ/dψ_N = qψ₀/(B₀ρ). + rho = sqrt(FastInterpolations.integrate(qspl, psi0, psi) * 2 * equil.psio / B0) + drho = q * equil.psio / (B0 * rho) + rho_i = sqrt(2 * E_CHG * Float64(T_i_of(psi)) / m_i) / (z_i * E_CHG * B0 / m_i) + w_pol = 0.5 * q * rho_i / sqrt(rho / R0) + b_res = abs(pe.resonant_area_weighted_field[row]) + delta_rmp = abs(pe.delta_prime[row]) / (2π * R0 * q) * B0 / b_res + delta_c = -2 * m / rho + hw_v_crit = 0.5 * w_pol^(2 / 3) * ((27 / 4) * abs(delta_c))^(1 / 6) / sqrt(delta_rmp) + b_vac = _vacuum_resonant_field(pe, equil, psi, n, m, area) + shear = n * q1 / q + hw_v = sqrt(abs(4 * b_vac * area / (2π * shear * q * chi1))) + x_min, x_sat = callen_cubic_roots(delta_c, delta_rmp * (2 * hw_v * drho)^2, -w_pol^2) + for (k, v) in pairs((; psi, q, rho_tor=rho, drho_dpsi=drho, rho_i, w_pol, delta_rmp, delta_c, b_res, b_vac, + hw_isl=pe.island_half_width[row], hw_v, hw_v_crit=hw_v_crit / drho, hw_min=x_min / drho, hw_sat=x_sat / drho, + b_crit=b_vac * (hw_v_crit / drho / hw_v)^2)) + out[k][row] = v + end + m_out[row], n_out[row], n_roots[row] = m, n, isnan(x_min) ? 0 : 2 + end + return CallenResult(prob, out[:psi], out[:q], m_out, n_out, out[:rho_tor], out[:drho_dpsi], out[:rho_i], out[:w_pol], out[:delta_rmp], + out[:delta_c], out[:b_res], out[:b_vac], out[:hw_isl], out[:hw_v], out[:hw_v_crit], out[:hw_min], out[:hw_sat], n_roots, out[:b_crit]) +end diff --git a/src/Tearing/ModifiedRutherford/HDF5Output.jl b/src/Tearing/ModifiedRutherford/HDF5Output.jl index 4855ee3b6..2a0fdf432 100644 --- a/src/Tearing/ModifiedRutherford/HDF5Output.jl +++ b/src/Tearing/ModifiedRutherford/HDF5Output.jl @@ -5,7 +5,8 @@ # Tearing/ # └── ModifiedRutherford/ # ├── w -- width grid (ψ_N) -# ├── PerSurface/ -- surface identity, indices and term inputs +# ├── w_m_star -- smallest seed threshold over the surfaces +# ├── PerSurface/ -- surface identity, indices, term inputs, roots and flags # └── Terms/ -- F(w), each term and w_d(w), surface × width """ @@ -18,9 +19,14 @@ function save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::ModifiedRuthe tearing = haskey(parent, "Tearing") ? parent["Tearing"] : create_group(parent, "Tearing") g = replace_group!(tearing, "ModifiedRutherford") g["w"] = result.w + g["w_m_star"] = result.w_m_star for key in _MRE_PER_SURFACE g["PerSurface/$key"] = getfield(result, key) end + # Flags as 0/1 integers, readable without an HDF5 boolean type. + for key in _MRE_FLAGS + g["PerSurface/$key"] = Int8.(getfield(result, key)) + end for key in _MRE_TERMS g["Terms/$key"] = getfield(result, key) end @@ -29,8 +35,11 @@ function save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::ModifiedRuthe return g end -const _MRE_PER_SURFACE = (:psi, :q, :m, :n, :delta_prime, :D_I, :D_R, :H, :D_nc, :f_trap, :mu_ratio, :zeff, :Wc_prefac, :chi_perp, - :v_te, :R0, :omega_star_i, :omega_hat, :omega_hat_pi, :beta_p, :rho_theta, :w_ib, :eps) +const _MRE_PER_SURFACE = (:psi, :q, :m, :n, :delta_prime, :delta_prime_imag, :D_I, :D_R, :H, :D_nc, :f_trap, :mu_ratio, :zeff, :Wc_prefac, + :chi_perp, :v_te, :R0, :omega_star_i, :omega_star_e, :omega_hat, :omega_hat_pi, :beta_p, :rho_theta, :w_ib, :eps, :gamma, :delta_r, :w_lo, + :w_m, :w_sat) +const _MRE_FLAGS = (:fully_stabilized, :unstable_any_seed, :interchange_unstable, :linear_unstable, :below_w_ib, :large_delta_prime_w, + :complex_delta_prime) const _MRE_TERMS = (:F, :classical, :bootstrap, :curvature, :polarization, :w_d) # Julia's surface × width matrices land in HDF5 as (width, surface). @@ -38,11 +47,13 @@ const _MRE_TERM_DIMS = ("w", "surface") const MRE_H5_ANNOTATIONS = [ "w" => (; long_name="full island width grid", units="psi_N", dims=("w",)), + "w_m_star" => (; long_name="smallest seed-threshold full width over the surfaces, NaN if none", units="psi_N"), "PerSurface/psi" => (; long_name="normalized poloidal flux of the rational surface", units="psi_N", dims=("surface",)), "PerSurface/q" => (; long_name="safety factor at the surface", dims=("surface",)), "PerSurface/m" => (; long_name="resonant poloidal mode number", dims=("surface",)), "PerSurface/n" => (; long_name="resonant toroidal mode number", dims=("surface",)), - "PerSurface/delta_prime" => (; long_name="real part of the diagonal Delta' (PEST-3 convention)", dims=("surface",)), + "PerSurface/delta_prime" => (; long_name="real part of the diagonal Delta' (PEST-3 convention), times delta_prime_factor", dims=("surface",)), + "PerSurface/delta_prime_imag" => (; long_name="imaginary part of the diagonal Delta'", dims=("surface",)), "PerSurface/D_I" => (; long_name="Mercier index D_I = E + F + H - 1/4", dims=("surface",)), "PerSurface/D_R" => (; long_name="resistive interchange index D_R = E + F + H^2", dims=("surface",)), "PerSurface/H" => (; long_name="Glasser-Greene-Johnson coefficient H", dims=("surface",)), @@ -56,12 +67,26 @@ const MRE_H5_ANNOTATIONS = [ "PerSurface/v_te" => (; long_name="electron thermal speed sqrt(2T_e/m_e)", units="m/s", dims=("surface",)), "PerSurface/R0" => (; long_name="major radius (R_max + R_min)/2 of the surface, used in lambda_par", units="m", dims=("surface",)), "PerSurface/omega_star_i" => (; long_name="ion diamagnetic frequency from the density gradient", units="rad/s", dims=("surface",)), + "PerSurface/omega_star_e" => (; long_name="electron diamagnetic frequency from the density gradient", units="rad/s", dims=("surface",)), "PerSurface/omega_hat" => (; long_name="island frequency normalized to the ion diamagnetic frequency", dims=("surface",)), "PerSurface/omega_hat_pi" => (; long_name="omega_*pi/omega_*i = 1 + eta_i", dims=("surface",)), "PerSurface/beta_p" => (; long_name="poloidal ion beta 2 mu0 n_i T_i/", dims=("surface",)), "PerSurface/rho_theta" => (; long_name="poloidal ion Larmor radius", units="m", dims=("surface",)), "PerSurface/w_ib" => (; long_name="ion banana width", units="psi_N", dims=("surface",)), "PerSurface/eps" => (; long_name="inverse aspect ratio of the surface", dims=("surface",)), + "PerSurface/gamma" => (; long_name="linear tearing growth rate of the gate, NaN without one", units="1/s", dims=("surface",)), + "PerSurface/delta_r" => (; long_name="resistive layer width of the gate, NaN without one", units="psi_N", dims=("surface",)), + "PerSurface/w_lo" => (; long_name="lower end of the root search max(delta_r, w_min)", units="psi_N", dims=("surface",)), + "PerSurface/w_m" => (; long_name="seed threshold: smallest F crossing from negative to positive, NaN if none", units="psi_N", dims=("surface",)), + "PerSurface/w_sat" => (; long_name="saturated width: largest F crossing from positive to negative, NaN if none", units="psi_N", + dims=("surface",)), + "PerSurface/fully_stabilized" => (; long_name="1 if F < 0 on the whole search range", dims=("surface",)), + "PerSurface/unstable_any_seed" => (; long_name="1 if F > 0 at w_lo", dims=("surface",)), + "PerSurface/interchange_unstable" => (; long_name="1 if D_R > 0 (excluded)", dims=("surface",)), + "PerSurface/linear_unstable" => (; long_name="1 if the gate's growth rate is positive (excluded)", dims=("surface",)), + "PerSurface/below_w_ib" => (; long_name="1 if w_m < w_ib", dims=("surface",)), + "PerSurface/large_delta_prime_w" => (; long_name="1 if |Delta'| w >= 1 at w_m or w_sat", dims=("surface",)), + "PerSurface/complex_delta_prime" => (; long_name="1 if |Im Delta'| > 1e-3 |Re Delta'|", dims=("surface",)), "Terms/F" => (; long_name="modified Rutherford F(w), the sum of the switched-on terms", dims=_MRE_TERM_DIMS), "Terms/classical" => (; long_name="classical term Delta'(w)", dims=_MRE_TERM_DIMS), "Terms/bootstrap" => (; long_name="bootstrap term Delta_nc(w)", dims=_MRE_TERM_DIMS), @@ -69,3 +94,42 @@ const MRE_H5_ANNOTATIONS = [ "Terms/polarization" => (; long_name="ion polarization term Delta_pol(w), including pol_sign", dims=_MRE_TERM_DIMS), "Terms/w_d" => (; long_name="transport threshold width w_d(w)", units="psi_N", dims=_MRE_TERM_DIMS) ] + +""" + save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::CallenResult) + +Write `result` into `Tearing/Callen/` of `parent`, one dataset per field over the resonant rows, replacing any existing +one and keeping the rest of `Tearing/`, and record its deck section. +""" +function save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::CallenResult) + tearing = haskey(parent, "Tearing") ? parent["Tearing"] : create_group(parent, "Tearing") + g = replace_group!(tearing, "Callen") + for (key, _) in CALLEN_H5_ANNOTATIONS + g[key] = getfield(result, Symbol(key)) + end + Utilities.HDF5Annotations.annotate!(g, CALLEN_H5_ANNOTATIONS) + Utilities.record_inputs!(parent, result) + return g +end + +const CALLEN_H5_ANNOTATIONS = [ + "psi" => (; long_name="normalized poloidal flux of the rational surface", units="psi_N", dims=("surface",)), + "q" => (; long_name="safety factor at the surface", dims=("surface",)), + "m" => (; long_name="resonant poloidal mode number", dims=("surface",)), + "n" => (; long_name="resonant toroidal mode number", dims=("surface",)), + "rho_tor" => (; long_name="toroidal-flux radius (Phi_tor/(pi B0))^(1/2)", units="m", dims=("surface",)), + "drho_dpsi" => (; long_name="d rho_tor / d psi_N", units="m", dims=("surface",)), + "rho_i" => (; long_name="ion gyroradius in the axis field", units="m", dims=("surface",)), + "w_pol" => (; long_name="polarization width 0.5 q rho_i/(rho/R0)^(1/2)", units="m", dims=("surface",)), + "delta_rmp" => (; long_name="Callen Delta'_RMP from the total resonant field", units="1/m", dims=("surface",)), + "delta_c" => (; long_name="Callen classical Delta' = -2m/rho", units="1/m", dims=("surface",)), + "b_res" => (; long_name="total resonant field |flux|/area", units="T", dims=("surface",)), + "b_vac" => (; long_name="vacuum (coil) resonant field |flux|/area", units="T", dims=("surface",)), + "hw_isl" => (; long_name="total-field island half-width", units="psi_N", dims=("surface",)), + "hw_v" => (; long_name="vacuum island half-width", units="psi_N", dims=("surface",)), + "hw_v_crit" => (; long_name="critical vacuum island half-width", units="psi_N", dims=("surface",)), + "hw_min" => (; long_name="smaller positive root of the Callen cubic, NaN if none", units="psi_N", dims=("surface",)), + "hw_sat" => (; long_name="larger positive root of the Callen cubic, NaN if none", units="psi_N", dims=("surface",)), + "n_roots" => (; long_name="number of positive roots of the Callen cubic", dims=("surface",)), + "b_crit" => (; long_name="critical vacuum resonant field", units="T", dims=("surface",)) +] diff --git a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl index ff7b7fb07..218af7bcd 100644 --- a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl +++ b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl @@ -9,18 +9,21 @@ import FastInterpolations using FastInterpolations: cubic_interp, Series, PeriodicBC, DerivOp import ...Utilities +import ...ForcingTerms import ...Utilities: save_to_hdf5 using ...Utilities.HDF5Annotations: replace_group! using ...Utilities.PhysicalConstants: M_E, M_P, E_CHG, MU_0 using ...InnerLayer.SLAYER: _slayer_profiles +using Roots: find_zero, Brent include("Geometry.jl") include("Terms.jl") include("Problem.jl") +include("Callen.jl") include("HDF5Output.jl") export MRESurfaceGeometry, mre_geometry, sauter2002_trapped_fraction, bootstrap_coefficient export classical_term, bootstrap_term, curvature_term, polarization_term, reduced_polarization_term, transport_width -export ModifiedRutherfordProblem, ModifiedRutherfordResult +export mre_roots, ModifiedRutherfordProblem, ModifiedRutherfordResult, CallenProblem, CallenResult, callen_cubic_roots end # module ModifiedRutherford diff --git a/src/Tearing/ModifiedRutherford/Problem.jl b/src/Tearing/ModifiedRutherford/Problem.jl index 9884d8974..5d1a40850 100644 --- a/src/Tearing/ModifiedRutherford/Problem.jl +++ b/src/Tearing/ModifiedRutherford/Problem.jl @@ -16,28 +16,42 @@ the Mercier, resistive-interchange and Pfirsch-Schlüter indices D_I, D_R = E + `ResistGeometry`, and the plasma (densities, temperatures, Z_eff, χ⊥,e, E×B rotation, ion species) is the equilibrium's kinetic data, as every tearing layer reads it. A deck states it as `[Tearing.ModifiedRutherford]`. +The roots of F are refined between grid points from `w_lo = max(δ_r, w_min)`: the seed threshold `w_m` is the +smallest crossing from F < 0 to F > 0 and the saturated width `w_sat` the largest from F > 0 to F < 0. Surfaces that +are resistive-interchange unstable (D_R > 0) or linearly tearing unstable (γ > 0 in `tearing`) get no roots. + ## Fields - `ffs` - Outer solution with a Δ′ matrix (Riccati with `vac_flag=true`, or Galerkin with `rpec_flag=true`), single n. + - `tearing` - Linear tearing solve on the same Δ′ (the gate and the layer width δ_r), or `nothing`; set by the deck + when `[Tearing]` is present. - `classical`, `bootstrap`, `curvature`, `polarization` - Term switches s_Δ, s_nc, s_GGJ, s_pol. - `delta_prime_model` - `:mercier_power` (Benjamin Eq. 10) or `:constant` (Δ′(w) = Δ′_mn). + - `delta_prime_factor`, `D_nc_factor`, `rho_theta_factor` - Multipliers on Δ′_mn, D_nc and the poloidal ion Larmor + radius ρ_θi (which also sets w_ib), for scans. - `k1` - Bootstrap and curvature coefficient k₁. - `c_wd` - Prefactor of the transport threshold width w_d. - `chi_perp_factor` - Multiplier on χ⊥,e (the kinetic `chi_e` profile, else its `chi_e_default`). - `pol_model` - `:full` (Benjamin Eq. C.1) or `:reduced` (−c_pol w_pol²/w³, w_pol = C_pol w_ib). - `c_pol`, `C_pol` - Polarization coefficients. - `pol_sign` - ±1, multiplies the polarization term. - - `omega_factor` - Multiplier on the island frequency ω = n Ω_E from the kinetic `omega_E`; 0 is a stalled island. - - `omega_ratio` - ω/ω_*i on every surface, replacing the one from `omega_E`; `nothing` (default) uses `omega_E`. + - `omega_model` - Island frequency ω in the E_r = 0 frame: `:electron_diamagnetic`, ω = ω_*e(1 + η_e/4) (Wilson + 1996), or `:exb`, the lab-frame ω = n Ω_E from the kinetic `omega_E`. + - `omega_factor` - Multiplier on the ω of `omega_model`; 0 is a stalled island. + - `omega_ratio` - ω/ω_*i on every surface, replacing `omega_model`; `nothing` (default) uses the model. - `w_min`, `w_max`, `nw` - Log-spaced width grid in ψ_N. """ -@kwdef struct ModifiedRutherfordProblem{R} +@kwdef struct ModifiedRutherfordProblem{R,T} ffs::R + tearing::T = nothing classical::Bool = true bootstrap::Bool = true curvature::Bool = true polarization::Bool = false delta_prime_model::Symbol = :mercier_power + delta_prime_factor::Float64 = 1.0 + D_nc_factor::Float64 = 1.0 + rho_theta_factor::Float64 = 1.0 k1::Float64 = 1.7 c_wd::Float64 = 1.0 chi_perp_factor::Float64 = 1.0 @@ -45,6 +59,7 @@ equilibrium's kinetic data, as every tearing layer reads it. A deck states it as c_pol::Float64 = 1.0 C_pol::Float64 = 1.9 pol_sign::Float64 = 1.0 + omega_model::Symbol = :electron_diamagnetic omega_factor::Float64 = 1.0 omega_ratio::Union{Nothing,Float64} = nothing w_min::Float64 = 1e-5 @@ -52,7 +67,7 @@ equilibrium's kinetic data, as every tearing layer reads it. A deck states it as nw::Int = 200 end -ModifiedRutherfordProblem(ffs; kwargs...) = ModifiedRutherfordProblem{typeof(ffs)}(; ffs=ffs, kwargs...) +ModifiedRutherfordProblem(ffs; kwargs...) = ModifiedRutherfordProblem(; ffs=ffs, kwargs...) """ ModifiedRutherfordResult @@ -64,12 +79,23 @@ Output of `solve(::ModifiedRutherfordProblem)`. Per-surface vectors run core to - `problem` - The producing problem. - `w` - Width grid in ψ_N. - `psi`, `q`, `m`, `n` - Surface identity. - - `delta_prime`, `D_I`, `D_R`, `H` - Outer-region and layer indices. + - `delta_prime`, `delta_prime_imag`, `D_I`, `D_R`, `H` - Outer-region and layer indices (Δ′ after `delta_prime_factor`). - `D_nc`, `f_trap`, `mu_ratio`, `zeff` - Bootstrap coefficient, Sauter 2002 trapped fraction, μ_e/(ν_e + μ_e), Z_eff. - `Wc_prefac`, `chi_perp`, `v_te`, `R0` - Inputs of w_d. - - `omega_star_i`, `omega_hat`, `omega_hat_pi` - Ion diamagnetic frequency [rad/s], ω/ω_*i and ω_*pi/ω_*i. + - `omega_star_i`, `omega_star_e`, `omega_hat`, `omega_hat_pi` - Ion and electron diamagnetic frequencies [rad/s] from + the density gradients, ω/ω_*i and ω_*pi/ω_*i. - `beta_p`, `rho_theta`, `w_ib`, `eps` - Polarization inputs: poloidal ion beta, poloidal ion Larmor radius [m], banana width in ψ_N, inverse aspect ratio. + - `gamma`, `delta_r` - Linear growth rate [1/s] and resistive layer width in ψ_N from `tearing` (`NaN` without it). + - `w_lo` - Lower end of the root search. + - `w_m`, `w_sat` - Seed threshold and saturated full widths in ψ_N; `NaN` when absent or excluded. + - `w_m_star` - Smallest `w_m` over the surfaces. + - `fully_stabilized` - F < 0 on the whole search range. + - `unstable_any_seed` - F > 0 at `w_lo`. + - `interchange_unstable`, `linear_unstable` - Excluded by D_R > 0, or by γ > 0. + - `below_w_ib` - `w_m` below the ion banana width, where the polarization ordering fails. + - `large_delta_prime_w` - |Δ′| w ≥ 1 at `w_m` or `w_sat`. + - `complex_delta_prime` - |Im Δ′_mn| > 10⁻³ |Re Δ′_mn|. - `F`, `classical`, `bootstrap`, `curvature`, `polarization`, `w_d` - F(w), each term, and w_d(w). """ struct ModifiedRutherfordResult{P} @@ -80,6 +106,7 @@ struct ModifiedRutherfordResult{P} m::Vector{Int} n::Vector{Int} delta_prime::Vector{Float64} + delta_prime_imag::Vector{Float64} D_I::Vector{Float64} D_R::Vector{Float64} H::Vector{Float64} @@ -92,12 +119,26 @@ struct ModifiedRutherfordResult{P} v_te::Vector{Float64} R0::Vector{Float64} omega_star_i::Vector{Float64} + omega_star_e::Vector{Float64} omega_hat::Vector{Float64} omega_hat_pi::Vector{Float64} beta_p::Vector{Float64} rho_theta::Vector{Float64} w_ib::Vector{Float64} eps::Vector{Float64} + gamma::Vector{Float64} + delta_r::Vector{Float64} + w_lo::Vector{Float64} + w_m::Vector{Float64} + w_sat::Vector{Float64} + w_m_star::Float64 + fully_stabilized::Vector{Bool} + unstable_any_seed::Vector{Bool} + interchange_unstable::Vector{Bool} + linear_unstable::Vector{Bool} + below_w_ib::Vector{Bool} + large_delta_prime_w::Vector{Bool} + complex_delta_prime::Vector{Bool} F::Matrix{Float64} classical::Matrix{Float64} bootstrap::Matrix{Float64} @@ -116,9 +157,11 @@ function validate(prob::ModifiedRutherfordProblem) ffs.equil.kinetic === nothing && throw(ArgumentError("ModifiedRutherfordProblem needs kinetic profiles; set kinetic_file in [Equilibrium]")) prob.delta_prime_model in (:mercier_power, :constant) || throw(ArgumentError("delta_prime_model must be :mercier_power or :constant")) prob.pol_model in (:full, :reduced) || throw(ArgumentError("pol_model must be :full or :reduced")) + prob.omega_model in (:electron_diamagnetic, :exb) || throw(ArgumentError("omega_model must be :electron_diamagnetic or :exb")) abs(prob.pol_sign) == 1 || throw(ArgumentError("pol_sign must be +1 or -1")) 0 < prob.w_min < prob.w_max && prob.nw >= 2 || throw(ArgumentError("the width grid needs 0 < w_min < w_max and nw ≥ 2")) - prob.k1 > 0 && prob.c_wd > 0 && prob.chi_perp_factor > 0 || throw(ArgumentError("k1, c_wd and chi_perp_factor must be positive")) + prob.k1 > 0 && prob.c_wd > 0 && prob.chi_perp_factor > 0 && prob.rho_theta_factor > 0 || + throw(ArgumentError("k1, c_wd, chi_perp_factor and rho_theta_factor must be positive")) return nothing end @@ -128,6 +171,30 @@ function _profile_at(psi_xs, values, psi) return Float64(itp(psi)), Float64(itp(psi; deriv=DerivOp(1))) end + +""" + mre_roots(F, w_lo, w_grid) -> (; w_m, w_sat, fully_stabilized, unstable_any_seed) + +Roots of `F` on `[w_lo, maximum(w_grid)]`, bracketed on `w_lo` and the grid points above it and refined by Brent's +method: `w_m` is the smallest crossing from negative to positive, `w_sat` the largest from positive to negative +(`NaN` when absent). +""" +function mre_roots(F, w_lo::Real, w_grid::AbstractVector) + nodes = vcat(Float64(w_lo), filter(>(w_lo), w_grid)) + vals = F.(nodes) + w_m, w_sat = NaN, NaN + for j in 1:(length(nodes)-1) + a, b = vals[j], vals[j+1] + isfinite(a) && isfinite(b) && a != 0 && (a * b < 0 || b == 0) || continue + root = find_zero(F, (nodes[j], nodes[j+1]), Brent()) + a < 0 ? (isnan(w_m) && (w_m = root)) : (w_sat = root) + end + return (; w_m, w_sat, fully_stabilized=all(<(0), vals), unstable_any_seed=vals[1] > 0) +end + +# The linear tearing row at ψ in `tearing`, or `nothing`. +_tearing_row(tearing, psi) = findfirst(p -> isapprox(p, psi; atol=1e-8), tearing.rational_psi) + function CommonSolve.solve(prob::ModifiedRutherfordProblem) validate(prob) ffs = prob.ffs @@ -146,11 +213,17 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) species = data.ion_species m_i = sum(s.fraction * s.m for s in species) / sum(s.fraction for s in species) * M_P z_i = Float64(first(species).z) + gate = prob.tearing + chi_gate = gate === nothing || !hasproperty(gate.model, :chi_perp_Dc) ? nothing : gate.model.chi_perp_Dc vec0() = zeros(ns) - out = (; psi=vec0(), q=vec0(), m=zeros(Int, ns), n=zeros(Int, ns), delta_prime=vec0(), D_I=vec0(), D_R=vec0(), H=vec0(), - D_nc=vec0(), f_trap=vec0(), mu_ratio=vec0(), zeff=vec0(), Wc_prefac=vec0(), chi_perp=vec0(), v_te=vec0(), R0=vec0(), - omega_star_i=vec0(), omega_hat=vec0(), omega_hat_pi=vec0(), beta_p=vec0(), rho_theta=vec0(), w_ib=vec0(), eps=vec0()) + flag0() = falses(ns) + out = (; psi=vec0(), q=vec0(), m=zeros(Int, ns), n=zeros(Int, ns), delta_prime=vec0(), delta_prime_imag=vec0(), D_I=vec0(), D_R=vec0(), + H=vec0(), D_nc=vec0(), f_trap=vec0(), mu_ratio=vec0(), zeff=vec0(), Wc_prefac=vec0(), chi_perp=vec0(), v_te=vec0(), R0=vec0(), + omega_star_i=vec0(), omega_star_e=vec0(), omega_hat=vec0(), omega_hat_pi=vec0(), beta_p=vec0(), rho_theta=vec0(), w_ib=vec0(), + eps=vec0(), gamma=vec0(), delta_r=vec0(), w_lo=vec0(), w_m=vec0(), w_sat=vec0()) + flags = (; fully_stabilized=flag0(), unstable_any_seed=flag0(), interchange_unstable=flag0(), linear_unstable=flag0(), + below_w_ib=flag0(), large_delta_prime_w=flag0(), complex_delta_prime=flag0()) mats = Dict(k => zeros(ns, prob.nw) for k in (:F, :classical, :bootstrap, :curvature, :polarization, :w_d)) for (k, s) in enumerate(surfaces) @@ -164,30 +237,37 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) D_I < 0 || @warn "ModifiedRutherfordProblem: D_I = $D_I ≥ 0 at the $m/$n surface; its terms are NaN" # Plasma at the surface; the ion density falls back to n_e where the data have no n_i. - n_e = Float64(loaded.profiles.n_e(psi)) - T_e = Float64(loaded.profiles.T_e(psi)) + n_e, dn_e = Float64(loaded.profiles.n_e(psi)), Float64(loaded.profiles.n_e(psi; deriv=DerivOp(1))) + T_e, dT_e = Float64(loaded.profiles.T_e(psi)), Float64(loaded.profiles.T_e(psi; deriv=DerivOp(1))) T_i, dT_i = Float64(loaded.profiles.T_i(psi)), Float64(loaded.profiles.T_i(psi; deriv=DerivOp(1))) - n_i, dn_i = usable(data.n_i) ? _profile_at(psi_xs, data.n_i, psi) : (n_e, Float64(loaded.profiles.n_e(psi; deriv=DerivOp(1)))) + n_i, dn_i = usable(data.n_i) ? _profile_at(psi_xs, data.n_i, psi) : (n_e, dn_e) zeff = loaded.zeff === nothing ? data.zeff_default : Float64(loaded.zeff(psi)) chi_perp = prob.chi_perp_factor * (usable(data.chi_e) ? first(_profile_at(psi_xs, data.chi_e, psi)) : data.chi_e_default) + chi_gate === nothing || isapprox(chi_gate, chi_perp; rtol=1e-6) || + @warn "ModifiedRutherfordProblem: the gate's chi_perp_Dc = $chi_gate differs from the w_d χ⊥ = $chi_perp at the $m/$n surface" D_nc, mu_ratio = bootstrap_coefficient(g, zeff) + D_nc *= prob.D_nc_factor # Polarization inputs (Benjamin App. C); widths in metres map to ψ_N by w/(2ψ_s) ↔ ρ/r_s. v_te = sqrt(2 * E_CHG * T_e / M_E) v_ti = sqrt(2 * E_CHG * T_i / m_i) - rho_theta = m_i * v_ti / (g.avg_bpol * E_CHG) + rho_theta = prob.rho_theta_factor * m_i * v_ti / (g.avg_bpol * E_CHG) w_ib = sqrt(g.eps) * rho_theta * 2 * psi / g.r_minor beta_p = 2 * MU_0 * n_i * E_CHG * T_i / g.avg_bpolsq gradient_ratio = (dn_i / n_i) / (g.q1 / g.q) - # Ion diamagnetic frequency from the density gradient, in SLAYER's sign convention; ω_*pi adds the temperature gradient. + # Diamagnetic frequencies from the density gradients, in SLAYER's sign convention (charge −1 for electrons). omega_star_i = -n * T_i * (dn_i / n_i) / (z_i * g.psio) + omega_star_e = n * T_e * (dn_e / n_e) / g.psio omega_hat_pi = 1 + (dT_i / T_i) / (dn_i / n_i) - omega = prob.omega_factor * n * (loaded.has_omega_E ? Float64(loaded.profiles.omega_E(psi)) : 0.0) - omega_hat = something(prob.omega_ratio, omega / omega_star_i) + omega_model = + prob.omega_model === :electron_diamagnetic ? omega_star_e * (1 + 0.25 * (dT_e / T_e) / (dn_e / n_e)) : + n * (loaded.has_omega_E ? Float64(loaded.profiles.omega_E(psi)) : 0.0) + omega_hat = something(prob.omega_ratio, prob.omega_factor * omega_model / omega_star_i) - dp_mn = real(ffs.delta_prime.matrix[k, k]) - for (j, wj) in enumerate(w) + dp_complex = ffs.delta_prime.matrix[k, k] + dp_mn = prob.delta_prime_factor * real(dp_complex) + function terms(wj) wd = transport_width(wj; prob.c_wd, chi_perp, v_te, R0=g.R_major, g.q, g.q1, n, m, g.Wc_prefac) cl = D_I < 0 ? classical_term(dp_mn, D_I, wj; model=prob.delta_prime_model) : NaN bs = bootstrap_term(D_nc, wd, wj; prob.k1) @@ -199,17 +279,43 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) gradient_ratio, omega_hat, omega_hat_pi, w_ib) : reduced_polarization_term(wj; prob.c_pol, prob.C_pol, w_ib) ) - mats[:w_d][k, j] = wd - mats[:classical][k, j], mats[:bootstrap][k, j], mats[:curvature][k, j], mats[:polarization][k, j] = cl, bs, cv, pl - mats[:F][k, j] = prob.classical * cl + prob.bootstrap * bs + prob.curvature * cv + prob.polarization * pl + F = prob.classical * cl + prob.bootstrap * bs + prob.curvature * cv + prob.polarization * pl + return (; F, cl, bs, cv, pl, wd) end - for (key, val) in pairs((; psi, q=s.q, m, n, delta_prime=dp_mn, D_I, D_R, H=rg.H, D_nc, f_trap=g.f_trap, mu_ratio, zeff, - g.Wc_prefac, chi_perp, v_te, R0=g.R_major, omega_star_i, omega_hat, omega_hat_pi, beta_p, rho_theta, w_ib, g.eps)) + for (j, wj) in enumerate(w) + t = terms(wj) + mats[:F][k, j], mats[:w_d][k, j] = t.F, t.wd + mats[:classical][k, j], mats[:bootstrap][k, j], mats[:curvature][k, j], mats[:polarization][k, j] = t.cl, t.bs, t.cv, t.pl + end + + # Gates: the linear tearing growth rate and resistive layer width from `tearing`, and resistive interchange. + row = gate === nothing ? nothing : _tearing_row(gate, psi) + gamma = row === nothing ? NaN : gate.gamma[row] + delta_r = row === nothing || isempty(gate.layer_widths) ? NaN : 2 * psi * gate.layer_widths[row].delta_s_m / g.r_minor + w_lo = isnan(delta_r) ? prob.w_min : max(delta_r, prob.w_min) + flags.interchange_unstable[k] = D_R > 0 + flags.linear_unstable[k] = gamma > 0 + flags.complex_delta_prime[k] = abs(imag(dp_complex)) > 1e-3 * abs(real(dp_complex)) + r = mre_roots(wj -> terms(wj).F, w_lo, w) + excluded = flags.interchange_unstable[k] || flags.linear_unstable[k] || D_I >= 0 + w_m, w_sat = excluded ? (NaN, NaN) : (r.w_m, r.w_sat) + flags.fully_stabilized[k], flags.unstable_any_seed[k] = !excluded && r.fully_stabilized, !excluded && r.unstable_any_seed + flags.below_w_ib[k] = w_m < w_ib + flags.large_delta_prime_w[k] = abs(dp_mn) * w_m >= 1 || abs(dp_mn) * w_sat >= 1 + + for (key, val) in pairs((; psi, q=s.q, m, n, delta_prime=dp_mn, delta_prime_imag=imag(dp_complex), D_I, D_R, H=rg.H, D_nc, + f_trap=g.f_trap, mu_ratio, zeff, g.Wc_prefac, chi_perp, v_te, R0=g.R_major, omega_star_i, omega_star_e, omega_hat, omega_hat_pi, + beta_p, rho_theta, w_ib, g.eps, gamma, delta_r, w_lo, w_m, w_sat)) getfield(out, key)[k] = val end end - return ModifiedRutherfordResult(prob, collect(w), out.psi, out.q, out.m, out.n, out.delta_prime, out.D_I, out.D_R, out.H, - out.D_nc, out.f_trap, out.mu_ratio, out.zeff, out.Wc_prefac, out.chi_perp, out.v_te, out.R0, out.omega_star_i, - out.omega_hat, out.omega_hat_pi, out.beta_p, out.rho_theta, out.w_ib, out.eps, + valid = filter(!isnan, out.w_m) + w_m_star = isempty(valid) ? NaN : minimum(valid) + return ModifiedRutherfordResult(prob, collect(w), out.psi, out.q, out.m, out.n, out.delta_prime, out.delta_prime_imag, out.D_I, out.D_R, + out.H, out.D_nc, out.f_trap, out.mu_ratio, out.zeff, out.Wc_prefac, out.chi_perp, out.v_te, out.R0, out.omega_star_i, + out.omega_star_e, out.omega_hat, out.omega_hat_pi, out.beta_p, out.rho_theta, out.w_ib, out.eps, out.gamma, out.delta_r, out.w_lo, + out.w_m, out.w_sat, w_m_star, Vector{Bool}(flags.fully_stabilized), Vector{Bool}(flags.unstable_any_seed), + Vector{Bool}(flags.interchange_unstable), Vector{Bool}(flags.linear_unstable), Vector{Bool}(flags.below_w_ib), + Vector{Bool}(flags.large_delta_prime_w), Vector{Bool}(flags.complex_delta_prime), mats[:F], mats[:classical], mats[:bootstrap], mats[:curvature], mats[:polarization], mats[:w_d]) end diff --git a/src/Tearing/Tearing.jl b/src/Tearing/Tearing.jl index a3cdf56b1..cbb24f1bb 100644 --- a/src/Tearing/Tearing.jl +++ b/src/Tearing/Tearing.jl @@ -46,7 +46,7 @@ include("ModifiedRutherford/ModifiedRutherford.jl") import .Dispersion as Dispersion import .CriticalResonantField as CriticalResonantField import .ModifiedRutherford as ModifiedRutherford -using .ModifiedRutherford: ModifiedRutherfordProblem, ModifiedRutherfordResult +using .ModifiedRutherford: ModifiedRutherfordProblem, ModifiedRutherfordResult, CallenProblem, CallenResult using .Dispersion: SurfaceCoupling, surface_coupling, MultiSurfaceCoupling, multi_surface_coupling, ScanResult, brute_force_scan, @@ -63,7 +63,7 @@ include("GGJTearing.jl") include("HDF5Output.jl") export InnerLayer, Dispersion, CriticalResonantField, ModifiedRutherford -export ModifiedRutherfordProblem, ModifiedRutherfordResult +export ModifiedRutherfordProblem, ModifiedRutherfordResult, CallenProblem, CallenResult export resistive_layer_overlap, layer_overlap_scan, LayerOverlapScan export TearingProblem, TearingConfig, TearingResult export run_slayer_from_inputs diff --git a/test/runtests_modified_rutherford.jl b/test/runtests_modified_rutherford.jl index 4fa055a79..2882e2205 100644 --- a/test/runtests_modified_rutherford.jl +++ b/test/runtests_modified_rutherford.jl @@ -44,6 +44,30 @@ using TOML @test MR.transport_width(16e-3; kw...) / MR.transport_width(1e-3; kw...) ≈ 2 rtol = 1e-12 end + @testset "roots: seed threshold rises through zero, saturation falls through it" begin + grid = exp.(range(log(1e-5), log(0.2); length=60)) + r = MR.mre_roots(w -> (w - 3e-3) * (0.05 - w) / w^2, 1e-5, grid) + @test r.w_m ≈ 3e-3 rtol = 1e-10 + @test r.w_sat ≈ 0.05 rtol = 1e-10 + @test !r.fully_stabilized && !r.unstable_any_seed + @test MR.mre_roots(w -> -1 / w, 1e-5, grid).fully_stabilized + s = MR.mre_roots(w -> (0.05 - w) / w, 1e-5, grid) + @test s.unstable_any_seed && isnan(s.w_m) + @test s.w_sat ≈ 0.05 rtol = 1e-10 + end + + @testset "Callen cubic: both positive roots solve it and merge at the critical vacuum width" begin + a, c = -40.0, -(1.5e-2)^2 + x_min, x_sat = MR.callen_cubic_roots(a, 2.0, c) + @test 0 < x_min < x_sat + @test abs(a * x_min^3 + 2.0 * x_min + c) < 1e-12 && abs(a * x_sat^3 + 2.0 * x_sat + c) < 1e-12 + # The double root sits where 4p³ + 27q² = 0, i.e. b = 3|a| (c/2a)^(2/3). + b_crit = 3 * abs(a) * (c / (2a))^(2 / 3) + @test all(isnan, MR.callen_cubic_roots(a, 0.99 * b_crit, c)) + lo, hi = MR.callen_cubic_roots(a, 1.0001 * b_crit, c) + @test hi / lo < 1.05 + end + @testset "surface geometry matches Fortran RDCON on the DIII-D-like example" begin # Fortran values at q = 2 (ψ_N = 0.5183), interpolated from rdcon_output_n1.nc with MRE_flag = t and Z_eff = 1.52. dir = joinpath(@__DIR__, "..", "examples", "DIIID-like_gal_resistive_example") diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index 2b17e2828..c166eecb0 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -562,7 +562,7 @@ using HDF5 @testset "modified Rutherford under [Tearing.ModifiedRutherford]" begin # The MRE alone: a [Tearing] holding only the sub-table runs no layer solve, and its deck replays. - settings = (; polarization=true, omega_factor=0.5, nw=40) + settings = (; polarization=true, omega_model=:exb, omega_factor=0.5, delta_prime_factor=0.9, D_nc_factor=1.1, rho_theta_factor=1.2, nw=40) mktempdir() do dir psi = collect(range(0.0, 1.0; length=101)) h5open(joinpath(dir, "kinetic.h5"), "w") do f @@ -575,19 +575,44 @@ using HDF5 api = solve(ModifiedRutherfordProblem(ric; settings...)) deck_dir = _stage_deck(dir; eq_overrides=Dict{String,Any}("kinetic_file" => "kinetic.h5"), ffs_overrides=Dict{String,Any}("integrator" => "Riccati"), - extra_sections=Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => Dict{String,Any}(String(k) => v for (k, v) in pairs(settings))))) + extra_sections=Dict{String,Any}( + "Tearing" => Dict{String,Any}("ModifiedRutherford" => + Dict{String,Any}(String(k) => (v isa Symbol ? String(v) : v) for (k, v) in pairs(settings))) + )) reference = GPEC.main([deck_dir]) @test reference.tearing === nothing @test api.F ≈ reference.modified_rutherford.F rtol = 1e-10 @test api.polarization ≈ reference.modified_rutherford.polarization rtol = 1e-10 + @test isequal(api.w_m, reference.modified_rutherford.w_m) && isequal(api.w_sat, reference.modified_rutherford.w_sat) + @test api.fully_stabilized == reference.modified_rutherford.fully_stabilized @test all(<(0), api.D_R) && all(isfinite, api.F) h5open(joinpath(dir, "gpec.h5")) do f @test read(f["Tearing/ModifiedRutherford/Terms/F"]) ≈ api.F rtol = 1e-12 recorded = TOML.parse(read(f["Input/gpec_toml_raw"])) @test recorded["Tearing"] == Dict{String,Any}("ModifiedRutherford" => recorded["Tearing"]["ModifiedRutherford"]) @test recorded["Tearing"]["ModifiedRutherford"]["omega_factor"] == 0.5 + @test recorded["Tearing"]["ModifiedRutherford"]["omega_model"] == "exb" + @test isequal(read(f["Tearing/ModifiedRutherford/PerSurface/w_m"]), api.w_m) + end + end + + # With [Tearing], main hands the linear solve to the MRE as its gate, as the API keyword does. + sl_dir = joinpath(@__DIR__, "..", "examples", "DIIID-like_SLAYER_example") + mktempdir() do dir + deck = TOML.parsefile(joinpath(sl_dir, "gpec.toml")) + deck["Run"]["output_file"] = joinpath(dir, "gpec.h5") + deck["Tearing"]["ModifiedRutherford"] = Dict{String,Any}("nw" => 40) + for key in ("eq_filename", "kinetic_file") + deck["Equilibrium"][key] = abspath(joinpath(sl_dir, deck["Equilibrium"][key])) end + open(io -> TOML.print(io, deck), joinpath(dir, "gpec.toml"), "w") + reference = GPEC.main([dir]) + api = solve(ModifiedRutherfordProblem(reference.ffs; tearing=reference.tearing, nw=40)) + @test reference.modified_rutherford.problem.tearing === reference.tearing + @test isequal(api.gamma, reference.modified_rutherford.gamma) && all(isfinite, api.gamma) + @test api.linear_unstable == (api.gamma .> 0) + @test isequal(api.w_m, reference.modified_rutherford.w_m) end # A forward solve has no Δ′, so the deck stops at parse time. From decaefa4f05301f5f5068c5995b3cad1890e0167 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 7 Nov 2026 16:05:50 -0500 Subject: [PATCH 05/26] Tearing - BUGFIX! - Report Callen cubic roots as half-widths Callen 2016 Eq. 17 poses the steady cubic in the full island width with w_vac = 2h_v, so its exact roots are full widths. hw_min and hw_sat are now half of them, matching Fortran GPEC 1.6.0 (roots agree to 1.2e-3 on the DIII-D-like example at 10x coil current). Adds the Callen Fig. 8 roots as a unit test and a deck and API parity case for [Tearing.Callen]. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- docs/src/inner_layer.md | 3 +-- src/Tearing/ModifiedRutherford/Callen.jl | 7 +++--- test/runtests_modified_rutherford.jl | 4 +++ test/runtests_solve_api.jl | 31 ++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/docs/src/inner_layer.md b/docs/src/inner_layer.md index e5f3e2239..05d5da678 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -122,9 +122,8 @@ When the deck also has a `[Tearing]` layer solve, its uncoupled growth rates gat Surfaces with ``D_R>0`` are excluded as resistive-interchange unstable, and roots below the ion banana width or with ``|\Delta'|w\ge1`` are flagged. The island frequency in ``\Delta_\mathrm{pol}`` is ``\omega_{*e}(1+\eta_e/4)`` (Wilson 1996) by default, or the lab-frame ``n\omega_E`` with `omega_model = "exb"`. -`[Tearing.Callen]` evaluates Callen's reduced equation for RMP-driven islands on a single-``n`` perturbed equilibrium driven by coils, in the form of Fortran GPEC: the critical vacuum island half-width, the critical vacuum resonant field and the two positive roots of the cubic ``\Delta_c x^3 + \Delta'_\mathrm{RMP}(2h_v)^2x - w_\mathrm{pol}^2 = 0``. +`[Tearing.Callen]` evaluates Callen's reduced equation for RMP-driven islands on a single-``n`` perturbed equilibrium driven by coils, in the form of Fortran GPEC: the critical vacuum island half-width, the critical vacuum resonant field and the half-widths of the two positive roots of the steady cubic ``\Delta_c w^3 + \Delta'_\mathrm{RMP}w_\mathrm{vac}^2w - w_\mathrm{pol}^2 = 0`` (Callen 2016, Eq. 17), which is posed in the full width ``w`` with ``w_\mathrm{vac} = 2h_v``. The vacuum resonant field is the coils' resonant harmonic on the rational surface over its area. -The roots are the exact roots of the cubic; Fortran GPEC 1.6.0 reports half of each, from a trigonometric solution missing its factor 2. In a script, `solve(CallenProblem(pe))` returns the same [`CallenResult`](@ref Tearing.ModifiedRutherford.CallenResult). ## Numerical notes diff --git a/src/Tearing/ModifiedRutherford/Callen.jl b/src/Tearing/ModifiedRutherford/Callen.jl index d758d9487..7510b5f7f 100644 --- a/src/Tearing/ModifiedRutherford/Callen.jl +++ b/src/Tearing/ModifiedRutherford/Callen.jl @@ -14,8 +14,8 @@ radius ρ = (Φ_tor/πB₀)^(1/2) inside, and in ψ_N on output, as in Fortran G - Δ_RMP = |Δ|/(2πR₀q) B₀/|B_res|, Δ the singular-current jump over 2πχ₁ and B_res the total resonant field; - Δ_c = −2m/ρ; - critical vacuum half-width h_v,crit = ½ w_pol^(2/3) ((27/4)|Δ_c|)^(1/6)/Δ_RMP^(1/2); - - the cubic Δ_c x³ + Δ_RMP (2h_v)² x − w_pol² = 0 for the half-width x, with h_v the vacuum island half-width; its - positive roots are h_min < h_sat; + - the steady state of Callen's Eq. 17, Δ_c w³ + Δ_RMP w_vac² w − w_pol² = 0, a cubic in the full width w with + w_vac = 2h_v; its positive roots halved are h_min < h_sat; - critical resonant field B_crit = |B_vac| (h_v,crit/h_v)². The vacuum resonant field B_vac is the coils' resonant harmonic on the rational surface over its area. @@ -141,9 +141,10 @@ function CommonSolve.solve(prob::CallenProblem) b_vac = _vacuum_resonant_field(pe, equil, psi, n, m, area) shear = n * q1 / q hw_v = sqrt(abs(4 * b_vac * area / (2π * shear * q * chi1))) + # Roots are full widths in metres; the outputs are half-widths in ψ_N. x_min, x_sat = callen_cubic_roots(delta_c, delta_rmp * (2 * hw_v * drho)^2, -w_pol^2) for (k, v) in pairs((; psi, q, rho_tor=rho, drho_dpsi=drho, rho_i, w_pol, delta_rmp, delta_c, b_res, b_vac, - hw_isl=pe.island_half_width[row], hw_v, hw_v_crit=hw_v_crit / drho, hw_min=x_min / drho, hw_sat=x_sat / drho, + hw_isl=pe.island_half_width[row], hw_v, hw_v_crit=hw_v_crit / drho, hw_min=x_min / (2 * drho), hw_sat=x_sat / (2 * drho), b_crit=b_vac * (hw_v_crit / drho / hw_v)^2)) out[k][row] = v end diff --git a/test/runtests_modified_rutherford.jl b/test/runtests_modified_rutherford.jl index 2882e2205..d58f59bb6 100644 --- a/test/runtests_modified_rutherford.jl +++ b/test/runtests_modified_rutherford.jl @@ -66,6 +66,10 @@ using TOML @test all(isnan, MR.callen_cubic_roots(a, 0.99 * b_crit, c)) lo, hi = MR.callen_cubic_roots(a, 1.0001 * b_crit, c) @test hi / lo < 1.05 + # Callen 2016 Fig. 8 (DIII-D 158115, ρ = 71.88 cm): full widths w_min ≈ 1.28 cm, w_sat ≈ 2.24 cm. + w_min, w_sat = MR.callen_cubic_roots(-16 / 71.88, 26.4 / 71.88 * 2.45^2, -1.5^2) + @test w_min ≈ 1.28 rtol = 0.1 + @test w_sat ≈ 2.24 rtol = 0.1 end @testset "surface geometry matches Fortran RDCON on the DIII-D-like example" begin diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index c166eecb0..79a8cebc4 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -622,6 +622,37 @@ using HDF5 end end + @testset "Callen threshold under [Tearing.Callen]" begin + # The coil-driven DIII-D-like example with the empty sub-table, against solve(CallenProblem(pe)). + ex_dir = joinpath(@__DIR__, "..", "examples", "DIIID-like_ideal_example") + mktempdir() do dir + deck = TOML.parsefile(joinpath(ex_dir, "gpec.toml")) + foreach(k -> delete!(deck, k), ("LocalStability", "Ballooning", "ErrorFields", "KineticForces")) + deck["Run"]["output_file"] = joinpath(dir, "gpec.h5") + deck["Tearing"] = Dict{String,Any}("Callen" => Dict{String,Any}()) + for key in ("eq_filename", "kinetic_file") + deck["Equilibrium"][key] = abspath(joinpath(ex_dir, deck["Equilibrium"][key])) + end + open(io -> TOML.print(io, deck), joinpath(dir, "gpec.toml"), "w") + reference = GPEC.main([dir]) + api = solve(CallenProblem(reference.pe)) + @test reference.tearing === nothing && reference.callen isa CallenResult + @test api.hw_v_crit ≈ reference.callen.hw_v_crit rtol = 1e-12 + @test all(isfinite, api.hw_v_crit) + @test isequal(api.hw_min, reference.callen.hw_min) && isequal(api.hw_sat, reference.callen.hw_sat) + h5open(joinpath(dir, "gpec.h5")) do f + @test read(f["Tearing/Callen/hw_v_crit"]) ≈ api.hw_v_crit rtol = 1e-12 + @test haskey(TOML.parse(read(f["Input/gpec_toml_raw"]))["Tearing"], "Callen") + end + end + + # Without a perturbed equilibrium the deck stops at parse time. + mktempdir() do dir + deck_dir = _stage_deck(dir; extra_sections=Dict{String,Any}("Tearing" => Dict{String,Any}("Callen" => Dict{String,Any}()))) + @test_throws "needs a [PerturbedEquilibrium]" GPEC.parse_deck(TOML.parsefile(joinpath(deck_dir, "gpec.toml")), deck_dir) + end + end + @testset "kinetic_format" begin # A rates-v1 kinetic file read through the deck's [Equilibrium] keys and through the API keyword. mktempdir() do dir From d3a9599dcd2553d616e7846899ca1db8a22fe5d1 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 7 Nov 2026 16:08:40 -0500 Subject: [PATCH 06/26] Tearing - DOCS - Describe Callen hw_min and hw_sat as half-widths Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- src/Tearing/ModifiedRutherford/Callen.jl | 2 +- src/Tearing/ModifiedRutherford/HDF5Output.jl | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Tearing/ModifiedRutherford/Callen.jl b/src/Tearing/ModifiedRutherford/Callen.jl index 7510b5f7f..245eb395b 100644 --- a/src/Tearing/ModifiedRutherford/Callen.jl +++ b/src/Tearing/ModifiedRutherford/Callen.jl @@ -44,7 +44,7 @@ does not exist. - `b_res`, `b_vac` - |total| and |vacuum| resonant field [T]. - `hw_isl`, `hw_v` - Total-field and vacuum island half-widths. - `hw_v_crit` - Critical vacuum half-width. - - `hw_min`, `hw_sat` - Smaller and larger positive cubic roots. + - `hw_min`, `hw_sat` - Half of the smaller and larger positive (full-width) cubic roots. - `n_roots` - Number of positive cubic roots (0 or 2). - `b_crit` - Critical vacuum resonant field [T]. """ diff --git a/src/Tearing/ModifiedRutherford/HDF5Output.jl b/src/Tearing/ModifiedRutherford/HDF5Output.jl index 2a0fdf432..473184a63 100644 --- a/src/Tearing/ModifiedRutherford/HDF5Output.jl +++ b/src/Tearing/ModifiedRutherford/HDF5Output.jl @@ -128,8 +128,8 @@ const CALLEN_H5_ANNOTATIONS = [ "hw_isl" => (; long_name="total-field island half-width", units="psi_N", dims=("surface",)), "hw_v" => (; long_name="vacuum island half-width", units="psi_N", dims=("surface",)), "hw_v_crit" => (; long_name="critical vacuum island half-width", units="psi_N", dims=("surface",)), - "hw_min" => (; long_name="smaller positive root of the Callen cubic, NaN if none", units="psi_N", dims=("surface",)), - "hw_sat" => (; long_name="larger positive root of the Callen cubic, NaN if none", units="psi_N", dims=("surface",)), + "hw_min" => (; long_name="island half-width at the smaller positive root of the Callen cubic, NaN if none", units="psi_N", dims=("surface",)), + "hw_sat" => (; long_name="island half-width at the larger positive root of the Callen cubic, NaN if none", units="psi_N", dims=("surface",)), "n_roots" => (; long_name="number of positive roots of the Callen cubic", dims=("surface",)), "b_crit" => (; long_name="critical vacuum resonant field", units="T", dims=("surface",)) ] From 2671e4068642b965869f4b70c34657f6990ba69a Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 7 Nov 2026 21:17:49 -0500 Subject: [PATCH 07/26] Tearing - FEATURE! - Map MRE lengths with radial_label, keep w_sat on unstable surfaces, use true Delta' in Callen - Polarization rho_theta, w_ib and the layer width delta_r map metres to psi_N with the radial_label dr/dpsi used by SLAYER and LayerOverlap, replacing 2 psi_s / r_s. - Linearly unstable surfaces (gamma > 0) keep w_sat; w_m is NaN there. Only interchange instability or D_I >= 0 excludes a surface. - Callen preset takes Delta_c from the computed Delta' referred to rho_tor by default (main adds a Riccati solve); delta_prime_source = "cylindrical" restores Callen's -2m/rho. - The MRE accepts delta_prime_source = "cylindrical" (-2m referred to psi_N), so it runs from a Forward solve without Delta'. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- docs/src/inner_layer.md | 5 +- src/Deck.jl | 26 +++++---- src/GeneralizedPerturbedEquilibrium.jl | 7 ++- src/Tearing/ModifiedRutherford/Callen.jl | 49 +++++++++++++---- src/Tearing/ModifiedRutherford/HDF5Output.jl | 9 ++-- .../ModifiedRutherford/ModifiedRutherford.jl | 2 +- src/Tearing/ModifiedRutherford/Problem.jl | 53 +++++++++++++------ src/Tearing/ModifiedRutherford/Terms.jl | 11 ++-- test/runtests_modified_rutherford.jl | 2 +- test/runtests_solve_api.jl | 29 +++++++--- 10 files changed, 135 insertions(+), 58 deletions(-) diff --git a/docs/src/inner_layer.md b/docs/src/inner_layer.md index 05d5da678..3456d41ed 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -118,12 +118,15 @@ F(w) = \Delta'(w) + \Delta_\mathrm{nc}(w) + \Delta_\mathrm{GGJ}(w) + \Delta_\mat the classical drive with its finite-pressure power law, the bootstrap drive with the transport threshold ``w_d``, the Glasser–Greene–Johnson curvature term and the ion-polarization term (Benjamin et al. 2026, Eqs. 7, 10–14 and C.1). Each term has a switch and a coefficient; the polarization term is off by default. The surface geometry follows the Fortran RDCON `mercier.f` averages, and the densities, temperatures, ``\omega_E``, ``Z_\mathrm{eff}`` and ``\chi_\perp`` come from the equilibrium's kinetic profiles. A `[Tearing]` section holding only this sub-table runs no layer solve. In a script, `solve(ModifiedRutherfordProblem(ffs; polarization=true))` returns the same [`ModifiedRutherfordResult`](@ref Tearing.ModifiedRutherford.ModifiedRutherfordResult). The roots of ``F`` are bracketed on the grid and refined by Brent's method: the seed threshold ``w_m`` is the smallest crossing from ``F<0`` to ``F>0`` and the saturated width ``w_\mathrm{sat}`` the largest from ``F>0`` to ``F<0``. -When the deck also has a `[Tearing]` layer solve, its uncoupled growth rates gate the roots (a surface with ``\gamma>0`` is linearly unstable and gets none) and its resistive layer width sets the lower end of the search; in a script, pass it as `ModifiedRutherfordProblem(ffs; tearing)`. +When the deck also has a `[Tearing]` layer solve, its uncoupled growth rates gate the roots (a surface with ``\gamma>0`` grows from noise, so it has no seed threshold ``w_m``, but keeps its saturated width) and its resistive layer width, converted to ``\psi_N`` with the layer's own radial label, sets the lower end of the search; in a script, pass it as `ModifiedRutherfordProblem(ffs; tearing)`. Surfaces with ``D_R>0`` are excluded as resistive-interchange unstable, and roots below the ion banana width or with ``|\Delta'|w\ge1`` are flagged. +Lengths in metres (the layer width, the poloidal ion Larmor radius and the banana width) map to ``\psi_N`` with the toroidal-flux label of `radial_label`, as SLAYER's do. The island frequency in ``\Delta_\mathrm{pol}`` is ``\omega_{*e}(1+\eta_e/4)`` (Wilson 1996) by default, or the lab-frame ``n\omega_E`` with `omega_model = "exb"`. `[Tearing.Callen]` evaluates Callen's reduced equation for RMP-driven islands on a single-``n`` perturbed equilibrium driven by coils, in the form of Fortran GPEC: the critical vacuum island half-width, the critical vacuum resonant field and the half-widths of the two positive roots of the steady cubic ``\Delta_c w^3 + \Delta'_\mathrm{RMP}w_\mathrm{vac}^2w - w_\mathrm{pol}^2 = 0`` (Callen 2016, Eq. 17), which is posed in the full width ``w`` with ``w_\mathrm{vac} = 2h_v``. The vacuum resonant field is the coils' resonant harmonic on the rational surface over its area. +Its intrinsic index ``\Delta_c`` is the computed ``\Delta'_{mn}`` by default (`main` adds a Riccati solve for it, since the perturbed equilibrium needs a forward one; in a script, pass it as `CallenProblem(pe; delta_prime)`), or Callen's cylindrical ``-2m/\rho`` of Fortran GPEC 1.6.0 with `delta_prime_source = "cylindrical"`. +The same switch on `[Tearing.ModifiedRutherford]` replaces ``\Delta'_{mn}`` by the cylindrical ``r_s\Delta' = -2m``, so the MRE then runs on any solve, forward included, as a reference case. In a script, `solve(CallenProblem(pe))` returns the same [`CallenResult`](@ref Tearing.ModifiedRutherford.CallenResult). ## Numerical notes diff --git a/src/Deck.jl b/src/Deck.jl index 19d17bffe..0b7c0953f 100644 --- a/src/Deck.jl +++ b/src/Deck.jl @@ -390,14 +390,13 @@ function modified_rutherford_from_deck(inputs::AbstractDict) end """ - callen_from_deck(inputs) -> Bool + callen_from_deck(inputs) -> Union{Nothing,Dict{Symbol,Any}} -Whether `[Tearing.Callen]` is present; it takes no keys. +The `CallenProblem` keywords of `[Tearing.Callen]`; `nothing` without that section. """ function callen_from_deck(inputs::AbstractDict) - haskey(inputs, "Tearing") && haskey(inputs["Tearing"], "Callen") || return false - section_keywords(inputs["Tearing"]["Callen"], "Tearing.Callen", _struct_fields(CallenProblem, (:pe,))) - return true + haskey(inputs, "Tearing") && haskey(inputs["Tearing"], "Callen") || return nothing + return section_keywords(inputs["Tearing"]["Callen"], "Tearing.Callen", _struct_fields(CallenProblem, (:pe, :delta_prime))) end """ @@ -500,16 +499,23 @@ function parse_deck(inputs::AbstractDict, path::AbstractString) has_ffs || error("[Tearing.ModifiedRutherford] needs a [ForceFreeStates] solve") (; keywords, algorithm) = force_balance first(keywords[:nn]) == last(keywords[:nn]) || error("[Tearing.ModifiedRutherford] is single-n: set [ForceFreeStates] nn to one toroidal mode number") - (algorithm isa Galerkin && algorithm.rpec_flag) || (algorithm isa Riccati && get(keywords, :vac_flag, false)) || - error("[Tearing.ModifiedRutherford] needs Δ′: use integrator = \"Riccati\" with vac_flag = true, or \"Galerkin\" with [Galerkin] rpec_flag = true") + get(modified_rutherford, :delta_prime_source, :computed) === :cylindrical || + (algorithm isa Galerkin && algorithm.rpec_flag) || (algorithm isa Riccati && get(keywords, :vac_flag, false)) || + error( + "[Tearing.ModifiedRutherford] needs Δ′: use integrator = \"Riccati\" with vac_flag = true, \"Galerkin\" with [Galerkin] rpec_flag = true, " * + "or delta_prime_source = \"cylindrical\"" + ) isempty(get(get(inputs, "Equilibrium", Dict{String,Any}()), "kinetic_file", "")) && error("[Tearing.ModifiedRutherford] needs kinetic profiles: set kinetic_file in [Equilibrium]") end perturbed_equilibrium = perturbed_equilibrium_from_deck(inputs) forcing = forcing_terms_control(inputs, path) callen = callen_from_deck(inputs) - if callen + if callen !== nothing perturbed_equilibrium === nothing && error("[Tearing.Callen] needs a [PerturbedEquilibrium] section") + get(callen, :delta_prime_source, :computed) === :cylindrical || get(force_balance.keywords, :vac_flag, false) || + error("[Tearing.Callen] computes Δ′ with an extra Riccati solve, which needs [ForceFreeStates] vac_flag = true; " * + "or set delta_prime_source = \"cylindrical\"") forcing.forcing_data_format == "coil" || error("[Tearing.Callen] needs a coil forcing: set [ForcingTerms] forcing_data_format = \"coil\"") isempty(get(get(inputs, "Equilibrium", Dict{String,Any}()), "kinetic_file", "")) && error("[Tearing.Callen] needs kinetic profiles: set kinetic_file in [Equilibrium]") @@ -621,7 +627,7 @@ function deck_inputs(deck::NamedTuple, config::Equilibrium.EquilibriumConfig, an end mre = deck.modified_rutherford mre === nothing || (get!(inputs, "Tearing", Dict{String,Any}())["ModifiedRutherford"] = _deck_table(mre)) - deck.callen && (get!(inputs, "Tearing", Dict{String,Any}())["Callen"] = Dict{String,Any}()) + deck.callen === nothing || (get!(inputs, "Tearing", Dict{String,Any}())["Callen"] = _deck_table(deck.callen)) deck.kinetic_forces === nothing || merge!(inputs, deck_sections(deck.kinetic_forces)) pe = deck.perturbed_equilibrium pe === nothing || (inputs["PerturbedEquilibrium"] = _deck_table(merge(pe.response, pe.drive))) @@ -671,7 +677,7 @@ deck_sections(m::ForceFreeStates.MatchResult) = deck_sections(kf::KineticForcesResult) = deck_sections(kf.control) deck_sections(t::TearingResult) = t.model === nothing ? Dict{String,Any}() : _tearing_sections(t.problem, t.problem.critical_resonant_field, t.model, t.config) deck_sections(r::ModifiedRutherfordResult) = Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => _deck_table(r.problem, (:ffs, :tearing)))) -deck_sections(::CallenResult) = Dict{String,Any}("Tearing" => Dict{String,Any}("Callen" => Dict{String,Any}())) +deck_sections(r::CallenResult) = Dict{String,Any}("Tearing" => Dict{String,Any}("Callen" => _deck_table(r.problem, (:pe, :delta_prime)))) deck_sections(ef::ErrorFieldsResult) = _error_fields_sections(ef.settings, ef.tolerances === nothing ? "" : _RECORDED_TOLERANCES) function deck_sections(ffs::ForceFreeStatesResult) diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 7aee845fd..d14e1354f 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -234,9 +234,12 @@ function main_from_inputs( save_to_hdf5(output_file, modified_rutherford) end - if deck.callen + if deck.callen !== nothing callen = _timed_stage(runtimes, "callen", "Callen threshold") do - solve(CallenProblem(pe)) + # The perturbed equilibrium needs a forward solve, so the computed Δ′ comes from a second, Riccati solve. + computed = get(deck.callen, :delta_prime_source, :computed) === :computed + ric = computed ? solve(ForceBalanceProblem(equil; deck.force_balance.keywords...), Riccati(); verbose) : nothing + solve(CallenProblem(pe; delta_prime=ric, deck.callen...)) end save_to_hdf5(output_file, callen) end diff --git a/src/Tearing/ModifiedRutherford/Callen.jl b/src/Tearing/ModifiedRutherford/Callen.jl index 245eb395b..0953ff722 100644 --- a/src/Tearing/ModifiedRutherford/Callen.jl +++ b/src/Tearing/ModifiedRutherford/Callen.jl @@ -4,7 +4,7 @@ # in the Fortran GPEC 1.6.0 form, on every resonant row of a single-n perturbed equilibrium driven by coils. """ - CallenProblem(pe) + CallenProblem(pe; delta_prime=nothing, delta_prime_source=:computed) Callen's reduced MRE for the vacuum-island threshold on every resonant row of the perturbed equilibrium `pe` (a single-n state from `perturbed_equilibrium` with a coil forcing). Half-widths are in metres of the toroidal-flux @@ -12,10 +12,12 @@ radius ρ = (Φ_tor/πB₀)^(1/2) inside, and in ψ_N on output, as in Fortran G - w_pol = ½ q ρ_i/(ρ/R₀)^(1/2) with ρ_i = (2T_i/m_i)^(1/2)/(Z_i e B₀/m_i), R₀ and B₀ on the axis; - Δ_RMP = |Δ|/(2πR₀q) B₀/|B_res|, Δ the singular-current jump over 2πχ₁ and B_res the total resonant field; - - Δ_c = −2m/ρ; + - Δ_c the intrinsic tearing index in 1/m of ρ: the true Δ′_mn of `delta_prime` (a Riccati or Galerkin solve on the + same equilibrium and n), referred to ρ as K^(2α) Δ′_mn/ρ with K = ρ dψ_N/dρ and α = (−D_I)^(1/2) as for SLAYER, with + `delta_prime_source = :computed`; or Callen's cylindrical −2m/ρ (Fortran GPEC 1.6.0) with `:cylindrical`; - critical vacuum half-width h_v,crit = ½ w_pol^(2/3) ((27/4)|Δ_c|)^(1/6)/Δ_RMP^(1/2); - the steady state of Callen's Eq. 17, Δ_c w³ + Δ_RMP w_vac² w − w_pol² = 0, a cubic in the full width w with - w_vac = 2h_v; its positive roots halved are h_min < h_sat; + w_vac = 2h_v; its positive roots halved are h_min < h_sat (with Δ_c ≥ 0 only h_min exists, and h_v,crit = 0); - critical resonant field B_crit = |B_vac| (h_v,crit/h_v)². The vacuum resonant field B_vac is the coils' resonant harmonic on the rational surface over its area. @@ -23,11 +25,17 @@ The vacuum resonant field B_vac is the coils' resonant harmonic on the rational ## Fields - `pe` - Perturbed-equilibrium state, single n, from a coil forcing. + - `delta_prime` - Force-free-states result carrying Δ′ (Riccati or Galerkin) for `:computed`; set by the deck. + - `delta_prime_source` - `:computed` (true Δ′_mn) or `:cylindrical` (−2m/ρ, the Fortran 1.6.0 reference). """ -struct CallenProblem{S} +@kwdef struct CallenProblem{S,D} pe::S + delta_prime::D = nothing + delta_prime_source::Symbol = :computed end +CallenProblem(pe; kwargs...) = CallenProblem(; pe=pe, kwargs...) + """ CallenResult @@ -45,7 +53,7 @@ does not exist. - `hw_isl`, `hw_v` - Total-field and vacuum island half-widths. - `hw_v_crit` - Critical vacuum half-width. - `hw_min`, `hw_sat` - Half of the smaller and larger positive (full-width) cubic roots. - - `n_roots` - Number of positive cubic roots (0 or 2). + - `n_roots` - Number of positive cubic roots (0 or 2; 1 when Δ_c ≥ 0). - `b_crit` - Critical vacuum resonant field [T]. """ struct CallenResult{P} @@ -74,12 +82,16 @@ end """ callen_cubic_roots(a, b, c) -> (x_min, x_sat) -The positive roots of `a x³ + b x + c = 0` with `a < 0`, `b ≥ 0`, `c < 0`, by the trigonometric solution -x_k = 2R cos((θ + 2πk)/3), R = (−p/3)^(1/2), cos θ = −q/(2R³) of the depressed cubic x³ + px + q; `(NaN, NaN)` -when it has no positive root. +The positive roots of `a x³ + b x + c = 0` with `b ≥ 0`, `c < 0`. For `a < 0`, the trigonometric solution +x_k = 2R cos((θ + 2πk)/3), R = (−p/3)^(1/2), cos θ = −q/(2R³) of the depressed cubic x³ + px + q gives two positive +roots or none (`(NaN, NaN)`); for `a > 0` there is one positive root (Cardano), returned as `(x, NaN)`. """ function callen_cubic_roots(a::Real, b::Real, c::Real) p, q = b / a, c / a + if a > 0 + s = sqrt(q^2 / 4 + p^3 / 27) + return (cbrt(-q / 2 + s) + cbrt(-q / 2 - s), NaN) + end p < 0 || return (NaN, NaN) R = sqrt(-p / 3) arg = -q / (2 * R^3) @@ -101,6 +113,15 @@ function _vacuum_resonant_field(pe, equil, psi, n, m, area) return abs(rmp.scale * amplitude) / area end +# Δ′_mn of the surface at ψ from a force-free-states result, referred to the radius ρ in 1/m: K^(2α) Δ′/ρ, K = ρ dψ_N/dρ. +function _callen_delta_c(ric, psi, rho, drho) + k = findfirst(s -> isapprox(s.psifac, psi; atol=1e-8), ric.surfaces) + k === nothing && throw(ArgumentError("CallenProblem: delta_prime has no rational surface at ψ = $psi")) + rg = ric.surfaces[k].restype + alpha = sqrt(max(0.25 - (rg.E + rg.F + rg.H), 0.0)) + return (rho / drho)^(2 * alpha) * real(ric.delta_prime.matrix[k, k]) / rho +end + function CommonSolve.solve(prob::CallenProblem) pe = prob.pe resp = pe.response @@ -110,6 +131,11 @@ function CommonSolve.solve(prob::CallenProblem) data = equil.kinetic data === nothing && throw(ArgumentError("CallenProblem needs kinetic profiles; set kinetic_file in [Equilibrium]")) isempty(pe.island_half_width) && throw(ArgumentError("CallenProblem needs the singular coupling of the perturbed equilibrium")) + prob.delta_prime_source in (:computed, :cylindrical) || + throw(ArgumentError("delta_prime_source must be :computed or :cylindrical, got :$(prob.delta_prime_source)")) + ric = prob.delta_prime + prob.delta_prime_source === :cylindrical || (ric !== nothing && !isempty(ric.delta_prime.matrix)) || + throw(ArgumentError("CallenProblem with delta_prime_source = :computed needs delta_prime, a Riccati or Galerkin solve carrying Δ′")) T_i_of = _slayer_profiles(data, nothing).profiles.T_i species = data.ion_species @@ -136,8 +162,9 @@ function CommonSolve.solve(prob::CallenProblem) w_pol = 0.5 * q * rho_i / sqrt(rho / R0) b_res = abs(pe.resonant_area_weighted_field[row]) delta_rmp = abs(pe.delta_prime[row]) / (2π * R0 * q) * B0 / b_res - delta_c = -2 * m / rho - hw_v_crit = 0.5 * w_pol^(2 / 3) * ((27 / 4) * abs(delta_c))^(1 / 6) / sqrt(delta_rmp) + delta_c = prob.delta_prime_source === :cylindrical ? -2 * m / rho : _callen_delta_c(ric, psi, rho, drho) + # With Δ_c ≥ 0 any vacuum island lets a seed above h_min grow, so the critical vacuum width is zero. + hw_v_crit = delta_c < 0 ? 0.5 * w_pol^(2 / 3) * ((27 / 4) * abs(delta_c))^(1 / 6) / sqrt(delta_rmp) : 0.0 b_vac = _vacuum_resonant_field(pe, equil, psi, n, m, area) shear = n * q1 / q hw_v = sqrt(abs(4 * b_vac * area / (2π * shear * q * chi1))) @@ -148,7 +175,7 @@ function CommonSolve.solve(prob::CallenProblem) b_crit=b_vac * (hw_v_crit / drho / hw_v)^2)) out[k][row] = v end - m_out[row], n_out[row], n_roots[row] = m, n, isnan(x_min) ? 0 : 2 + m_out[row], n_out[row], n_roots[row] = m, n, count(!isnan, (x_min, x_sat)) end return CallenResult(prob, out[:psi], out[:q], m_out, n_out, out[:rho_tor], out[:drho_dpsi], out[:rho_i], out[:w_pol], out[:delta_rmp], out[:delta_c], out[:b_res], out[:b_vac], out[:hw_isl], out[:hw_v], out[:hw_v_crit], out[:hw_min], out[:hw_sat], n_roots, out[:b_crit]) diff --git a/src/Tearing/ModifiedRutherford/HDF5Output.jl b/src/Tearing/ModifiedRutherford/HDF5Output.jl index 473184a63..6144c6fed 100644 --- a/src/Tearing/ModifiedRutherford/HDF5Output.jl +++ b/src/Tearing/ModifiedRutherford/HDF5Output.jl @@ -52,7 +52,10 @@ const MRE_H5_ANNOTATIONS = [ "PerSurface/q" => (; long_name="safety factor at the surface", dims=("surface",)), "PerSurface/m" => (; long_name="resonant poloidal mode number", dims=("surface",)), "PerSurface/n" => (; long_name="resonant toroidal mode number", dims=("surface",)), - "PerSurface/delta_prime" => (; long_name="real part of the diagonal Delta' (PEST-3 convention), times delta_prime_factor", dims=("surface",)), + "PerSurface/delta_prime" => (; + long_name="real part of the diagonal Delta' (PEST-3 convention; cylindrical -2m referred to psi_N with delta_prime_source = cylindrical), times delta_prime_factor", + dims=("surface",) + ), "PerSurface/delta_prime_imag" => (; long_name="imaginary part of the diagonal Delta'", dims=("surface",)), "PerSurface/D_I" => (; long_name="Mercier index D_I = E + F + H - 1/4", dims=("surface",)), "PerSurface/D_R" => (; long_name="resistive interchange index D_R = E + F + H^2", dims=("surface",)), @@ -83,7 +86,7 @@ const MRE_H5_ANNOTATIONS = [ "PerSurface/fully_stabilized" => (; long_name="1 if F < 0 on the whole search range", dims=("surface",)), "PerSurface/unstable_any_seed" => (; long_name="1 if F > 0 at w_lo", dims=("surface",)), "PerSurface/interchange_unstable" => (; long_name="1 if D_R > 0 (excluded)", dims=("surface",)), - "PerSurface/linear_unstable" => (; long_name="1 if the gate's growth rate is positive (excluded)", dims=("surface",)), + "PerSurface/linear_unstable" => (; long_name="1 if the gate's growth rate is positive (no seed threshold, w_sat kept)", dims=("surface",)), "PerSurface/below_w_ib" => (; long_name="1 if w_m < w_ib", dims=("surface",)), "PerSurface/large_delta_prime_w" => (; long_name="1 if |Delta'| w >= 1 at w_m or w_sat", dims=("surface",)), "PerSurface/complex_delta_prime" => (; long_name="1 if |Im Delta'| > 1e-3 |Re Delta'|", dims=("surface",)), @@ -122,7 +125,7 @@ const CALLEN_H5_ANNOTATIONS = [ "rho_i" => (; long_name="ion gyroradius in the axis field", units="m", dims=("surface",)), "w_pol" => (; long_name="polarization width 0.5 q rho_i/(rho/R0)^(1/2)", units="m", dims=("surface",)), "delta_rmp" => (; long_name="Callen Delta'_RMP from the total resonant field", units="1/m", dims=("surface",)), - "delta_c" => (; long_name="Callen classical Delta' = -2m/rho", units="1/m", dims=("surface",)), + "delta_c" => (; long_name="Callen intrinsic Delta' in 1/m of rho: computed Delta'_mn, or -2m/rho with delta_prime_source = cylindrical", units="1/m", dims=("surface",)), "b_res" => (; long_name="total resonant field |flux|/area", units="T", dims=("surface",)), "b_vac" => (; long_name="vacuum (coil) resonant field |flux|/area", units="T", dims=("surface",)), "hw_isl" => (; long_name="total-field island half-width", units="psi_N", dims=("surface",)), diff --git a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl index 218af7bcd..c3e921ff8 100644 --- a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl +++ b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl @@ -13,7 +13,7 @@ import ...ForcingTerms import ...Utilities: save_to_hdf5 using ...Utilities.HDF5Annotations: replace_group! using ...Utilities.PhysicalConstants: M_E, M_P, E_CHG, MU_0 -using ...InnerLayer.SLAYER: _slayer_profiles +using ...InnerLayer.SLAYER: _slayer_profiles, radial_label using Roots: find_zero, Brent include("Geometry.jl") diff --git a/src/Tearing/ModifiedRutherford/Problem.jl b/src/Tearing/ModifiedRutherford/Problem.jl index 5d1a40850..565c33056 100644 --- a/src/Tearing/ModifiedRutherford/Problem.jl +++ b/src/Tearing/ModifiedRutherford/Problem.jl @@ -11,21 +11,26 @@ The steady modified Rutherford equation (MRE) on every rational surface of a fin F(w) = s_Δ Δ′(w) + s_nc Δ_nc(w) + s_GGJ Δ_GGJ(w) + s_pol Δ_pol(w), with `w` the full island width in normalized poloidal flux ψ_N. Each term is evaluated on the grid whether or not its -switch is on; the switches select which terms `F` sums. Δ′ is the real part of the diagonal of `ffs.delta_prime`, +switch is on; the switches select which terms `F` sums. Δ′ is the real part of the diagonal of `ffs.delta_prime` +(`delta_prime_source = :computed`) or the cylindrical vacuum value r_s Δ′ = −2m referred to ψ_N as SLAYER refers Δ′ to +r_s (`:cylindrical`, which needs no Δ′ solve); the Mercier, resistive-interchange and Pfirsch-Schlüter indices D_I, D_R = E + F + H², H come from the surfaces' `ResistGeometry`, and the plasma (densities, temperatures, Z_eff, χ⊥,e, E×B rotation, ion species) is the equilibrium's kinetic data, as every tearing layer reads it. A deck states it as `[Tearing.ModifiedRutherford]`. The roots of F are refined between grid points from `w_lo = max(δ_r, w_min)`: the seed threshold `w_m` is the smallest crossing from F < 0 to F > 0 and the saturated width `w_sat` the largest from F > 0 to F < 0. Surfaces that -are resistive-interchange unstable (D_R > 0) or linearly tearing unstable (γ > 0 in `tearing`) get no roots. +are resistive-interchange unstable (D_R > 0) get no roots; linearly tearing unstable ones (γ > 0 in `tearing`) grow +from noise, so they get no seed threshold but keep `w_sat`. ## Fields - - `ffs` - Outer solution with a Δ′ matrix (Riccati with `vac_flag=true`, or Galerkin with `rpec_flag=true`), single n. + - `ffs` - Outer solution, single n, with a Δ′ matrix (Riccati with `vac_flag=true`, or Galerkin with `rpec_flag=true`) + unless `delta_prime_source = :cylindrical`. - `tearing` - Linear tearing solve on the same Δ′ (the gate and the layer width δ_r), or `nothing`; set by the deck when `[Tearing]` is present. - `classical`, `bootstrap`, `curvature`, `polarization` - Term switches s_Δ, s_nc, s_GGJ, s_pol. + - `delta_prime_source` - `:computed` (Δ′_mn of `ffs`) or `:cylindrical` (r_s Δ′_mn = −2m, a reference case). - `delta_prime_model` - `:mercier_power` (Benjamin Eq. 10) or `:constant` (Δ′(w) = Δ′_mn). - `delta_prime_factor`, `D_nc_factor`, `rho_theta_factor` - Multipliers on Δ′_mn, D_nc and the poloidal ion Larmor radius ρ_θi (which also sets w_ib), for scans. @@ -48,6 +53,7 @@ are resistive-interchange unstable (D_R > 0) or linearly tearing unstable (γ > bootstrap::Bool = true curvature::Bool = true polarization::Bool = false + delta_prime_source::Symbol = :computed delta_prime_model::Symbol = :mercier_power delta_prime_factor::Float64 = 1.0 D_nc_factor::Float64 = 1.0 @@ -88,11 +94,11 @@ Output of `solve(::ModifiedRutherfordProblem)`. Per-surface vectors run core to banana width in ψ_N, inverse aspect ratio. - `gamma`, `delta_r` - Linear growth rate [1/s] and resistive layer width in ψ_N from `tearing` (`NaN` without it). - `w_lo` - Lower end of the root search. - - `w_m`, `w_sat` - Seed threshold and saturated full widths in ψ_N; `NaN` when absent or excluded. + - `w_m`, `w_sat` - Seed threshold and saturated full widths in ψ_N; `NaN` when absent or excluded, and `w_m` `NaN` when γ > 0. - `w_m_star` - Smallest `w_m` over the surfaces. - `fully_stabilized` - F < 0 on the whole search range. - `unstable_any_seed` - F > 0 at `w_lo`. - - `interchange_unstable`, `linear_unstable` - Excluded by D_R > 0, or by γ > 0. + - `interchange_unstable`, `linear_unstable` - D_R > 0 (excluded), or γ > 0 (no seed threshold; `w_sat` kept). - `below_w_ib` - `w_m` below the ion banana width, where the polarization ordering fails. - `large_delta_prime_w` - |Δ′| w ≥ 1 at `w_m` or `w_sat`. - `complex_delta_prime` - |Im Δ′_mn| > 10⁻³ |Re Δ′_mn|. @@ -150,10 +156,19 @@ end function validate(prob::ModifiedRutherfordProblem) ffs = prob.ffs ffs.npert == 1 || throw(ArgumentError("ModifiedRutherfordProblem is single-n; the solve spans n = $(ffs.nlow):$(ffs.nhigh)")) - dp = ffs.delta_prime - dp === nothing && throw(ArgumentError("ModifiedRutherfordProblem needs Δ′: solve with Riccati() and vac_flag=true, or Galerkin(; rpec_flag=true)")) - size(dp.matrix, 1) == length(ffs.surfaces) || - throw(ArgumentError("ModifiedRutherfordProblem: the Δ′ matrix is $(size(dp.matrix, 1))×$(size(dp.matrix, 2)) for $(length(ffs.surfaces)) surfaces")) + prob.delta_prime_source in (:computed, :cylindrical) || throw(ArgumentError("delta_prime_source must be :computed or :cylindrical")) + if prob.delta_prime_source === :computed + dp = ffs.delta_prime + (dp === nothing || isempty(dp.matrix)) && + throw( + ArgumentError( + "ModifiedRutherfordProblem needs Δ′: solve with Riccati() and vac_flag=true, Galerkin(; rpec_flag=true), " * + "or set delta_prime_source = :cylindrical" + ) + ) + size(dp.matrix, 1) == length(ffs.surfaces) || + throw(ArgumentError("ModifiedRutherfordProblem: the Δ′ matrix is $(size(dp.matrix, 1))×$(size(dp.matrix, 2)) for $(length(ffs.surfaces)) surfaces")) + end ffs.equil.kinetic === nothing && throw(ArgumentError("ModifiedRutherfordProblem needs kinetic profiles; set kinetic_file in [Equilibrium]")) prob.delta_prime_model in (:mercier_power, :constant) || throw(ArgumentError("delta_prime_model must be :mercier_power or :constant")) prob.pol_model in (:full, :reduced) || throw(ArgumentError("pol_model must be :full or :reduced")) @@ -214,6 +229,8 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) m_i = sum(s.fraction * s.m for s in species) / sum(s.fraction for s in species) * M_P z_i = Float64(first(species).z) gate = prob.tearing + # Metres map to ψ_N with the toroidal-flux radial label's dr/dψ, the label SLAYER's layer widths use. + r_at, dr_dpsi = radial_label(equil) chi_gate = gate === nothing || !hasproperty(gate.model, :chi_perp_Dc) ? nothing : gate.model.chi_perp_Dc vec0() = zeros(ns) @@ -249,11 +266,12 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) D_nc, mu_ratio = bootstrap_coefficient(g, zeff) D_nc *= prob.D_nc_factor - # Polarization inputs (Benjamin App. C); widths in metres map to ψ_N by w/(2ψ_s) ↔ ρ/r_s. + # Polarization inputs (Benjamin App. C); lengths in metres map to ψ_N with dr/dψ. v_te = sqrt(2 * E_CHG * T_e / M_E) v_ti = sqrt(2 * E_CHG * T_i / m_i) rho_theta = prob.rho_theta_factor * m_i * v_ti / (g.avg_bpol * E_CHG) - w_ib = sqrt(g.eps) * rho_theta * 2 * psi / g.r_minor + rho_theta_psi = rho_theta / abs(dr_dpsi(psi)) + w_ib = sqrt(g.eps) * rho_theta_psi beta_p = 2 * MU_0 * n_i * E_CHG * T_i / g.avg_bpolsq gradient_ratio = (dn_i / n_i) / (g.q1 / g.q) # Diamagnetic frequencies from the density gradients, in SLAYER's sign convention (charge −1 for electrons). @@ -265,7 +283,9 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) n * (loaded.has_omega_E ? Float64(loaded.profiles.omega_E(psi)) : 0.0) omega_hat = something(prob.omega_ratio, prob.omega_factor * omega_model / omega_star_i) - dp_complex = ffs.delta_prime.matrix[k, k] + # Cylindrical: r_s Δ′ = −2m, referred back to ψ_N by K^(−2α), K = r_s dψ_N/dr, α = √(−D_I) (as delta_prime_to_rs_reference). + dp_complex = prob.delta_prime_source === :computed ? ffs.delta_prime.matrix[k, k] : + complex(-2 * m * (r_at(psi) / abs(dr_dpsi(psi)))^(-2 * sqrt(max(-D_I, 0.0)))) dp_mn = prob.delta_prime_factor * real(dp_complex) function terms(wj) wd = transport_width(wj; prob.c_wd, chi_perp, v_te, R0=g.R_major, g.q, g.q1, n, m, g.Wc_prefac) @@ -275,7 +295,7 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) pl = prob.pol_sign * ( prob.pol_model === :full ? - polarization_term(wj; prob.c_pol, g.eps, rho_theta_over_rs=rho_theta / g.r_minor, psi_s=psi, beta_p, + polarization_term(wj; prob.c_pol, g.eps, rho_theta_psi, beta_p, gradient_ratio, omega_hat, omega_hat_pi, w_ib) : reduced_polarization_term(wj; prob.c_pol, prob.C_pol, w_ib) ) @@ -291,14 +311,15 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) # Gates: the linear tearing growth rate and resistive layer width from `tearing`, and resistive interchange. row = gate === nothing ? nothing : _tearing_row(gate, psi) gamma = row === nothing ? NaN : gate.gamma[row] - delta_r = row === nothing || isempty(gate.layer_widths) ? NaN : 2 * psi * gate.layer_widths[row].delta_s_m / g.r_minor + delta_r = row === nothing || isempty(gate.layer_widths) ? NaN : gate.layer_widths[row].delta_s_m / abs(dr_dpsi(psi)) w_lo = isnan(delta_r) ? prob.w_min : max(delta_r, prob.w_min) flags.interchange_unstable[k] = D_R > 0 flags.linear_unstable[k] = gamma > 0 flags.complex_delta_prime[k] = abs(imag(dp_complex)) > 1e-3 * abs(real(dp_complex)) r = mre_roots(wj -> terms(wj).F, w_lo, w) - excluded = flags.interchange_unstable[k] || flags.linear_unstable[k] || D_I >= 0 - w_m, w_sat = excluded ? (NaN, NaN) : (r.w_m, r.w_sat) + excluded = flags.interchange_unstable[k] || D_I >= 0 + # A linearly unstable surface grows from noise, so it has no seed threshold; the MRE still sets its saturation. + w_m, w_sat = excluded ? (NaN, NaN) : (flags.linear_unstable[k] ? NaN : r.w_m, r.w_sat) flags.fully_stabilized[k], flags.unstable_any_seed[k] = !excluded && r.fully_stabilized, !excluded && r.unstable_any_seed flags.below_w_ib[k] = w_m < w_ib flags.large_delta_prime_w[k] = abs(dp_mn) * w_m >= 1 || abs(dp_mn) * w_sat >= 1 diff --git a/src/Tearing/ModifiedRutherford/Terms.jl b/src/Tearing/ModifiedRutherford/Terms.jl index eb0dc23f6..b59969369 100644 --- a/src/Tearing/ModifiedRutherford/Terms.jl +++ b/src/Tearing/ModifiedRutherford/Terms.jl @@ -36,16 +36,17 @@ function curvature_term(D_R::Real, H::Real, D_I::Real, w_d::Real, w::Real; k1::R end """ - polarization_term(w; c_pol, eps, rho_theta_over_rs, psi_s, beta_p, gradient_ratio, omega_hat, omega_hat_pi, w_ib) -> Float64 + polarization_term(w; c_pol, eps, rho_theta_psi, beta_p, gradient_ratio, omega_hat, omega_hat_pi, w_ib) -> Float64 Ion-polarization term of Benjamin Eq. C.1, -`−c_pol 3.28 ε^(3/2) I₁ [(ρ_iθ/r_s)/(w/(2ψ_s))]² (β_p/w) G² ω̂(ω̂ − ω̂_pi) w⁴/(w⁴ + w_ib⁴)`, with `I₁ = 0.71`, +`−c_pol 3.28 ε^(3/2) I₁ (ρ_iθ,ψ/w)² (β_p/w) G² ω̂(ω̂ − ω̂_pi) w⁴/(w⁴ + w_ib⁴)`, with `I₁ = 0.71`, the poloidal Larmor +radius `ρ_iθ,ψ` in ψ_N (Benjamin's `(ρ_iθ/r_s)/(w/2ψ_s)` with its `dψ/dr = 2ψ_s/r_s` replaced by the layer label's), `G = (n_i⁻¹ dn_i/dψ)/(q⁻¹ dq/dψ)` and the island frequency `ω̂ = ω/ω_*i` and `ω̂_pi = ω_*pi/ω_*i` normalized to the ion diamagnetic frequency. Zero at `ω̂ = 0` and at `ω̂ = ω̂_pi`; stabilizing for `ω̂(ω̂ − ω̂_pi) > 0`. """ -function polarization_term(w::Real; c_pol::Real, eps::Real, rho_theta_over_rs::Real, psi_s::Real, beta_p::Real, - gradient_ratio::Real, omega_hat::Real, omega_hat_pi::Real, w_ib::Real) - return -c_pol * 3.28 * eps^1.5 * 0.71 * (rho_theta_over_rs / (w / (2 * psi_s)))^2 * (beta_p / w) * gradient_ratio^2 * +function polarization_term(w::Real; c_pol::Real, eps::Real, rho_theta_psi::Real, beta_p::Real, gradient_ratio::Real, omega_hat::Real, + omega_hat_pi::Real, w_ib::Real) + return -c_pol * 3.28 * eps^1.5 * 0.71 * (rho_theta_psi / w)^2 * (beta_p / w) * gradient_ratio^2 * omega_hat * (omega_hat - omega_hat_pi) * w^4 / (w^4 + w_ib^4) end diff --git a/test/runtests_modified_rutherford.jl b/test/runtests_modified_rutherford.jl index d58f59bb6..35fbb8ebc 100644 --- a/test/runtests_modified_rutherford.jl +++ b/test/runtests_modified_rutherford.jl @@ -30,7 +30,7 @@ using TOML end @testset "polarization term: sign follows ω̂(ω̂ − ω̂_pi), zero at ω̂ = 0" begin - args = (; c_pol=1.0, eps=0.2, rho_theta_over_rs=0.01, psi_s=0.6, beta_p=0.5, gradient_ratio=-0.8, omega_hat_pi=1.5, w_ib=1e-3) + args = (; c_pol=1.0, eps=0.2, rho_theta_psi=0.012, beta_p=0.5, gradient_ratio=-0.8, omega_hat_pi=1.5, w_ib=1e-3) pol(ω̂) = MR.polarization_term(0.02; args..., omega_hat=ω̂) @test pol(0.0) == 0 @test pol(1.5) == 0 diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index 79a8cebc4..fd32c4e80 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -623,26 +623,39 @@ using HDF5 end @testset "Callen threshold under [Tearing.Callen]" begin - # The coil-driven DIII-D-like example with the empty sub-table, against solve(CallenProblem(pe)). + # The coil-driven DIII-D-like example: Callen with the computed Δ′ (main adds a Riccati solve) and the + # cylindrical MRE on the same forward solve, against the API. ex_dir = joinpath(@__DIR__, "..", "examples", "DIIID-like_ideal_example") mktempdir() do dir deck = TOML.parsefile(joinpath(ex_dir, "gpec.toml")) foreach(k -> delete!(deck, k), ("LocalStability", "Ballooning", "ErrorFields", "KineticForces")) deck["Run"]["output_file"] = joinpath(dir, "gpec.h5") - deck["Tearing"] = Dict{String,Any}("Callen" => Dict{String,Any}()) + deck["Tearing"] = Dict{String,Any}("Callen" => Dict{String,Any}(), + "ModifiedRutherford" => Dict{String,Any}("delta_prime_source" => "cylindrical", "nw" => 40)) for key in ("eq_filename", "kinetic_file") deck["Equilibrium"][key] = abspath(joinpath(ex_dir, deck["Equilibrium"][key])) end open(io -> TOML.print(io, deck), joinpath(dir, "gpec.toml"), "w") reference = GPEC.main([dir]) - api = solve(CallenProblem(reference.pe)) @test reference.tearing === nothing && reference.callen isa CallenResult - @test api.hw_v_crit ≈ reference.callen.hw_v_crit rtol = 1e-12 - @test all(isfinite, api.hw_v_crit) - @test isequal(api.hw_min, reference.callen.hw_min) && isequal(api.hw_sat, reference.callen.hw_sat) + + ric = solve(ForceBalanceProblem(reference.ffs.equil; GPEC.parse_deck(deck, dir).force_balance.keywords...), Riccati()) + api = solve(CallenProblem(reference.pe; delta_prime=ric)) + @test api.delta_c ≈ reference.callen.delta_c rtol = 1e-10 + @test api.hw_v_crit ≈ reference.callen.hw_v_crit rtol = 1e-10 + @test isequal(api.n_roots, reference.callen.n_roots) + cyl = solve(CallenProblem(reference.pe; delta_prime_source=:cylindrical)) + @test cyl.delta_c ≈ -2 .* cyl.m ./ cyl.rho_tor + @test_throws "needs delta_prime" solve(CallenProblem(reference.pe)) + + mre = solve(ModifiedRutherfordProblem(reference.ffs; delta_prime_source=:cylindrical, nw=40)) + @test mre.F ≈ reference.modified_rutherford.F rtol = 1e-10 + @test isequal(mre.w_m, reference.modified_rutherford.w_m) + @test all(<(0), mre.delta_prime) h5open(joinpath(dir, "gpec.h5")) do f - @test read(f["Tearing/Callen/hw_v_crit"]) ≈ api.hw_v_crit rtol = 1e-12 - @test haskey(TOML.parse(read(f["Input/gpec_toml_raw"]))["Tearing"], "Callen") + @test read(f["Tearing/Callen/delta_c"]) ≈ api.delta_c rtol = 1e-10 + recorded = TOML.parse(read(f["Input/gpec_toml_raw"]))["Tearing"] + @test haskey(recorded, "Callen") && recorded["ModifiedRutherford"]["delta_prime_source"] == "cylindrical" end end From c61eba5d97d33f887e011d8bdb725fc06023cabb Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 7 Nov 2026 21:31:54 -0500 Subject: [PATCH 08/26] Tearing - FEATURE - Add finite-width saturation term to the modified Rutherford equation saturation = true replaces r_s Delta' by r_s Delta'(0) - C_sat W/r_s (Fitzpatrick 2023 Eq. 9.15, after Hastie, Militello & Porcelli 2005), C_sat = 0.8 alpha_s^2 - 0.27 beta_s - 0.09 alpha_s, with alpha_s, beta_s from J_z = (2 - s)/q in the radial_label radius. beta_s takes r^2 (dimensionless), which reproduces Fitzpatrick's Fig. 9.2. The correction is referred to psi_N as the outer Delta' is and carried through the classical term's pressure factor. Off by default; the unit test checks the coefficients against the Wesson cylinder and the root against Eq. 9.21. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- docs/src/inner_layer.md | 2 +- src/Tearing/ModifiedRutherford/HDF5Output.jl | 9 +++- .../ModifiedRutherford/ModifiedRutherford.jl | 3 +- src/Tearing/ModifiedRutherford/Problem.jl | 41 +++++++++++++------ src/Tearing/ModifiedRutherford/Terms.jl | 32 +++++++++++++++ test/runtests_modified_rutherford.jl | 25 +++++++++++ test/runtests_solve_api.jl | 7 +++- 7 files changed, 100 insertions(+), 19 deletions(-) diff --git a/docs/src/inner_layer.md b/docs/src/inner_layer.md index 3456d41ed..2fb80a794 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -115,7 +115,7 @@ The DIII-D-like kinetic file of the examples carries analytic χ profiles of the F(w) = \Delta'(w) + \Delta_\mathrm{nc}(w) + \Delta_\mathrm{GGJ}(w) + \Delta_\mathrm{pol}(w; \omega), ``` -the classical drive with its finite-pressure power law, the bootstrap drive with the transport threshold ``w_d``, the Glasser–Greene–Johnson curvature term and the ion-polarization term (Benjamin et al. 2026, Eqs. 7, 10–14 and C.1). Each term has a switch and a coefficient; the polarization term is off by default. The surface geometry follows the Fortran RDCON `mercier.f` averages, and the densities, temperatures, ``\omega_E``, ``Z_\mathrm{eff}`` and ``\chi_\perp`` come from the equilibrium's kinetic profiles. A `[Tearing]` section holding only this sub-table runs no layer solve. In a script, `solve(ModifiedRutherfordProblem(ffs; polarization=true))` returns the same [`ModifiedRutherfordResult`](@ref Tearing.ModifiedRutherford.ModifiedRutherfordResult). +the classical drive with its finite-pressure power law, the bootstrap drive with the transport threshold ``w_d``, the Glasser–Greene–Johnson curvature term and the ion-polarization term (Benjamin et al. 2026, Eqs. 7, 10–14 and C.1). Each term has a switch and a coefficient; the polarization term is off by default. A finite-width saturation term, `saturation = true` (off by default), replaces ``r_s\Delta'`` by ``r_s\Delta'(0) - C_\mathrm{sat}W/r_s`` with ``C_\mathrm{sat} = 0.8\alpha_s^2 - 0.27\beta_s - 0.09\alpha_s`` from the current-density gradients at the surface (Fitzpatrick 2023, Eq. 9.15), so that a surface with ``\Delta' > 0`` saturates. The surface geometry follows the Fortran RDCON `mercier.f` averages, and the densities, temperatures, ``\omega_E``, ``Z_\mathrm{eff}`` and ``\chi_\perp`` come from the equilibrium's kinetic profiles. A `[Tearing]` section holding only this sub-table runs no layer solve. In a script, `solve(ModifiedRutherfordProblem(ffs; polarization=true))` returns the same [`ModifiedRutherfordResult`](@ref Tearing.ModifiedRutherford.ModifiedRutherfordResult). The roots of ``F`` are bracketed on the grid and refined by Brent's method: the seed threshold ``w_m`` is the smallest crossing from ``F<0`` to ``F>0`` and the saturated width ``w_\mathrm{sat}`` the largest from ``F>0`` to ``F<0``. When the deck also has a `[Tearing]` layer solve, its uncoupled growth rates gate the roots (a surface with ``\gamma>0`` grows from noise, so it has no seed threshold ``w_m``, but keeps its saturated width) and its resistive layer width, converted to ``\psi_N`` with the layer's own radial label, sets the lower end of the search; in a script, pass it as `ModifiedRutherfordProblem(ffs; tearing)`. diff --git a/src/Tearing/ModifiedRutherford/HDF5Output.jl b/src/Tearing/ModifiedRutherford/HDF5Output.jl index 6144c6fed..ebc78611e 100644 --- a/src/Tearing/ModifiedRutherford/HDF5Output.jl +++ b/src/Tearing/ModifiedRutherford/HDF5Output.jl @@ -37,10 +37,10 @@ end const _MRE_PER_SURFACE = (:psi, :q, :m, :n, :delta_prime, :delta_prime_imag, :D_I, :D_R, :H, :D_nc, :f_trap, :mu_ratio, :zeff, :Wc_prefac, :chi_perp, :v_te, :R0, :omega_star_i, :omega_star_e, :omega_hat, :omega_hat_pi, :beta_p, :rho_theta, :w_ib, :eps, :gamma, :delta_r, :w_lo, - :w_m, :w_sat) + :alpha_s, :beta_s, :C_sat, :w_m, :w_sat) const _MRE_FLAGS = (:fully_stabilized, :unstable_any_seed, :interchange_unstable, :linear_unstable, :below_w_ib, :large_delta_prime_w, :complex_delta_prime) -const _MRE_TERMS = (:F, :classical, :bootstrap, :curvature, :polarization, :w_d) +const _MRE_TERMS = (:F, :classical, :saturation, :bootstrap, :curvature, :polarization, :w_d) # Julia's surface × width matrices land in HDF5 as (width, surface). const _MRE_TERM_DIMS = ("w", "surface") @@ -80,6 +80,10 @@ const MRE_H5_ANNOTATIONS = [ "PerSurface/gamma" => (; long_name="linear tearing growth rate of the gate, NaN without one", units="1/s", dims=("surface",)), "PerSurface/delta_r" => (; long_name="resistive layer width of the gate, NaN without one", units="psi_N", dims=("surface",)), "PerSurface/w_lo" => (; long_name="lower end of the root search max(delta_r, w_min)", units="psi_N", dims=("surface",)), + "PerSurface/alpha_s" => (; long_name="saturation current-gradient coefficient alpha_s = -r q J_z'/s (Fitzpatrick 2023 Eq. 9.16)", dims=("surface",)), + "PerSurface/beta_s" => (; long_name="saturation current-curvature coefficient beta_s = -r^2 q J_z''/s", dims=("surface",)), + "PerSurface/C_sat" => (; long_name="saturation coefficient 0.8 alpha_s^2 - 0.27 beta_s - 0.09 alpha_s (Fitzpatrick 2023 Eq. 9.15)", + dims=("surface",)), "PerSurface/w_m" => (; long_name="seed threshold: smallest F crossing from negative to positive, NaN if none", units="psi_N", dims=("surface",)), "PerSurface/w_sat" => (; long_name="saturated width: largest F crossing from positive to negative, NaN if none", units="psi_N", dims=("surface",)), @@ -92,6 +96,7 @@ const MRE_H5_ANNOTATIONS = [ "PerSurface/complex_delta_prime" => (; long_name="1 if |Im Delta'| > 1e-3 |Re Delta'|", dims=("surface",)), "Terms/F" => (; long_name="modified Rutherford F(w), the sum of the switched-on terms", dims=_MRE_TERM_DIMS), "Terms/classical" => (; long_name="classical term Delta'(w)", dims=_MRE_TERM_DIMS), + "Terms/saturation" => (; long_name="finite-width saturation term Delta_sat(w)", dims=_MRE_TERM_DIMS), "Terms/bootstrap" => (; long_name="bootstrap term Delta_nc(w)", dims=_MRE_TERM_DIMS), "Terms/curvature" => (; long_name="curvature term Delta_GGJ(w)", dims=_MRE_TERM_DIMS), "Terms/polarization" => (; long_name="ion polarization term Delta_pol(w), including pol_sign", dims=_MRE_TERM_DIMS), diff --git a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl index c3e921ff8..d613cbbb8 100644 --- a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl +++ b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl @@ -23,7 +23,8 @@ include("Callen.jl") include("HDF5Output.jl") export MRESurfaceGeometry, mre_geometry, sauter2002_trapped_fraction, bootstrap_coefficient -export classical_term, bootstrap_term, curvature_term, polarization_term, reduced_polarization_term, transport_width +export classical_term, saturation_coefficient, saturation_term, bootstrap_term, curvature_term, polarization_term, reduced_polarization_term, + transport_width export mre_roots, ModifiedRutherfordProblem, ModifiedRutherfordResult, CallenProblem, CallenResult, callen_cubic_roots end # module ModifiedRutherford diff --git a/src/Tearing/ModifiedRutherford/Problem.jl b/src/Tearing/ModifiedRutherford/Problem.jl index 565c33056..b281aa7e1 100644 --- a/src/Tearing/ModifiedRutherford/Problem.jl +++ b/src/Tearing/ModifiedRutherford/Problem.jl @@ -8,7 +8,7 @@ The steady modified Rutherford equation (MRE) on every rational surface of a finished force-balance solve, - F(w) = s_Δ Δ′(w) + s_nc Δ_nc(w) + s_GGJ Δ_GGJ(w) + s_pol Δ_pol(w), + F(w) = s_Δ Δ′(w) + s_sat Δ_sat(w) + s_nc Δ_nc(w) + s_GGJ Δ_GGJ(w) + s_pol Δ_pol(w), with `w` the full island width in normalized poloidal flux ψ_N. Each term is evaluated on the grid whether or not its switch is on; the switches select which terms `F` sums. Δ′ is the real part of the diagonal of `ffs.delta_prime` @@ -29,7 +29,9 @@ from noise, so they get no seed threshold but keep `w_sat`. unless `delta_prime_source = :cylindrical`. - `tearing` - Linear tearing solve on the same Δ′ (the gate and the layer width δ_r), or `nothing`; set by the deck when `[Tearing]` is present. - - `classical`, `bootstrap`, `curvature`, `polarization` - Term switches s_Δ, s_nc, s_GGJ, s_pol. + - `classical`, `saturation`, `bootstrap`, `curvature`, `polarization` - Term switches s_Δ, s_sat, s_nc, s_GGJ, s_pol. + `saturation` is the finite-width correction to Δ′ ([`saturation_term`](@ref)), off by default. + - `saturation_dpsi` - ψ_N step of the differences that give J_z′ and J_z″ in [`saturation_coefficient`](@ref). - `delta_prime_source` - `:computed` (Δ′_mn of `ffs`) or `:cylindrical` (r_s Δ′_mn = −2m, a reference case). - `delta_prime_model` - `:mercier_power` (Benjamin Eq. 10) or `:constant` (Δ′(w) = Δ′_mn). - `delta_prime_factor`, `D_nc_factor`, `rho_theta_factor` - Multipliers on Δ′_mn, D_nc and the poloidal ion Larmor @@ -53,6 +55,8 @@ from noise, so they get no seed threshold but keep `w_sat`. bootstrap::Bool = true curvature::Bool = true polarization::Bool = false + saturation::Bool = false + saturation_dpsi::Float64 = 0.01 delta_prime_source::Symbol = :computed delta_prime_model::Symbol = :mercier_power delta_prime_factor::Float64 = 1.0 @@ -94,6 +98,7 @@ Output of `solve(::ModifiedRutherfordProblem)`. Per-surface vectors run core to banana width in ψ_N, inverse aspect ratio. - `gamma`, `delta_r` - Linear growth rate [1/s] and resistive layer width in ψ_N from `tearing` (`NaN` without it). - `w_lo` - Lower end of the root search. + - `alpha_s`, `beta_s`, `C_sat` - Current-gradient coefficients of the saturation term and its coefficient. - `w_m`, `w_sat` - Seed threshold and saturated full widths in ψ_N; `NaN` when absent or excluded, and `w_m` `NaN` when γ > 0. - `w_m_star` - Smallest `w_m` over the surfaces. - `fully_stabilized` - F < 0 on the whole search range. @@ -102,7 +107,7 @@ Output of `solve(::ModifiedRutherfordProblem)`. Per-surface vectors run core to - `below_w_ib` - `w_m` below the ion banana width, where the polarization ordering fails. - `large_delta_prime_w` - |Δ′| w ≥ 1 at `w_m` or `w_sat`. - `complex_delta_prime` - |Im Δ′_mn| > 10⁻³ |Re Δ′_mn|. - - `F`, `classical`, `bootstrap`, `curvature`, `polarization`, `w_d` - F(w), each term, and w_d(w). + - `F`, `classical`, `saturation`, `bootstrap`, `curvature`, `polarization`, `w_d` - F(w), each term, and w_d(w). """ struct ModifiedRutherfordResult{P} problem::P @@ -135,6 +140,9 @@ struct ModifiedRutherfordResult{P} gamma::Vector{Float64} delta_r::Vector{Float64} w_lo::Vector{Float64} + alpha_s::Vector{Float64} + beta_s::Vector{Float64} + C_sat::Vector{Float64} w_m::Vector{Float64} w_sat::Vector{Float64} w_m_star::Float64 @@ -147,6 +155,7 @@ struct ModifiedRutherfordResult{P} complex_delta_prime::Vector{Bool} F::Matrix{Float64} classical::Matrix{Float64} + saturation::Matrix{Float64} bootstrap::Matrix{Float64} curvature::Matrix{Float64} polarization::Matrix{Float64} @@ -238,10 +247,10 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) out = (; psi=vec0(), q=vec0(), m=zeros(Int, ns), n=zeros(Int, ns), delta_prime=vec0(), delta_prime_imag=vec0(), D_I=vec0(), D_R=vec0(), H=vec0(), D_nc=vec0(), f_trap=vec0(), mu_ratio=vec0(), zeff=vec0(), Wc_prefac=vec0(), chi_perp=vec0(), v_te=vec0(), R0=vec0(), omega_star_i=vec0(), omega_star_e=vec0(), omega_hat=vec0(), omega_hat_pi=vec0(), beta_p=vec0(), rho_theta=vec0(), w_ib=vec0(), - eps=vec0(), gamma=vec0(), delta_r=vec0(), w_lo=vec0(), w_m=vec0(), w_sat=vec0()) + eps=vec0(), gamma=vec0(), delta_r=vec0(), w_lo=vec0(), alpha_s=vec0(), beta_s=vec0(), C_sat=vec0(), w_m=vec0(), w_sat=vec0()) flags = (; fully_stabilized=flag0(), unstable_any_seed=flag0(), interchange_unstable=flag0(), linear_unstable=flag0(), below_w_ib=flag0(), large_delta_prime_w=flag0(), complex_delta_prime=flag0()) - mats = Dict(k => zeros(ns, prob.nw) for k in (:F, :classical, :bootstrap, :curvature, :polarization, :w_d)) + mats = Dict(k => zeros(ns, prob.nw) for k in (:F, :classical, :saturation, :bootstrap, :curvature, :polarization, :w_d)) for (k, s) in enumerate(surfaces) rg = s.restype @@ -284,12 +293,17 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) omega_hat = something(prob.omega_ratio, prob.omega_factor * omega_model / omega_star_i) # Cylindrical: r_s Δ′ = −2m, referred back to ψ_N by K^(−2α), K = r_s dψ_N/dr, α = √(−D_I) (as delta_prime_to_rs_reference). - dp_complex = prob.delta_prime_source === :computed ? ffs.delta_prime.matrix[k, k] : - complex(-2 * m * (r_at(psi) / abs(dr_dpsi(psi)))^(-2 * sqrt(max(-D_I, 0.0)))) + K = r_at(psi) / abs(dr_dpsi(psi)) + dp_complex = prob.delta_prime_source === :computed ? ffs.delta_prime.matrix[k, k] : complex(-2 * m * K^(-2 * sqrt(max(-D_I, 0.0)))) dp_mn = prob.delta_prime_factor * real(dp_complex) + # Saturation coefficients in the same radial label; the stencil (±2h) stays inside the ψ_N grid. + h_sat = min(prob.saturation_dpsi, 0.4 * psi, 0.4 * (1 - psi)) + sc = saturation_coefficient(r_at, x -> abs(dr_dpsi(x)), x -> Float64(equil.profiles.q_spline(x)), + x -> Float64(equil.profiles.q_deriv(x)), psi; h=h_sat) function terms(wj) wd = transport_width(wj; prob.c_wd, chi_perp, v_te, R0=g.R_major, g.q, g.q1, n, m, g.Wc_prefac) cl = D_I < 0 ? classical_term(dp_mn, D_I, wj; model=prob.delta_prime_model) : NaN + st = D_I < 0 ? saturation_term(sc.C_sat, K, D_I, wj; model=prob.delta_prime_model) : NaN bs = bootstrap_term(D_nc, wd, wj; prob.k1) cv = D_I < 0 ? curvature_term(D_R, rg.H, D_I, wd, wj; prob.k1) : NaN pl = @@ -299,13 +313,14 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) gradient_ratio, omega_hat, omega_hat_pi, w_ib) : reduced_polarization_term(wj; prob.c_pol, prob.C_pol, w_ib) ) - F = prob.classical * cl + prob.bootstrap * bs + prob.curvature * cv + prob.polarization * pl - return (; F, cl, bs, cv, pl, wd) + F = prob.classical * cl + prob.saturation * st + prob.bootstrap * bs + prob.curvature * cv + prob.polarization * pl + return (; F, cl, st, bs, cv, pl, wd) end for (j, wj) in enumerate(w) t = terms(wj) mats[:F][k, j], mats[:w_d][k, j] = t.F, t.wd - mats[:classical][k, j], mats[:bootstrap][k, j], mats[:curvature][k, j], mats[:polarization][k, j] = t.cl, t.bs, t.cv, t.pl + mats[:classical][k, j], mats[:saturation][k, j], mats[:bootstrap][k, j] = t.cl, t.st, t.bs + mats[:curvature][k, j], mats[:polarization][k, j] = t.cv, t.pl end # Gates: the linear tearing growth rate and resistive layer width from `tearing`, and resistive interchange. @@ -326,7 +341,7 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) for (key, val) in pairs((; psi, q=s.q, m, n, delta_prime=dp_mn, delta_prime_imag=imag(dp_complex), D_I, D_R, H=rg.H, D_nc, f_trap=g.f_trap, mu_ratio, zeff, g.Wc_prefac, chi_perp, v_te, R0=g.R_major, omega_star_i, omega_star_e, omega_hat, omega_hat_pi, - beta_p, rho_theta, w_ib, g.eps, gamma, delta_r, w_lo, w_m, w_sat)) + beta_p, rho_theta, w_ib, g.eps, gamma, delta_r, w_lo, sc.alpha_s, sc.beta_s, sc.C_sat, w_m, w_sat)) getfield(out, key)[k] = val end end @@ -335,8 +350,8 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) return ModifiedRutherfordResult(prob, collect(w), out.psi, out.q, out.m, out.n, out.delta_prime, out.delta_prime_imag, out.D_I, out.D_R, out.H, out.D_nc, out.f_trap, out.mu_ratio, out.zeff, out.Wc_prefac, out.chi_perp, out.v_te, out.R0, out.omega_star_i, out.omega_star_e, out.omega_hat, out.omega_hat_pi, out.beta_p, out.rho_theta, out.w_ib, out.eps, out.gamma, out.delta_r, out.w_lo, - out.w_m, out.w_sat, w_m_star, Vector{Bool}(flags.fully_stabilized), Vector{Bool}(flags.unstable_any_seed), + out.alpha_s, out.beta_s, out.C_sat, out.w_m, out.w_sat, w_m_star, Vector{Bool}(flags.fully_stabilized), Vector{Bool}(flags.unstable_any_seed), Vector{Bool}(flags.interchange_unstable), Vector{Bool}(flags.linear_unstable), Vector{Bool}(flags.below_w_ib), Vector{Bool}(flags.large_delta_prime_w), Vector{Bool}(flags.complex_delta_prime), - mats[:F], mats[:classical], mats[:bootstrap], mats[:curvature], mats[:polarization], mats[:w_d]) + mats[:F], mats[:classical], mats[:saturation], mats[:bootstrap], mats[:curvature], mats[:polarization], mats[:w_d]) end diff --git a/src/Tearing/ModifiedRutherford/Terms.jl b/src/Tearing/ModifiedRutherford/Terms.jl index b59969369..0944c9945 100644 --- a/src/Tearing/ModifiedRutherford/Terms.jl +++ b/src/Tearing/ModifiedRutherford/Terms.jl @@ -35,6 +35,38 @@ function curvature_term(D_R::Real, H::Real, D_I::Real, w_d::Real, w::Real; k1::R return (k1 * D_R / (alpha_plus - H)) / (w + k1 * w_d / (0.3 * (1 + alpha_plus))) end +""" + saturation_coefficient(r, dr_dpsi, q, dq_dpsi, psi; h=0.01) -> NamedTuple + +Current-gradient coefficients of the finite-width saturation term (Fitzpatrick 2023 Eqs. 9.15–9.19, after Hastie, +Militello & Porcelli 2005): `α_s = −r q J_z′/s`, `β_s = −r² q J_z″/s` and `C_sat = 0.8 α_s² − 0.27 β_s − 0.09 α_s`, with +`s = r q′/q`, `′ = d/dr` and the cylindrical `J_z = R₀μ₀j_z/B_z = (2 − s)/q`, all in the radial label `r(ψ)` with +derivative `dr_dpsi(ψ)`. `J_z′` and `J_z″` are central differences of step `h` in ψ_N. Eq. 9.17 prints `r q J_z″/s`; +the `r²` form is the dimensionless one and the one that reproduces Fitzpatrick's Fig. 9.2. +""" +function saturation_coefficient(r, dr_dpsi, q, dq_dpsi, psi::Real; h::Real=0.01) + shear(x) = r(x) * dq_dpsi(x) / (q(x) * dr_dpsi(x)) + J(x) = (2 - shear(x)) / q(x) + dJ_dr(x) = (J(x + h) - J(x - h)) / (2h * dr_dpsi(x)) + d2J_dr2 = (dJ_dr(psi + h) - dJ_dr(psi - h)) / (2h * dr_dpsi(psi)) + rs, qs, s = r(psi), q(psi), shear(psi) + alpha_s = -rs * qs * dJ_dr(psi) / s + beta_s = -rs^2 * qs * d2J_dr2 / s + return (; alpha_s, beta_s, C_sat=0.8 * alpha_s^2 - 0.27 * beta_s - 0.09 * alpha_s) +end + +""" + saturation_term(C_sat, K, D_I, w; model=:mercier_power) -> Float64 + +Finite-width saturation term: Fitzpatrick's replacement `r_sΔ′ → r_sΔ′(0) − C_sat W/r_s` (Eq. 9.15) referred to ψ_N +as the outer Δ′ is (`W/r_s = w/K`, `Δ′_ψ = K^(−2α) r_sΔ′`, `K = r_s dψ_N/dr`, `α = √(−D_I)`), then carried through +[`classical_term`](@ref), which is linear in Δ′: `−classical_term(C_sat K^(−1−2α) w, D_I, w)`. At `D_I = −¼` it is +`−C_sat w/K²`, so classical plus saturation vanish at Fitzpatrick's `W_pw/r_s = r_sΔ′(0)/C_sat` (Eq. 9.21). +""" +function saturation_term(C_sat::Real, K::Real, D_I::Real, w::Real; model::Symbol=:mercier_power) + return -classical_term(C_sat * K^(-1 - 2 * sqrt(-D_I)) * w, D_I, w; model) +end + """ polarization_term(w; c_pol, eps, rho_theta_psi, beta_p, gradient_ratio, omega_hat, omega_hat_pi, w_ib) -> Float64 diff --git a/test/runtests_modified_rutherford.jl b/test/runtests_modified_rutherford.jl index 35fbb8ebc..9d7c77134 100644 --- a/test/runtests_modified_rutherford.jl +++ b/test/runtests_modified_rutherford.jl @@ -39,6 +39,31 @@ using TOML @test MR.reduced_polarization_term(0.01; c_pol=1.0, C_pol=1.9, w_ib=1e-3) ≈ -(1.9e-3)^2 / 1e-6 end + @testset "saturation term: Wesson cylinder of Fitzpatrick 2023 Fig. 9.2" begin + # q0 = 0.8, q(a) = 4.4, r = √ψ (a = 1); reference α_s, β_s from the analytic J_z = (2/q0)(1 − r²)^ν, not from q. + q0, qa = 0.8, 4.4 + nu = qa / q0 - 1 + q(x) = qa * x / (1 - (1 - x)^(nu + 1)) + dq(x) = (q(x + 1e-7) - q(x - 1e-7)) / 2e-7 + lo, hi = 0.1, 0.9 + for _ in 1:60 + mid = (lo + hi) / 2 + q(mid) < 2 ? (lo = mid) : (hi = mid) + end + psi_s = (lo + hi) / 2 + sc = MR.saturation_coefficient(sqrt, x -> 0.5 / sqrt(x), q, dq, psi_s; h=1e-3) + @test sqrt(psi_s) ≈ 0.65942681 rtol = 1e-6 + @test sc.alpha_s ≈ 1.642673 rtol = 1e-4 + @test sc.beta_s ≈ -7.204691 rtol = 1e-3 + # Classical plus saturation vanish at W_pw/r_s = r_sΔ′(0)/C_sat (Eq. 9.21); r_sΔ′(0) = 1.64 from the cylindrical + # tearing equation, which puts W_pw/a at the 0.27 peak of the r_w = a curve. K = r_s dψ/dr = 2ψ_s. + E_ss, K = 1.64, 2 * psi_s + grid = exp.(range(log(1e-4), log(2.0); length=200)) + r = MR.mre_roots(w -> MR.classical_term(E_ss / K, -0.25, w) + MR.saturation_term(sc.C_sat, K, -0.25, w), 1e-4, grid) + @test r.w_sat / K ≈ E_ss / sc.C_sat rtol = 1e-8 + @test sqrt(psi_s) * r.w_sat / K ≈ 0.27 rtol = 0.02 + end + @testset "transport width grows as w^(1/4)" begin kw = (; c_wd=1.0, chi_perp=1.0, v_te=5e7, R0=1.7, q=2.0, q1=3.0, n=1, m=2, Wc_prefac=3e4) @test MR.transport_width(16e-3; kw...) / MR.transport_width(1e-3; kw...) ≈ 2 rtol = 1e-12 diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index fd32c4e80..799b281eb 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -631,7 +631,7 @@ using HDF5 foreach(k -> delete!(deck, k), ("LocalStability", "Ballooning", "ErrorFields", "KineticForces")) deck["Run"]["output_file"] = joinpath(dir, "gpec.h5") deck["Tearing"] = Dict{String,Any}("Callen" => Dict{String,Any}(), - "ModifiedRutherford" => Dict{String,Any}("delta_prime_source" => "cylindrical", "nw" => 40)) + "ModifiedRutherford" => Dict{String,Any}("delta_prime_source" => "cylindrical", "saturation" => true, "nw" => 40)) for key in ("eq_filename", "kinetic_file") deck["Equilibrium"][key] = abspath(joinpath(ex_dir, deck["Equilibrium"][key])) end @@ -648,14 +648,17 @@ using HDF5 @test cyl.delta_c ≈ -2 .* cyl.m ./ cyl.rho_tor @test_throws "needs delta_prime" solve(CallenProblem(reference.pe)) - mre = solve(ModifiedRutherfordProblem(reference.ffs; delta_prime_source=:cylindrical, nw=40)) + mre = solve(ModifiedRutherfordProblem(reference.ffs; delta_prime_source=:cylindrical, saturation=true, nw=40)) @test mre.F ≈ reference.modified_rutherford.F rtol = 1e-10 @test isequal(mre.w_m, reference.modified_rutherford.w_m) @test all(<(0), mre.delta_prime) + @test all(isfinite, mre.C_sat) + @test all(all(sign.(mre.saturation[k, :]) .== -sign(mre.C_sat[k])) for k in eachindex(mre.psi) if mre.D_I[k] < 0) h5open(joinpath(dir, "gpec.h5")) do f @test read(f["Tearing/Callen/delta_c"]) ≈ api.delta_c rtol = 1e-10 recorded = TOML.parse(read(f["Input/gpec_toml_raw"]))["Tearing"] @test haskey(recorded, "Callen") && recorded["ModifiedRutherford"]["delta_prime_source"] == "cylindrical" + @test recorded["ModifiedRutherford"]["saturation"] && read(f["Tearing/ModifiedRutherford/PerSurface/C_sat"]) ≈ mre.C_sat end end From 051efdef2bcb369a2fcd383ecfb4249c1d73be32 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 7 Nov 2026 21:55:37 -0500 Subject: [PATCH 09/26] Tearing - BUGFIX! - Take the saturation current profile from Grad-Shafranov with a windowed fit The cylindrical identity J_z = (2 - s)/q gives J_z <= 0 at q = 2 on shaped equilibria (the radial_label shear exceeds 2 inside the current channel), and its central differences need q''', which is piecewise constant on the spline, so C_sat did not converge. J_z is now (R0^2/B0) from the Grad-Shafranov flux-surface identity (current_density_profile), J_z' and J_z'' come from a least-squares quadratic over psi_N +- saturation_window (0.02, replacing saturation_dpsi), and saturation_unconverged flags surfaces whose C_sat moves more than 20% at half or double the window or whose window leaves the grid. On the TJ equilibrium J_z(0) = 2/q0 to 0.5% and C_sat at q = 2 matches the cylinder to 5%. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- src/Tearing/ModifiedRutherford/Geometry.jl | 28 ++++++++++++++ src/Tearing/ModifiedRutherford/HDF5Output.jl | 7 +++- .../ModifiedRutherford/ModifiedRutherford.jl | 2 +- src/Tearing/ModifiedRutherford/Problem.jl | 20 ++++++---- src/Tearing/ModifiedRutherford/Terms.jl | 37 ++++++++++++------- test/runtests_modified_rutherford.jl | 5 ++- 6 files changed, 73 insertions(+), 26 deletions(-) diff --git a/src/Tearing/ModifiedRutherford/Geometry.jl b/src/Tearing/ModifiedRutherford/Geometry.jl index 79937ffa6..bf35b6be2 100644 --- a/src/Tearing/ModifiedRutherford/Geometry.jl +++ b/src/Tearing/ModifiedRutherford/Geometry.jl @@ -127,3 +127,31 @@ function mre_geometry(equil, psifac::Real, q1::Real) return MRESurfaceGeometry(q, q1, p1, v1, equil.psio, avg[1], avg[2], avg[3], avg[4], avg[5], avg[6], avg[7], R_major, eps, delta, sauter2002_trapped_fraction(eps, delta), Wc_prefac) end + +""" + current_density_profile(equil) -> Function + +Dimensionless toroidal current density `J_z(ψ_N) = (R₀²/B₀) ⟨μ₀ j_φ/R⟩` of the cylindrical saturation theory, from the +Grad–Shafranov flux-surface identity `⟨μ₀ j_φ/R⟩ = μ₀ dp/dψ + F dF/dψ ⟨R⁻²⟩` (ψ the poloidal flux per radian), signed +positive in the core. It reduces to `R₀μ₀j_z/B_z` in the large-aspect-ratio cylinder. +""" +function current_density_profile(equil) + profiles = equil.profiles + R0, B0, psio = Float64(equil.ro), Float64(equil.params.b0), Float64(equil.psio) + ys = equil.rzphi_ys + function avg_inv_Rsq(psi) + ff = zeros(Float64, length(ys), 2) + for (i, theta) in enumerate(ys) + rfac = sqrt(equil.rzphi_rsquared((psi, theta))) + jac = equil.rzphi_jac((psi, theta)) + r = equil.ro + rfac * cos(2π * (theta + equil.rzphi_offset((psi, theta)))) + ff[i, 1], ff[i, 2] = jac / r^2, jac + end + @views ff[end, :] .= ff[1, :] + avg = FastInterpolations.integrate(cubic_interp(ys, Series(ff); bc=PeriodicBC())) + return avg[1] / avg[2] + end + raw(psi) = (profiles.P_deriv(psi) + profiles.F_spline(psi) * profiles.F_deriv(psi) / (4π^2) * avg_inv_Rsq(psi)) / psio + sgn = sign(raw(0.05)) + return psi -> sgn * R0^2 / B0 * raw(Float64(psi)) +end diff --git a/src/Tearing/ModifiedRutherford/HDF5Output.jl b/src/Tearing/ModifiedRutherford/HDF5Output.jl index ebc78611e..4ee2b5077 100644 --- a/src/Tearing/ModifiedRutherford/HDF5Output.jl +++ b/src/Tearing/ModifiedRutherford/HDF5Output.jl @@ -39,7 +39,7 @@ const _MRE_PER_SURFACE = (:psi, :q, :m, :n, :delta_prime, :delta_prime_imag, :D_ :chi_perp, :v_te, :R0, :omega_star_i, :omega_star_e, :omega_hat, :omega_hat_pi, :beta_p, :rho_theta, :w_ib, :eps, :gamma, :delta_r, :w_lo, :alpha_s, :beta_s, :C_sat, :w_m, :w_sat) const _MRE_FLAGS = (:fully_stabilized, :unstable_any_seed, :interchange_unstable, :linear_unstable, :below_w_ib, :large_delta_prime_w, - :complex_delta_prime) + :complex_delta_prime, :saturation_unconverged) const _MRE_TERMS = (:F, :classical, :saturation, :bootstrap, :curvature, :polarization, :w_d) # Julia's surface × width matrices land in HDF5 as (width, surface). @@ -80,7 +80,8 @@ const MRE_H5_ANNOTATIONS = [ "PerSurface/gamma" => (; long_name="linear tearing growth rate of the gate, NaN without one", units="1/s", dims=("surface",)), "PerSurface/delta_r" => (; long_name="resistive layer width of the gate, NaN without one", units="psi_N", dims=("surface",)), "PerSurface/w_lo" => (; long_name="lower end of the root search max(delta_r, w_min)", units="psi_N", dims=("surface",)), - "PerSurface/alpha_s" => (; long_name="saturation current-gradient coefficient alpha_s = -r q J_z'/s (Fitzpatrick 2023 Eq. 9.16)", dims=("surface",)), + "PerSurface/alpha_s" => + (; long_name="saturation current-gradient coefficient alpha_s = -r q J_z'/s (Fitzpatrick 2023 Eq. 9.16), J_z = (R0^2/B0) ", dims=("surface",)), "PerSurface/beta_s" => (; long_name="saturation current-curvature coefficient beta_s = -r^2 q J_z''/s", dims=("surface",)), "PerSurface/C_sat" => (; long_name="saturation coefficient 0.8 alpha_s^2 - 0.27 beta_s - 0.09 alpha_s (Fitzpatrick 2023 Eq. 9.15)", dims=("surface",)), @@ -93,6 +94,8 @@ const MRE_H5_ANNOTATIONS = [ "PerSurface/linear_unstable" => (; long_name="1 if the gate's growth rate is positive (no seed threshold, w_sat kept)", dims=("surface",)), "PerSurface/below_w_ib" => (; long_name="1 if w_m < w_ib", dims=("surface",)), "PerSurface/large_delta_prime_w" => (; long_name="1 if |Delta'| w >= 1 at w_m or w_sat", dims=("surface",)), + "PerSurface/saturation_unconverged" => (; long_name="1 if C_sat moves more than 20% between half and double the fit window, or the window leaves the grid", + dims=("surface",)), "PerSurface/complex_delta_prime" => (; long_name="1 if |Im Delta'| > 1e-3 |Re Delta'|", dims=("surface",)), "Terms/F" => (; long_name="modified Rutherford F(w), the sum of the switched-on terms", dims=_MRE_TERM_DIMS), "Terms/classical" => (; long_name="classical term Delta'(w)", dims=_MRE_TERM_DIMS), diff --git a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl index d613cbbb8..8e04e421a 100644 --- a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl +++ b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl @@ -22,7 +22,7 @@ include("Problem.jl") include("Callen.jl") include("HDF5Output.jl") -export MRESurfaceGeometry, mre_geometry, sauter2002_trapped_fraction, bootstrap_coefficient +export MRESurfaceGeometry, mre_geometry, current_density_profile, sauter2002_trapped_fraction, bootstrap_coefficient export classical_term, saturation_coefficient, saturation_term, bootstrap_term, curvature_term, polarization_term, reduced_polarization_term, transport_width export mre_roots, ModifiedRutherfordProblem, ModifiedRutherfordResult, CallenProblem, CallenResult, callen_cubic_roots diff --git a/src/Tearing/ModifiedRutherford/Problem.jl b/src/Tearing/ModifiedRutherford/Problem.jl index b281aa7e1..3d63f794e 100644 --- a/src/Tearing/ModifiedRutherford/Problem.jl +++ b/src/Tearing/ModifiedRutherford/Problem.jl @@ -31,7 +31,7 @@ from noise, so they get no seed threshold but keep `w_sat`. when `[Tearing]` is present. - `classical`, `saturation`, `bootstrap`, `curvature`, `polarization` - Term switches s_Δ, s_sat, s_nc, s_GGJ, s_pol. `saturation` is the finite-width correction to Δ′ ([`saturation_term`](@ref)), off by default. - - `saturation_dpsi` - ψ_N step of the differences that give J_z′ and J_z″ in [`saturation_coefficient`](@ref). + - `saturation_window` - Half-width in ψ_N of the quadratic fit that gives J_z′ and J_z″ in [`saturation_coefficient`](@ref). - `delta_prime_source` - `:computed` (Δ′_mn of `ffs`) or `:cylindrical` (r_s Δ′_mn = −2m, a reference case). - `delta_prime_model` - `:mercier_power` (Benjamin Eq. 10) or `:constant` (Δ′(w) = Δ′_mn). - `delta_prime_factor`, `D_nc_factor`, `rho_theta_factor` - Multipliers on Δ′_mn, D_nc and the poloidal ion Larmor @@ -56,7 +56,7 @@ from noise, so they get no seed threshold but keep `w_sat`. curvature::Bool = true polarization::Bool = false saturation::Bool = false - saturation_dpsi::Float64 = 0.01 + saturation_window::Float64 = 0.02 delta_prime_source::Symbol = :computed delta_prime_model::Symbol = :mercier_power delta_prime_factor::Float64 = 1.0 @@ -107,6 +107,7 @@ Output of `solve(::ModifiedRutherfordProblem)`. Per-surface vectors run core to - `below_w_ib` - `w_m` below the ion banana width, where the polarization ordering fails. - `large_delta_prime_w` - |Δ′| w ≥ 1 at `w_m` or `w_sat`. - `complex_delta_prime` - |Im Δ′_mn| > 10⁻³ |Re Δ′_mn|. + - `saturation_unconverged` - `C_sat` not converged in the fit window (see [`saturation_coefficient`](@ref)). - `F`, `classical`, `saturation`, `bootstrap`, `curvature`, `polarization`, `w_d` - F(w), each term, and w_d(w). """ struct ModifiedRutherfordResult{P} @@ -153,6 +154,7 @@ struct ModifiedRutherfordResult{P} below_w_ib::Vector{Bool} large_delta_prime_w::Vector{Bool} complex_delta_prime::Vector{Bool} + saturation_unconverged::Vector{Bool} F::Matrix{Float64} classical::Matrix{Float64} saturation::Matrix{Float64} @@ -240,6 +242,8 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) gate = prob.tearing # Metres map to ψ_N with the toroidal-flux radial label's dr/dψ, the label SLAYER's layer widths use. r_at, dr_dpsi = radial_label(equil) + J_z = current_density_profile(equil) + psi_lim = (first(equil.profiles.xs), last(equil.profiles.xs)) chi_gate = gate === nothing || !hasproperty(gate.model, :chi_perp_Dc) ? nothing : gate.model.chi_perp_Dc vec0() = zeros(ns) @@ -249,7 +253,8 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) omega_star_i=vec0(), omega_star_e=vec0(), omega_hat=vec0(), omega_hat_pi=vec0(), beta_p=vec0(), rho_theta=vec0(), w_ib=vec0(), eps=vec0(), gamma=vec0(), delta_r=vec0(), w_lo=vec0(), alpha_s=vec0(), beta_s=vec0(), C_sat=vec0(), w_m=vec0(), w_sat=vec0()) flags = (; fully_stabilized=flag0(), unstable_any_seed=flag0(), interchange_unstable=flag0(), linear_unstable=flag0(), - below_w_ib=flag0(), large_delta_prime_w=flag0(), complex_delta_prime=flag0()) + below_w_ib=flag0(), large_delta_prime_w=flag0(), complex_delta_prime=flag0(), + saturation_unconverged=flag0()) mats = Dict(k => zeros(ns, prob.nw) for k in (:F, :classical, :saturation, :bootstrap, :curvature, :polarization, :w_d)) for (k, s) in enumerate(surfaces) @@ -296,10 +301,10 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) K = r_at(psi) / abs(dr_dpsi(psi)) dp_complex = prob.delta_prime_source === :computed ? ffs.delta_prime.matrix[k, k] : complex(-2 * m * K^(-2 * sqrt(max(-D_I, 0.0)))) dp_mn = prob.delta_prime_factor * real(dp_complex) - # Saturation coefficients in the same radial label; the stencil (±2h) stays inside the ψ_N grid. - h_sat = min(prob.saturation_dpsi, 0.4 * psi, 0.4 * (1 - psi)) - sc = saturation_coefficient(r_at, x -> abs(dr_dpsi(x)), x -> Float64(equil.profiles.q_spline(x)), - x -> Float64(equil.profiles.q_deriv(x)), psi; h=h_sat) + # Saturation coefficients from the equilibrium current in the same radial label. + sc = saturation_coefficient(J_z, r_at, x -> abs(dr_dpsi(x)), x -> Float64(equil.profiles.q_spline(x)), + x -> Float64(equil.profiles.q_deriv(x)), psi; window=prob.saturation_window, psi_lim) + flags.saturation_unconverged[k] = !sc.converged function terms(wj) wd = transport_width(wj; prob.c_wd, chi_perp, v_te, R0=g.R_major, g.q, g.q1, n, m, g.Wc_prefac) cl = D_I < 0 ? classical_term(dp_mn, D_I, wj; model=prob.delta_prime_model) : NaN @@ -353,5 +358,6 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) out.alpha_s, out.beta_s, out.C_sat, out.w_m, out.w_sat, w_m_star, Vector{Bool}(flags.fully_stabilized), Vector{Bool}(flags.unstable_any_seed), Vector{Bool}(flags.interchange_unstable), Vector{Bool}(flags.linear_unstable), Vector{Bool}(flags.below_w_ib), Vector{Bool}(flags.large_delta_prime_w), Vector{Bool}(flags.complex_delta_prime), + Vector{Bool}(flags.saturation_unconverged), mats[:F], mats[:classical], mats[:saturation], mats[:bootstrap], mats[:curvature], mats[:polarization], mats[:w_d]) end diff --git a/src/Tearing/ModifiedRutherford/Terms.jl b/src/Tearing/ModifiedRutherford/Terms.jl index 0944c9945..a5fe5a801 100644 --- a/src/Tearing/ModifiedRutherford/Terms.jl +++ b/src/Tearing/ModifiedRutherford/Terms.jl @@ -36,23 +36,32 @@ function curvature_term(D_R::Real, H::Real, D_I::Real, w_d::Real, w::Real; k1::R end """ - saturation_coefficient(r, dr_dpsi, q, dq_dpsi, psi; h=0.01) -> NamedTuple + saturation_coefficient(J, r, dr_dpsi, q, dq_dpsi, psi; window=0.02, psi_lim=(0.0, 1.0), npts=11) -> NamedTuple Current-gradient coefficients of the finite-width saturation term (Fitzpatrick 2023 Eqs. 9.15–9.19, after Hastie, Militello & Porcelli 2005): `α_s = −r q J_z′/s`, `β_s = −r² q J_z″/s` and `C_sat = 0.8 α_s² − 0.27 β_s − 0.09 α_s`, with -`s = r q′/q`, `′ = d/dr` and the cylindrical `J_z = R₀μ₀j_z/B_z = (2 − s)/q`, all in the radial label `r(ψ)` with -derivative `dr_dpsi(ψ)`. `J_z′` and `J_z″` are central differences of step `h` in ψ_N. Eq. 9.17 prints `r q J_z″/s`; -the `r²` form is the dimensionless one and the one that reproduces Fitzpatrick's Fig. 9.2. -""" -function saturation_coefficient(r, dr_dpsi, q, dq_dpsi, psi::Real; h::Real=0.01) - shear(x) = r(x) * dq_dpsi(x) / (q(x) * dr_dpsi(x)) - J(x) = (2 - shear(x)) / q(x) - dJ_dr(x) = (J(x + h) - J(x - h)) / (2h * dr_dpsi(x)) - d2J_dr2 = (dJ_dr(psi + h) - dJ_dr(psi - h)) / (2h * dr_dpsi(psi)) - rs, qs, s = r(psi), q(psi), shear(psi) - alpha_s = -rs * qs * dJ_dr(psi) / s - beta_s = -rs^2 * qs * d2J_dr2 / s - return (; alpha_s, beta_s, C_sat=0.8 * alpha_s^2 - 0.27 * beta_s - 0.09 * alpha_s) +`s = r q′/q` and `′ = d/dr` in the radial label `r(ψ)` (derivative `dr_dpsi`), and `J(ψ)` the dimensionless current +density ([`current_density_profile`](@ref)). Eq. 9.17 prints `r q J_z″/s`; the `r²` form is the dimensionless one and +the one that reproduces Fitzpatrick's Fig. 9.2. `J_z′` and `J_z″` come from a least-squares quadratic in `r − r_s` over +`ψ ± window` (`npts` samples), the island-width scale of the theory's expansion in W. `converged` is false when the +doubled window leaves `psi_lim` or `C_sat` at half or double the window differs from it by more than 20%. +""" +function saturation_coefficient(J, r, dr_dpsi, q, dq_dpsi, psi::Real; window::Real=0.02, psi_lim=(0.0, 1.0), npts::Int=11) + rs, qs = r(psi), q(psi) + s = rs * dq_dpsi(psi) / (qs * dr_dpsi(psi)) + clamped = psi - 2window < psi_lim[1] || psi + 2window > psi_lim[2] + function coefficients(W) + W = min(W, 0.999 * (psi - psi_lim[1]), 0.999 * (psi_lim[2] - psi)) + xs = range(psi - W, psi + W; length=npts) + x = r.(xs) .- rs + c = hcat(ones(npts), x, x .^ 2) \ J.(xs) + alpha_s = -rs * qs * c[2] / s + beta_s = -rs^2 * qs * 2 * c[3] / s + return (; alpha_s, beta_s, C_sat=0.8 * alpha_s^2 - 0.27 * beta_s - 0.09 * alpha_s, J_s=c[1]) + end + mid = coefficients(window) + spread = maximum(abs(coefficients(f * window).C_sat - mid.C_sat) for f in (0.5, 2.0)) / max(abs(mid.C_sat), eps()) + return (; mid..., converged=!clamped && spread <= 0.2) end """ diff --git a/test/runtests_modified_rutherford.jl b/test/runtests_modified_rutherford.jl index 9d7c77134..f5b4a8f9b 100644 --- a/test/runtests_modified_rutherford.jl +++ b/test/runtests_modified_rutherford.jl @@ -40,7 +40,7 @@ using TOML end @testset "saturation term: Wesson cylinder of Fitzpatrick 2023 Fig. 9.2" begin - # q0 = 0.8, q(a) = 4.4, r = √ψ (a = 1); reference α_s, β_s from the analytic J_z = (2/q0)(1 − r²)^ν, not from q. + # q0 = 0.8, q(a) = 4.4, r = √ψ (a = 1), J_z = (2/q0)(1 − r²)^ν; reference α_s, β_s from the independent shooting script. q0, qa = 0.8, 4.4 nu = qa / q0 - 1 q(x) = qa * x / (1 - (1 - x)^(nu + 1)) @@ -51,7 +51,8 @@ using TOML q(mid) < 2 ? (lo = mid) : (hi = mid) end psi_s = (lo + hi) / 2 - sc = MR.saturation_coefficient(sqrt, x -> 0.5 / sqrt(x), q, dq, psi_s; h=1e-3) + sc = MR.saturation_coefficient(x -> (2 / q0) * (1 - x)^nu, sqrt, x -> 0.5 / sqrt(x), q, dq, psi_s; window=0.005) + @test sc.converged @test sqrt(psi_s) ≈ 0.65942681 rtol = 1e-6 @test sc.alpha_s ≈ 1.642673 rtol = 1e-4 @test sc.beta_s ≈ -7.204691 rtol = 1e-3 From de7831cfa7acf6336c0c3053125b7cefb5bca56d Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 7 Nov 2026 23:53:59 -0500 Subject: [PATCH 10/26] =?UTF-8?q?Tearing=20-=20FEATURE!=20-=20Default=20th?= =?UTF-8?q?e=20MRE=20curvature=20term=20to=20the=20L=C3=BCtjens=20form=20m?= =?UTF-8?q?atched=20to=20the=20SLAYER=20critical=20Delta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- docs/src/inner_layer.md | 2 +- src/Tearing/ModifiedRutherford/HDF5Output.jl | 8 +++-- .../ModifiedRutherford/ModifiedRutherford.jl | 6 ++-- src/Tearing/ModifiedRutherford/Problem.jl | 36 ++++++++++++++++--- src/Tearing/ModifiedRutherford/Terms.jl | 21 +++++++++++ test/runtests_modified_rutherford.jl | 10 ++++++ test/runtests_solve_api.jl | 10 ++++++ 7 files changed, 83 insertions(+), 10 deletions(-) diff --git a/docs/src/inner_layer.md b/docs/src/inner_layer.md index 2fb80a794..79d534a81 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -115,7 +115,7 @@ The DIII-D-like kinetic file of the examples carries analytic χ profiles of the F(w) = \Delta'(w) + \Delta_\mathrm{nc}(w) + \Delta_\mathrm{GGJ}(w) + \Delta_\mathrm{pol}(w; \omega), ``` -the classical drive with its finite-pressure power law, the bootstrap drive with the transport threshold ``w_d``, the Glasser–Greene–Johnson curvature term and the ion-polarization term (Benjamin et al. 2026, Eqs. 7, 10–14 and C.1). Each term has a switch and a coefficient; the polarization term is off by default. A finite-width saturation term, `saturation = true` (off by default), replaces ``r_s\Delta'`` by ``r_s\Delta'(0) - C_\mathrm{sat}W/r_s`` with ``C_\mathrm{sat} = 0.8\alpha_s^2 - 0.27\beta_s - 0.09\alpha_s`` from the current-density gradients at the surface (Fitzpatrick 2023, Eq. 9.15), so that a surface with ``\Delta' > 0`` saturates. The surface geometry follows the Fortran RDCON `mercier.f` averages, and the densities, temperatures, ``\omega_E``, ``Z_\mathrm{eff}`` and ``\chi_\perp`` come from the equilibrium's kinetic profiles. A `[Tearing]` section holding only this sub-table runs no layer solve. In a script, `solve(ModifiedRutherfordProblem(ffs; polarization=true))` returns the same [`ModifiedRutherfordResult`](@ref Tearing.ModifiedRutherford.ModifiedRutherfordResult). +the classical drive with its finite-pressure power law, the bootstrap drive with the transport threshold ``w_d``, the Glasser–Greene–Johnson curvature term and the ion-polarization term (Benjamin et al. 2026, Eqs. 7, 10–14 and C.1). Each term has a switch and a coefficient; the polarization term is off by default. A finite-width saturation term, `saturation = true` (off by default), replaces ``r_s\Delta'`` by ``r_s\Delta'(0) - C_\mathrm{sat}W/r_s`` with ``C_\mathrm{sat} = 0.8\alpha_s^2 - 0.27\beta_s - 0.09\alpha_s`` from the current-density gradients at the surface (Fitzpatrick 2023, Eq. 9.15), so that a surface with ``\Delta' > 0`` saturates. The curvature term defaults to the Lütjens et al. (2001) form ``r_s\Delta_\mathrm{GGJ} = 6.35 D_R/\sqrt{(W/r_s)^2 + 0.65 W_d^2}`` with ``W_d`` from SLAYER's toroidal critical offset, so that at small widths it equals the critical ``\Delta`` of the linear SLAYER layer; `curvature_model = "benjamin"` selects Benjamin et al. (2026) Eq. 12. The surface geometry follows the Fortran RDCON `mercier.f` averages, and the densities, temperatures, ``\omega_E``, ``Z_\mathrm{eff}`` and ``\chi_\perp`` come from the equilibrium's kinetic profiles. A `[Tearing]` section holding only this sub-table runs no layer solve. In a script, `solve(ModifiedRutherfordProblem(ffs; polarization=true))` returns the same [`ModifiedRutherfordResult`](@ref Tearing.ModifiedRutherford.ModifiedRutherfordResult). The roots of ``F`` are bracketed on the grid and refined by Brent's method: the seed threshold ``w_m`` is the smallest crossing from ``F<0`` to ``F>0`` and the saturated width ``w_\mathrm{sat}`` the largest from ``F>0`` to ``F<0``. When the deck also has a `[Tearing]` layer solve, its uncoupled growth rates gate the roots (a surface with ``\gamma>0`` grows from noise, so it has no seed threshold ``w_m``, but keeps its saturated width) and its resistive layer width, converted to ``\psi_N`` with the layer's own radial label, sets the lower end of the search; in a script, pass it as `ModifiedRutherfordProblem(ffs; tearing)`. diff --git a/src/Tearing/ModifiedRutherford/HDF5Output.jl b/src/Tearing/ModifiedRutherford/HDF5Output.jl index 4ee2b5077..9f0f6afde 100644 --- a/src/Tearing/ModifiedRutherford/HDF5Output.jl +++ b/src/Tearing/ModifiedRutherford/HDF5Output.jl @@ -36,7 +36,7 @@ function save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::ModifiedRuthe end const _MRE_PER_SURFACE = (:psi, :q, :m, :n, :delta_prime, :delta_prime_imag, :D_I, :D_R, :H, :D_nc, :f_trap, :mu_ratio, :zeff, :Wc_prefac, - :chi_perp, :v_te, :R0, :omega_star_i, :omega_star_e, :omega_hat, :omega_hat_pi, :beta_p, :rho_theta, :w_ib, :eps, :gamma, :delta_r, :w_lo, + :chi_perp, :v_te, :R0, :omega_star_i, :omega_star_e, :omega_hat, :omega_hat_pi, :beta_p, :rho_theta, :w_ib, :eps, :gamma, :delta_r, :w_lo, :D_c, :W_d_layer, :alpha_s, :beta_s, :C_sat, :w_m, :w_sat) const _MRE_FLAGS = (:fully_stabilized, :unstable_any_seed, :interchange_unstable, :linear_unstable, :below_w_ib, :large_delta_prime_w, :complex_delta_prime, :saturation_unconverged) @@ -80,6 +80,10 @@ const MRE_H5_ANNOTATIONS = [ "PerSurface/gamma" => (; long_name="linear tearing growth rate of the gate, NaN without one", units="1/s", dims=("surface",)), "PerSurface/delta_r" => (; long_name="resistive layer width of the gate, NaN without one", units="psi_N", dims=("surface",)), "PerSurface/w_lo" => (; long_name="lower end of the root search max(delta_r, w_min)", units="psi_N", dims=("surface",)), + "PerSurface/D_c" => (; long_name="SLAYER toroidal critical offset used by curvature_model = lutjens (r_s reference), NaN otherwise", + dims=("surface",)), + "PerSurface/W_d_layer" => (; long_name="transport width sqrt(2) pi^1.5 (-D_R)/D_c in the (r - r_s)/r_s reference, NaN otherwise", + dims=("surface",)), "PerSurface/alpha_s" => (; long_name="saturation current-gradient coefficient alpha_s = -r q J_z'/s (Fitzpatrick 2023 Eq. 9.16), J_z = (R0^2/B0) ", dims=("surface",)), "PerSurface/beta_s" => (; long_name="saturation current-curvature coefficient beta_s = -r^2 q J_z''/s", dims=("surface",)), @@ -101,7 +105,7 @@ const MRE_H5_ANNOTATIONS = [ "Terms/classical" => (; long_name="classical term Delta'(w)", dims=_MRE_TERM_DIMS), "Terms/saturation" => (; long_name="finite-width saturation term Delta_sat(w)", dims=_MRE_TERM_DIMS), "Terms/bootstrap" => (; long_name="bootstrap term Delta_nc(w)", dims=_MRE_TERM_DIMS), - "Terms/curvature" => (; long_name="curvature term Delta_GGJ(w)", dims=_MRE_TERM_DIMS), + "Terms/curvature" => (; long_name="curvature term Delta_GGJ(w) of curvature_model", dims=_MRE_TERM_DIMS), "Terms/polarization" => (; long_name="ion polarization term Delta_pol(w), including pol_sign", dims=_MRE_TERM_DIMS), "Terms/w_d" => (; long_name="transport threshold width w_d(w)", units="psi_N", dims=_MRE_TERM_DIMS) ] diff --git a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl index 8e04e421a..25686acf6 100644 --- a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl +++ b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl @@ -13,7 +13,8 @@ import ...ForcingTerms import ...Utilities: save_to_hdf5 using ...Utilities.HDF5Annotations: replace_group! using ...Utilities.PhysicalConstants: M_E, M_P, E_CHG, MU_0 -using ...InnerLayer.SLAYER: _slayer_profiles, radial_label +using ...InnerLayer.SLAYER: _slayer_profiles, radial_label, build_slayer_inputs, _layer_zeff, _layer_ion_mass, _layer_mass_per_electron, + _build_resistivity_model using Roots: find_zero, Brent include("Geometry.jl") @@ -23,7 +24,8 @@ include("Callen.jl") include("HDF5Output.jl") export MRESurfaceGeometry, mre_geometry, current_density_profile, sauter2002_trapped_fraction, bootstrap_coefficient -export classical_term, saturation_coefficient, saturation_term, bootstrap_term, curvature_term, polarization_term, reduced_polarization_term, +export classical_term, saturation_coefficient, saturation_term, bootstrap_term, curvature_term, lutjens_curvature_term, layer_transport_width, polarization_term, + reduced_polarization_term, transport_width export mre_roots, ModifiedRutherfordProblem, ModifiedRutherfordResult, CallenProblem, CallenResult, callen_cubic_roots diff --git a/src/Tearing/ModifiedRutherford/Problem.jl b/src/Tearing/ModifiedRutherford/Problem.jl index 3d63f794e..8da848698 100644 --- a/src/Tearing/ModifiedRutherford/Problem.jl +++ b/src/Tearing/ModifiedRutherford/Problem.jl @@ -36,7 +36,11 @@ from noise, so they get no seed threshold but keep `w_sat`. - `delta_prime_model` - `:mercier_power` (Benjamin Eq. 10) or `:constant` (Δ′(w) = Δ′_mn). - `delta_prime_factor`, `D_nc_factor`, `rho_theta_factor` - Multipliers on Δ′_mn, D_nc and the poloidal ion Larmor radius ρ_θi (which also sets w_ib), for scans. - - `k1` - Bootstrap and curvature coefficient k₁. + - `curvature_model` - `:lutjens` (default; Lütjens et al. 2001, [`lutjens_curvature_term`](@ref), whose small-w limit is + the SLAYER toroidal critical offset D_c of the linear gate) or `:benjamin` (Benjamin Eq. 12, [`curvature_term`](@ref), + for comparison with the tearing-physics suite). `:lutjens` takes W_d and D_c from SLAYER's toroidal layer parameters + built on each surface with this problem's χ⊥. + - `k1` - Bootstrap coefficient k₁, and curvature coefficient of `curvature_model = :benjamin`. - `c_wd` - Prefactor of the transport threshold width w_d. - `chi_perp_factor` - Multiplier on χ⊥,e (the kinetic `chi_e` profile, else its `chi_e_default`). - `pol_model` - `:full` (Benjamin Eq. C.1) or `:reduced` (−c_pol w_pol²/w³, w_pol = C_pol w_ib). @@ -57,6 +61,7 @@ from noise, so they get no seed threshold but keep `w_sat`. polarization::Bool = false saturation::Bool = false saturation_window::Float64 = 0.02 + curvature_model::Symbol = :lutjens delta_prime_source::Symbol = :computed delta_prime_model::Symbol = :mercier_power delta_prime_factor::Float64 = 1.0 @@ -98,6 +103,8 @@ Output of `solve(::ModifiedRutherfordProblem)`. Per-surface vectors run core to banana width in ψ_N, inverse aspect ratio. - `gamma`, `delta_r` - Linear growth rate [1/s] and resistive layer width in ψ_N from `tearing` (`NaN` without it). - `w_lo` - Lower end of the root search. + - `D_c`, `W_d_layer` - SLAYER toroidal critical offset (r_s reference) and the transport width it implies, in the + (r − r_s)/r_s reference, used by `curvature_model = :lutjens` (`NaN` for `:benjamin`). - `alpha_s`, `beta_s`, `C_sat` - Current-gradient coefficients of the saturation term and its coefficient. - `w_m`, `w_sat` - Seed threshold and saturated full widths in ψ_N; `NaN` when absent or excluded, and `w_m` `NaN` when γ > 0. - `w_m_star` - Smallest `w_m` over the surfaces. @@ -141,6 +148,8 @@ struct ModifiedRutherfordResult{P} gamma::Vector{Float64} delta_r::Vector{Float64} w_lo::Vector{Float64} + D_c::Vector{Float64} + W_d_layer::Vector{Float64} alpha_s::Vector{Float64} beta_s::Vector{Float64} C_sat::Vector{Float64} @@ -182,6 +191,7 @@ function validate(prob::ModifiedRutherfordProblem) end ffs.equil.kinetic === nothing && throw(ArgumentError("ModifiedRutherfordProblem needs kinetic profiles; set kinetic_file in [Equilibrium]")) prob.delta_prime_model in (:mercier_power, :constant) || throw(ArgumentError("delta_prime_model must be :mercier_power or :constant")) + prob.curvature_model in (:lutjens, :benjamin) || throw(ArgumentError("curvature_model must be :lutjens or :benjamin")) prob.pol_model in (:full, :reduced) || throw(ArgumentError("pol_model must be :full or :reduced")) prob.omega_model in (:electron_diamagnetic, :exb) || throw(ArgumentError("omega_model must be :electron_diamagnetic or :exb")) abs(prob.pol_sign) == 1 || throw(ArgumentError("pol_sign must be +1 or -1")) @@ -218,6 +228,17 @@ function mre_roots(F, w_lo::Real, w_grid::AbstractVector) return (; w_m, w_sat, fully_stabilized=all(<(0), vals), unstable_any_seed=vals[1] > 0) end +# SLAYER's toroidal critical offset D_c and the W_d it implies at one surface, with χ⊥ for every χ input. +function _layer_critical_offset(equil, s, loaded, data, chi_perp) + zeff = _layer_zeff(loaded, data) + p = only( + build_slayer_inputs(equil, [s], loaded.profiles; mu_i=_layer_ion_mass(data), mu_rho=_layer_mass_per_electron(data, zeff), zeff, + chi_perp_e=chi_perp, chi_perp_i=chi_perp, chi_perp_Dc=chi_perp, chi_tor=chi_perp, tau_E=data.tau_E, P_perp_model=:chi_perp_e, + dc_type=:toroidal, resistivity_model=_build_resistivity_model(data.resistivity_model), lnLambda_form=data.lnLambda_form) + ) + return p.dc_tmp, layer_transport_width(p.dr_val, p.dc_tmp) +end + # The linear tearing row at ψ in `tearing`, or `nothing`. _tearing_row(tearing, psi) = findfirst(p -> isapprox(p, psi; atol=1e-8), tearing.rational_psi) @@ -251,7 +272,7 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) out = (; psi=vec0(), q=vec0(), m=zeros(Int, ns), n=zeros(Int, ns), delta_prime=vec0(), delta_prime_imag=vec0(), D_I=vec0(), D_R=vec0(), H=vec0(), D_nc=vec0(), f_trap=vec0(), mu_ratio=vec0(), zeff=vec0(), Wc_prefac=vec0(), chi_perp=vec0(), v_te=vec0(), R0=vec0(), omega_star_i=vec0(), omega_star_e=vec0(), omega_hat=vec0(), omega_hat_pi=vec0(), beta_p=vec0(), rho_theta=vec0(), w_ib=vec0(), - eps=vec0(), gamma=vec0(), delta_r=vec0(), w_lo=vec0(), alpha_s=vec0(), beta_s=vec0(), C_sat=vec0(), w_m=vec0(), w_sat=vec0()) + eps=vec0(), gamma=vec0(), delta_r=vec0(), w_lo=vec0(), D_c=vec0(), W_d_layer=vec0(), alpha_s=vec0(), beta_s=vec0(), C_sat=vec0(), w_m=vec0(), w_sat=vec0()) flags = (; fully_stabilized=flag0(), unstable_any_seed=flag0(), interchange_unstable=flag0(), linear_unstable=flag0(), below_w_ib=flag0(), large_delta_prime_w=flag0(), complex_delta_prime=flag0(), saturation_unconverged=flag0()) @@ -305,12 +326,17 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) sc = saturation_coefficient(J_z, r_at, x -> abs(dr_dpsi(x)), x -> Float64(equil.profiles.q_spline(x)), x -> Float64(equil.profiles.q_deriv(x)), psi; window=prob.saturation_window, psi_lim) flags.saturation_unconverged[k] = !sc.converged + # SLAYER's toroidal critical offset at this surface with this χ⊥, so the curvature term meets the gate at small w. + D_c, W_d_layer = prob.curvature_model === :lutjens ? _layer_critical_offset(equil, s, loaded, data, chi_perp) : (NaN, NaN) function terms(wj) wd = transport_width(wj; prob.c_wd, chi_perp, v_te, R0=g.R_major, g.q, g.q1, n, m, g.Wc_prefac) cl = D_I < 0 ? classical_term(dp_mn, D_I, wj; model=prob.delta_prime_model) : NaN st = D_I < 0 ? saturation_term(sc.C_sat, K, D_I, wj; model=prob.delta_prime_model) : NaN bs = bootstrap_term(D_nc, wd, wj; prob.k1) - cv = D_I < 0 ? curvature_term(D_R, rg.H, D_I, wd, wj; prob.k1) : NaN + cv = + D_I >= 0 ? NaN : + prob.curvature_model === :lutjens ? lutjens_curvature_term(D_R, D_I, K, W_d_layer, wj) : + curvature_term(D_R, rg.H, D_I, wd, wj; prob.k1) pl = prob.pol_sign * ( prob.pol_model === :full ? @@ -346,7 +372,7 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) for (key, val) in pairs((; psi, q=s.q, m, n, delta_prime=dp_mn, delta_prime_imag=imag(dp_complex), D_I, D_R, H=rg.H, D_nc, f_trap=g.f_trap, mu_ratio, zeff, g.Wc_prefac, chi_perp, v_te, R0=g.R_major, omega_star_i, omega_star_e, omega_hat, omega_hat_pi, - beta_p, rho_theta, w_ib, g.eps, gamma, delta_r, w_lo, sc.alpha_s, sc.beta_s, sc.C_sat, w_m, w_sat)) + beta_p, rho_theta, w_ib, g.eps, gamma, delta_r, w_lo, D_c, W_d_layer, sc.alpha_s, sc.beta_s, sc.C_sat, w_m, w_sat)) getfield(out, key)[k] = val end end @@ -355,7 +381,7 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) return ModifiedRutherfordResult(prob, collect(w), out.psi, out.q, out.m, out.n, out.delta_prime, out.delta_prime_imag, out.D_I, out.D_R, out.H, out.D_nc, out.f_trap, out.mu_ratio, out.zeff, out.Wc_prefac, out.chi_perp, out.v_te, out.R0, out.omega_star_i, out.omega_star_e, out.omega_hat, out.omega_hat_pi, out.beta_p, out.rho_theta, out.w_ib, out.eps, out.gamma, out.delta_r, out.w_lo, - out.alpha_s, out.beta_s, out.C_sat, out.w_m, out.w_sat, w_m_star, Vector{Bool}(flags.fully_stabilized), Vector{Bool}(flags.unstable_any_seed), + out.D_c, out.W_d_layer, out.alpha_s, out.beta_s, out.C_sat, out.w_m, out.w_sat, w_m_star, Vector{Bool}(flags.fully_stabilized), Vector{Bool}(flags.unstable_any_seed), Vector{Bool}(flags.interchange_unstable), Vector{Bool}(flags.linear_unstable), Vector{Bool}(flags.below_w_ib), Vector{Bool}(flags.large_delta_prime_w), Vector{Bool}(flags.complex_delta_prime), Vector{Bool}(flags.saturation_unconverged), diff --git a/src/Tearing/ModifiedRutherford/Terms.jl b/src/Tearing/ModifiedRutherford/Terms.jl index a5fe5a801..cab86e0f7 100644 --- a/src/Tearing/ModifiedRutherford/Terms.jl +++ b/src/Tearing/ModifiedRutherford/Terms.jl @@ -35,6 +35,27 @@ function curvature_term(D_R::Real, H::Real, D_I::Real, w_d::Real, w::Real; k1::R return (k1 * D_R / (alpha_plus - H)) / (w + k1 * w_d / (0.3 * (1 + alpha_plus))) end +""" + lutjens_curvature_term(D_R, D_I, K, W_d, w) -> Float64 + +Curvature term of Lütjens, Luciani & Garbet (2001) in the form of Urso et al. (2010) Eq. 1, +`r_s Δ_GGJ = 6.35 D_R / √((W/r_s)² + 0.65 W_d²)`, with `W/r_s = w/K` and `W_d` the transport width in the same +`(r − r_s)/r_s` reference ([`layer_transport_width`](@ref)), referred to ψ_N as the outer Δ′ is (`K^(−2α)`, +`α = √(−D_I)`). Its `w → 0` limit is SLAYER's critical offset `−√2 π^(3/2) D_R/W_d` (6.35/√0.65 = √2 π^(3/2)); for +`w ≫ W_d` it is the large-aspect-ratio `6.35 D_R/W` (La Haye 2006). Stabilizing for `D_R < 0`. +""" +function lutjens_curvature_term(D_R::Real, D_I::Real, K::Real, W_d::Real, w::Real) + return K^(-2 * sqrt(-D_I)) * 6.35 * D_R / sqrt((w / K)^2 + 0.65 * W_d^2) +end + +""" + layer_transport_width(D_R, D_c) -> Float64 + +Transport width `W_d` in the `(r − r_s)/r_s` reference implied by a SLAYER critical offset, +`D_c = −√2 π^(3/2) D_R/W_d` (Connor et al. 2015 Eq. 59 with Fitzpatrick's W_d); `Inf` when `D_c = 0`. +""" +layer_transport_width(D_R::Real, D_c::Real) = D_c == 0 ? Inf : sqrt(2) * π^1.5 * (-D_R) / D_c + """ saturation_coefficient(J, r, dr_dpsi, q, dq_dpsi, psi; window=0.02, psi_lim=(0.0, 1.0), npts=11) -> NamedTuple diff --git a/test/runtests_modified_rutherford.jl b/test/runtests_modified_rutherford.jl index f5b4a8f9b..d40e6ec56 100644 --- a/test/runtests_modified_rutherford.jl +++ b/test/runtests_modified_rutherford.jl @@ -39,6 +39,16 @@ using TOML @test MR.reduced_polarization_term(0.01; c_pol=1.0, C_pol=1.9, w_ib=1e-3) ≈ -(1.9e-3)^2 / 1e-6 end + @testset "Lütjens curvature: SLAYER's critical offset at small w, 6.35 D_R/W at large w" begin + D_R, D_c, K = -0.02, 1.5, 0.9 + W_d = MR.layer_transport_width(D_R, D_c) + @test MR.lutjens_curvature_term(D_R, -0.25, K, W_d, 1e-9) * K ≈ -D_c rtol = 1e-3 + @test MR.lutjens_curvature_term(D_R, -0.25, K, W_d, 1e3 * W_d * K) ≈ 6.35 * D_R / (1e3 * W_d * K) rtol = 1e-6 + # Referred to ψ_N as the outer Δ′: K^(−2α) at D_I < −¼. + @test MR.lutjens_curvature_term(D_R, -0.3, K, W_d, 1e-9) * K^(2 * sqrt(0.3)) ≈ -D_c rtol = 1e-3 + @test MR.layer_transport_width(D_R, 0.0) == Inf + end + @testset "saturation term: Wesson cylinder of Fitzpatrick 2023 Fig. 9.2" begin # q0 = 0.8, q(a) = 4.4, r = √ψ (a = 1), J_z = (2/q0)(1 − r²)^ν; reference α_s, β_s from the independent shooting script. q0, qa = 0.8, 4.4 diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index 799b281eb..8712f7680 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -613,6 +613,16 @@ using HDF5 @test isequal(api.gamma, reference.modified_rutherford.gamma) && all(isfinite, api.gamma) @test api.linear_unstable == (api.gamma .> 0) @test isequal(api.w_m, reference.modified_rutherford.w_m) + # The default Lütjens curvature takes SLAYER's toroidal critical offset, so it equals a toroidal gate's at the same χ⊥. + # χ⊥ varies by surface here, so the gate is built with the 3/1 surface's χ⊥ and compared there. + @test all(isfinite, api.D_c) && all(>(0), api.W_d_layer) + k = findfirst(==(3), api.m) + gate = solve(TearingProblem(reference.ffs), SLAYERModel(; D_c_type=:toroidal, chi_perp_Dc=api.chi_perp[k])) + row = findfirst(x -> isapprox(x, api.psi[k]; atol=1e-8), gate.rational_psi) + @test gate.params[row].dc_tmp ≈ api.D_c[k] rtol = 1e-10 + ben = solve(ModifiedRutherfordProblem(reference.ffs; tearing=reference.tearing, nw=40, curvature_model=:benjamin)) + @test all(isnan, ben.D_c) && ben.classical ≈ api.classical && !(ben.curvature ≈ api.curvature) + @test_throws "curvature_model" solve(ModifiedRutherfordProblem(reference.ffs; curvature_model=:other)) end # A forward solve has no Δ′, so the deck stops at parse time. From de1724d5dee90038c62b0071774842775190c8f2 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sat, 7 Nov 2026 23:53:59 -0500 Subject: [PATCH 11/26] Regression - TEST - Add modified Rutherford and Callen harness cases Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- regression-harness/cases/diiid_n1_callen.toml | 57 ++++++++++++ .../cases/diiid_slayer_n1_mre.toml | 88 +++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 regression-harness/cases/diiid_n1_callen.toml create mode 100644 regression-harness/cases/diiid_slayer_n1_mre.toml diff --git a/regression-harness/cases/diiid_n1_callen.toml b/regression-harness/cases/diiid_n1_callen.toml new file mode 100644 index 000000000..8392af2ff --- /dev/null +++ b/regression-harness/cases/diiid_n1_callen.toml @@ -0,0 +1,57 @@ +# Regression case: DIII-D-like n=1 Callen vacuum-island threshold on the coil-driven perturbed equilibrium. +# The cylindrical intrinsic Δ′ (−2m/ρ) keeps the Fortran GPEC parity case and needs no extra Δ′ solve. +# Each [quantities.*] block names an HDF5 path in the run output, how to extract it, +# and the noise floor below which a difference is treated as zero. +[case] +name = "diiid_n1_callen" +description = "DIII-D-like, n=1, Callen vacuum-island threshold and cubic roots on the coil-driven perturbed equilibrium" +example_dir = "examples/DIIID-like_ideal_example" +remove_sections = ["LocalStability", "Ballooning", "ErrorFields", "KineticForces"] + +[overrides] +"Tearing.Callen.delta_prime_source" = "cylindrical" + +[quantities.callen_hw_v] +h5path = "Tearing/Callen/hw_v" +type = "real_vector" +extract = "all_real" +label = "Callen vacuum island half-width (psi_N)" +noise_threshold = 1e-10 +order = 10 +class = "physics_converged" + +[quantities.callen_hw_v_crit] +h5path = "Tearing/Callen/hw_v_crit" +type = "real_vector" +extract = "all_real" +label = "Callen critical vacuum half-width (psi_N)" +noise_threshold = 1e-10 +order = 12 +class = "physics_converged" + +[quantities.callen_b_crit] +h5path = "Tearing/Callen/b_crit" +type = "real_vector" +extract = "all_real" +label = "Callen critical vacuum resonant field (T)" +noise_threshold = 1e-12 +order = 14 +class = "physics_converged" + +[quantities.callen_n_roots] +h5path = "Tearing/Callen/n_roots" +type = "real_vector" +extract = "all_real" +label = "Callen positive cubic roots per surface" +noise_threshold = 0 +order = 16 +class = "physics_converged" + +[quantities.runtime] +h5path = "" +type = "runtime" +extract = "value" +label = "Runtime (s)" +noise_threshold = 0.0 +order = 999 +class = "diagnostic" diff --git a/regression-harness/cases/diiid_slayer_n1_mre.toml b/regression-harness/cases/diiid_slayer_n1_mre.toml new file mode 100644 index 000000000..8f7e7fc98 --- /dev/null +++ b/regression-harness/cases/diiid_slayer_n1_mre.toml @@ -0,0 +1,88 @@ +# Regression case: DIII-D-like H-mode n=1 modified Rutherford equation (MRE) behind the toroidal SLAYER gate. +# Same deck as diiid_slayer_n1_toroidal plus [Tearing.ModifiedRutherford] with polarization and saturation on, so +# the classical, saturation, bootstrap, Lütjens curvature and polarization terms, the gate and the roots are tracked. +# Each [quantities.*] block names an HDF5 path in the run output, how to extract it, +# and the noise floor below which a difference is treated as zero. +[case] +name = "diiid_slayer_n1_mre" +description = "DIII-D-like H-mode, n=1, modified Rutherford equation with polarization and saturation behind the toroidal SLAYER gate" +example_dir = "examples/DIIID-like_SLAYER_example" + +[overrides] +"SLAYER.D_c_type" = "toroidal" +"Tearing.ModifiedRutherford.polarization" = true +"Tearing.ModifiedRutherford.saturation" = true + +# Surface coefficients the terms are built from. +[quantities.mre_D_nc] +h5path = "Tearing/ModifiedRutherford/PerSurface/D_nc" +type = "real_vector" +extract = "all_real" +label = "MRE bootstrap coefficient D_nc" +noise_threshold = 1e-8 +order = 10 +class = "physics_converged" + +[quantities.mre_C_sat] +h5path = "Tearing/ModifiedRutherford/PerSurface/C_sat" +type = "real_vector" +extract = "all_real" +label = "MRE saturation coefficient C_sat" +noise_threshold = 1e-6 +order = 12 +class = "physics_converged" + +[quantities.mre_D_c] +h5path = "Tearing/ModifiedRutherford/PerSurface/D_c" +type = "real_vector" +extract = "all_real" +label = "MRE Lütjens critical offset D_c" +noise_threshold = 1e-8 +order = 14 +class = "physics_converged" + +[quantities.mre_w_ib] +h5path = "Tearing/ModifiedRutherford/PerSurface/w_ib" +type = "real_vector" +extract = "all_real" +label = "MRE ion banana width w_ib (psi_N)" +noise_threshold = 1e-10 +order = 16 +class = "physics_converged" + +# Roots: seed thresholds and saturated widths per surface (NaN where absent), and their minimum. +[quantities.mre_w_m] +h5path = "Tearing/ModifiedRutherford/PerSurface/w_m" +type = "real_vector" +extract = "all_real" +label = "MRE seed threshold w_m (psi_N)" +noise_threshold = 1e-8 +order = 20 +class = "physics_converged" + +[quantities.mre_w_sat] +h5path = "Tearing/ModifiedRutherford/PerSurface/w_sat" +type = "real_vector" +extract = "all_real" +label = "MRE saturated width w_sat (psi_N)" +noise_threshold = 1e-8 +order = 22 +class = "physics_converged" + +[quantities.mre_w_m_star] +h5path = "Tearing/ModifiedRutherford/w_m_star" +type = "real_scalar" +extract = "value" +label = "MRE smallest seed threshold w_m* (psi_N)" +noise_threshold = 1e-8 +order = 24 +class = "physics_converged" + +[quantities.runtime] +h5path = "" +type = "runtime" +extract = "value" +label = "Runtime (s)" +noise_threshold = 0.0 +order = 999 +class = "diagnostic" From fc5e2d8379ebf0aafa476d8c06dce72e3ef5599b Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sun, 8 Nov 2026 00:03:46 -0500 Subject: [PATCH 12/26] Tearing - BUGFIX - Keep ModifiedRutherford and Callen output when a TearingResult is saved after them The TearingResult writer now clears only its own part of Tearing/, and record_inputs! keeps recorded sub-tables a result does not state, so the saved file no longer depends on the order of the writes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- src/GeneralizedPerturbedEquilibrium.jl | 3 +-- src/Tearing/HDF5Output.jl | 23 ++++++++++++++++++++--- src/Utilities/Output.jl | 13 ++++++++----- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index 157141f8f..c86c25876 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -142,7 +142,7 @@ equilibrium and writes nothing. The raw forcing file contents are captured in `Input/RawInputs/ForcingTerms/`, and `preloaded_coil_sets` lets the rerun path inject the coil geometry stored in `Input/RawInputs/Coils/`. -Returns `(; equil, local_stability, ffs, match, resp, pe, kinetic, error_fields, tearing, modified_rutherford)`, each +Returns `(; equil, local_stability, ffs, match, resp, pe, kinetic, error_fields, tearing, modified_rutherford, callen)`, each `nothing` when its section is absent. `equil` is the equilibrium the stages used (the solve's, on the two-pass grid), `ffs` the result the later stages read (the matched one when the deck has `[Match]`), and `match` its `MatchResult`. @@ -226,7 +226,6 @@ function main_from_inputs( save_to_hdf5(output_file, tearing) end - # After the tearing stage, which replaces the whole Tearing/ group. if deck.modified_rutherford !== nothing modified_rutherford = _timed_stage(runtimes, "modified_rutherford", "Modified Rutherford") do # With [Tearing], its linear solve gates the roots on the same Δ′. diff --git a/src/Tearing/HDF5Output.jl b/src/Tearing/HDF5Output.jl index dbf1ed5b9..5fe5ebdcf 100644 --- a/src/Tearing/HDF5Output.jl +++ b/src/Tearing/HDF5Output.jl @@ -1,5 +1,5 @@ import ..Utilities: save_to_hdf5 -import ..Utilities.HDF5Annotations: replace_group!, write_ragged! +import ..Utilities.HDF5Annotations: write_ragged! # HDF5Output.jl # @@ -19,11 +19,12 @@ using HDF5 """ save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::TearingResult) -Write `result` into a `Tearing/` subgroup of `parent`, replacing any existing one. +Write `result` into a `Tearing/` subgroup of `parent`, replacing its previous contents but keeping the +`ModifiedRutherford/` and `Callen/` groups that other results write there. """ function save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::TearingResult) - g = replace_group!(parent, "Tearing") + g = _reset_tearing_group!(parent) g["enabled"] = Int(result.enabled) # Which inner-layer model produced PerSurface/: the SLAYER and GGJ branches write # disjoint field sets, so readers must not have to infer it from the schema. @@ -182,6 +183,22 @@ const TEARING_RAGGED_H5_ANNOTATIONS = [ "offsets" => (; long_name="ragged-array offsets: row k spans offsets[k]+1:offsets[k+1]") ] +# Groups under Tearing/ that other results own, so a TearingResult write keeps them. +const _TEARING_FOREIGN_GROUPS = ("ModifiedRutherford", "Callen") + +# Empty Tearing/ of everything a TearingResult writes, or create it. +function _reset_tearing_group!(parent) + haskey(parent, "Tearing") || return create_group(parent, "Tearing") + g = parent["Tearing"] + for k in keys(g) + k in _TEARING_FOREIGN_GROUPS || HDF5.delete_object(g, k) + end + for a in keys(attrs(g)) + HDF5.delete_attribute(g, a) + end + return g +end + const TEARING_RAGGED_WINDING_H5_ANNOTATIONS = [ "flat" => (; long_name="winding number of the dispersion residual around each entry of the matching root list (NaN: circle unresolved)"), "offsets" => (; long_name="ragged-array offsets: row k spans offsets[k]+1:offsets[k+1]") diff --git a/src/Utilities/Output.jl b/src/Utilities/Output.jl index c3f9f11a5..f255e717d 100644 --- a/src/Utilities/Output.jl +++ b/src/Utilities/Output.jl @@ -28,16 +28,19 @@ function deck_sections end """ record_inputs!(target, result) -Merge the deck sections of `result` into the run record `Input/gpec_toml_raw` of `target`, replacing any section of -the same name (a section stated only as sub-tables joins it instead), so every saved result adds the deck it was computed from. +Merge the deck sections of `result` into the run record `Input/gpec_toml_raw` of `target`, so every saved result adds +the deck it was computed from. A result replaces its section's scalars and the sub-tables it states, and keeps recorded +sub-tables it does not state (a section stated only as sub-tables keeps the recorded scalars too). """ function record_inputs!(target::Union{HDF5.File,HDF5.Group}, result) path = "Input/gpec_toml_raw" deck = haskey(target, path) ? TOML.parse(read(target[path])) : Dict{String,Any}() - # A section stated only as sub-tables (e.g. [Tearing.ModifiedRutherford]) joins the recorded section; any other replaces it. + # A result owns its section's scalars and the sub-tables it states; recorded sub-tables it does not state stay. for (k, v) in deck_sections(result) - subtables_only = v isa AbstractDict && all(x -> x isa AbstractDict, values(v)) - deck[k] = subtables_only && get(deck, k, nothing) isa AbstractDict ? merge(deck[k], v) : v + old = get(deck, k, nothing) + deck[k] = + !(v isa AbstractDict && old isa AbstractDict) ? v : + all(x -> x isa AbstractDict, values(v)) ? merge(old, v) : merge(filter(p -> p.second isa AbstractDict, old), v) end haskey(target, path) && HDF5.delete_object(target, path) target[path] = sprint(TOML.print, deck) From b22f5e311a925d4e44d82c9b7a885c05cdafb70e Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sun, 8 Nov 2026 00:03:46 -0500 Subject: [PATCH 13/26] =?UTF-8?q?Tearing=20-=20BUGFIX!=20-=20Carry=20the?= =?UTF-8?q?=20L=C3=BCtjens=20curvature=20through=20the=20classical=20facto?= =?UTF-8?q?r=20and=20fix=20review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Lütjens term was in Δ′_mn units while the other MRE terms are in ψ_N⁻¹; it now passes through classical_term as the saturation term does, so classical plus curvature change sign where SLAYER's gate does at every D_I (unchanged at D_I = −¼). large_delta_prime_w uses the dimensionless Δ′(w) w. Also: Callen single-n parse guard, the cubic at a = 0, the J_z sign probe, stale docstrings, and tests that reuse main's Riccati solve and drop assertions restating the code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- src/Deck.jl | 2 ++ src/Tearing/ModifiedRutherford/Callen.jl | 4 ++- src/Tearing/ModifiedRutherford/Geometry.jl | 36 +++++++++++--------- src/Tearing/ModifiedRutherford/HDF5Output.jl | 2 +- src/Tearing/ModifiedRutherford/Problem.jl | 9 ++--- src/Tearing/ModifiedRutherford/Terms.jl | 12 ++++--- test/runtests_modified_rutherford.jl | 7 ++-- test/runtests_solve_api.jl | 25 ++++++++++---- 8 files changed, 60 insertions(+), 37 deletions(-) diff --git a/src/Deck.jl b/src/Deck.jl index 0b7c0953f..387da1110 100644 --- a/src/Deck.jl +++ b/src/Deck.jl @@ -513,6 +513,8 @@ function parse_deck(inputs::AbstractDict, path::AbstractString) callen = callen_from_deck(inputs) if callen !== nothing perturbed_equilibrium === nothing && error("[Tearing.Callen] needs a [PerturbedEquilibrium] section") + first(force_balance.keywords[:nn]) == last(force_balance.keywords[:nn]) || + error("[Tearing.Callen] is single-n: set [ForceFreeStates] nn to one toroidal mode number") get(callen, :delta_prime_source, :computed) === :cylindrical || get(force_balance.keywords, :vac_flag, false) || error("[Tearing.Callen] computes Δ′ with an extra Riccati solve, which needs [ForceFreeStates] vac_flag = true; " * "or set delta_prime_source = \"cylindrical\"") diff --git a/src/Tearing/ModifiedRutherford/Callen.jl b/src/Tearing/ModifiedRutherford/Callen.jl index 0953ff722..e9d87e554 100644 --- a/src/Tearing/ModifiedRutherford/Callen.jl +++ b/src/Tearing/ModifiedRutherford/Callen.jl @@ -10,7 +10,8 @@ Callen's reduced MRE for the vacuum-island threshold on every resonant row of th (a single-n state from `perturbed_equilibrium` with a coil forcing). Half-widths are in metres of the toroidal-flux radius ρ = (Φ_tor/πB₀)^(1/2) inside, and in ψ_N on output, as in Fortran GPEC 1.6.0: - - w_pol = ½ q ρ_i/(ρ/R₀)^(1/2) with ρ_i = (2T_i/m_i)^(1/2)/(Z_i e B₀/m_i), R₀ and B₀ on the axis; + - w_pol = ½ q ρ_i/(ρ/R₀)^(1/2) with ρ_i = (2T_i/m_i)^(1/2)/(Z_i e B₀/m_i), R₀ the axis major radius and B₀ the + vacuum toroidal field `bt0`, as Fortran GPEC 1.6.0 takes them; - Δ_RMP = |Δ|/(2πR₀q) B₀/|B_res|, Δ the singular-current jump over 2πχ₁ and B_res the total resonant field; - Δ_c the intrinsic tearing index in 1/m of ρ: the true Δ′_mn of `delta_prime` (a Riccati or Galerkin solve on the same equilibrium and n), referred to ρ as K^(2α) Δ′_mn/ρ with K = ρ dψ_N/dρ and α = (−D_I)^(1/2) as for SLAYER, with @@ -87,6 +88,7 @@ x_k = 2R cos((θ + 2πk)/3), R = (−p/3)^(1/2), cos θ = −q/(2R³) of the dep roots or none (`(NaN, NaN)`); for `a > 0` there is one positive root (Cardano), returned as `(x, NaN)`. """ function callen_cubic_roots(a::Real, b::Real, c::Real) + a == 0 && return (b > 0 ? -c / b : NaN, NaN) p, q = b / a, c / a if a > 0 s = sqrt(q^2 / 4 + p^3 / 27) diff --git a/src/Tearing/ModifiedRutherford/Geometry.jl b/src/Tearing/ModifiedRutherford/Geometry.jl index bf35b6be2..09374f851 100644 --- a/src/Tearing/ModifiedRutherford/Geometry.jl +++ b/src/Tearing/ModifiedRutherford/Geometry.jl @@ -12,22 +12,22 @@ Per-surface geometry of the modified Rutherford terms. Averages ⟨·⟩ are normalized flux-surface averages (⟨1⟩ = 1) and `ψ_N` is normalized poloidal flux, as in [`ResistGeometry`](@ref GeneralizedPerturbedEquilibrium.ForceFreeStates.ResistGeometry). -| field | meaning | -|:------------------------ |:------------------------------------------------------------------- | -| `q`, `q1` | safety factor and dq/dψ_N | -| `p1` | μ₀ dp/dψ_N | -| `v1` | dV/dψ_N [m³] | -| `psio` | poloidal flux per radian at the boundary [Wb] | -| `avg_bsq_over_dpsisq` | ⟨B²/‖∇ψ_N‖²⟩ | -| `avg_bsq` | ⟨B²⟩ [T²] | -| `avg_dpsisq` | ⟨‖∇ψ_N‖²⟩ | -| `overbar_Rsq` | Hegna 1999 R̄² = ⟨R² V′/𝒥⟩ [m²] | -| `avg_bpol`, `avg_bpolsq` | ⟨B_pol⟩ [T] and ⟨B_pol²⟩ [T²] | -| `r_minor` | ⟨r⟩, mean distance from the magnetic axis [m] | -| `R_major`, `eps` | (R_max + R_min)/2 [m] and (R_max − R_min)/(R_max + R_min) | -| `delta` | triangularity, mean of the upper and lower values | -| `f_trap` | Sauter 2002 trapped fraction with the triangularity-corrected ε_eff | -| `Wc_prefac` | Schlutt & Hegna 2012 w_d⁴ prefactor in ψ_N⁴, before χ⊥/χ∥ and 1/m² | +## Fields + + - `q`, `q1` - Safety factor and dq/dψ_N. + - `p1` - μ₀ dp/dψ_N. + - `v1` - dV/dψ_N [m³]. + - `psio` - Poloidal flux per radian at the boundary [Wb]. + - `avg_bsq_over_dpsisq` - ⟨B²/‖∇ψ_N‖²⟩. + - `avg_bsq` - ⟨B²⟩ [T²]. + - `avg_dpsisq` - ⟨‖∇ψ_N‖²⟩. + - `overbar_Rsq` - Hegna 1999 R̄² = ⟨R² V′/𝒥⟩ [m²]. + - `avg_bpol`, `avg_bpolsq` - ⟨B_pol⟩ [T] and ⟨B_pol²⟩ [T²]. + - `r_minor` - ⟨r⟩, mean distance from the magnetic axis [m]. + - `R_major`, `eps` - (R_max + R_min)/2 [m] and (R_max − R_min)/(R_max + R_min). + - `delta` - Triangularity, mean of the upper and lower values. + - `f_trap` - Sauter 2002 trapped fraction with the triangularity-corrected ε_eff. + - `Wc_prefac` - Schlutt & Hegna 2012 w_d⁴ prefactor in ψ_N⁴, before χ⊥/χ∥ and 1/m². """ struct MRESurfaceGeometry q::Float64 @@ -152,6 +152,8 @@ function current_density_profile(equil) return avg[1] / avg[2] end raw(psi) = (profiles.P_deriv(psi) + profiles.F_spline(psi) * profiles.F_deriv(psi) / (4π^2) * avg_inv_Rsq(psi)) / psio - sgn = sign(raw(0.05)) + # Sign from a core surface inside the grid, so the core current is positive. + sgn = sign(raw(max(0.05, Float64(first(equil.rzphi_xs))))) + sgn == 0 && throw(ArgumentError("current_density_profile: zero current density at the core surface")) return psi -> sgn * R0^2 / B0 * raw(Float64(psi)) end diff --git a/src/Tearing/ModifiedRutherford/HDF5Output.jl b/src/Tearing/ModifiedRutherford/HDF5Output.jl index 9f0f6afde..6f552650d 100644 --- a/src/Tearing/ModifiedRutherford/HDF5Output.jl +++ b/src/Tearing/ModifiedRutherford/HDF5Output.jl @@ -97,7 +97,7 @@ const MRE_H5_ANNOTATIONS = [ "PerSurface/interchange_unstable" => (; long_name="1 if D_R > 0 (excluded)", dims=("surface",)), "PerSurface/linear_unstable" => (; long_name="1 if the gate's growth rate is positive (no seed threshold, w_sat kept)", dims=("surface",)), "PerSurface/below_w_ib" => (; long_name="1 if w_m < w_ib", dims=("surface",)), - "PerSurface/large_delta_prime_w" => (; long_name="1 if |Delta'| w >= 1 at w_m or w_sat", dims=("surface",)), + "PerSurface/large_delta_prime_w" => (; long_name="1 if |Delta'(w)| w >= 1 at w_m or w_sat, Delta'(w) the classical term", dims=("surface",)), "PerSurface/saturation_unconverged" => (; long_name="1 if C_sat moves more than 20% between half and double the fit window, or the window leaves the grid", dims=("surface",)), "PerSurface/complex_delta_prime" => (; long_name="1 if |Im Delta'| > 1e-3 |Re Delta'|", dims=("surface",)), diff --git a/src/Tearing/ModifiedRutherford/Problem.jl b/src/Tearing/ModifiedRutherford/Problem.jl index 8da848698..7ad38c1c1 100644 --- a/src/Tearing/ModifiedRutherford/Problem.jl +++ b/src/Tearing/ModifiedRutherford/Problem.jl @@ -1,7 +1,7 @@ # Problem.jl # # `solve(ModifiedRutherfordProblem(ffs; ...))`: the terms of the steady modified Rutherford equation and their sum F(w) -# on a log-spaced grid of full island widths, per rational surface. Diagnostic only: no roots. +# on a log-spaced grid of full island widths, per rational surface, and its roots w_m and w_sat. """ ModifiedRutherfordProblem(ffs; kwargs...) @@ -112,7 +112,7 @@ Output of `solve(::ModifiedRutherfordProblem)`. Per-surface vectors run core to - `unstable_any_seed` - F > 0 at `w_lo`. - `interchange_unstable`, `linear_unstable` - D_R > 0 (excluded), or γ > 0 (no seed threshold; `w_sat` kept). - `below_w_ib` - `w_m` below the ion banana width, where the polarization ordering fails. - - `large_delta_prime_w` - |Δ′| w ≥ 1 at `w_m` or `w_sat`. + - `large_delta_prime_w` - |Δ′(w)| w ≥ 1 at `w_m` or `w_sat`, with Δ′(w) the classical term. - `complex_delta_prime` - |Im Δ′_mn| > 10⁻³ |Re Δ′_mn|. - `saturation_unconverged` - `C_sat` not converged in the fit window (see [`saturation_coefficient`](@ref)). - `F`, `classical`, `saturation`, `bootstrap`, `curvature`, `polarization`, `w_d` - F(w), each term, and w_d(w). @@ -335,7 +335,7 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) bs = bootstrap_term(D_nc, wd, wj; prob.k1) cv = D_I >= 0 ? NaN : - prob.curvature_model === :lutjens ? lutjens_curvature_term(D_R, D_I, K, W_d_layer, wj) : + prob.curvature_model === :lutjens ? lutjens_curvature_term(D_R, D_I, K, W_d_layer, wj; model=prob.delta_prime_model) : curvature_term(D_R, rg.H, D_I, wd, wj; prob.k1) pl = prob.pol_sign * ( @@ -368,7 +368,8 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) w_m, w_sat = excluded ? (NaN, NaN) : (flags.linear_unstable[k] ? NaN : r.w_m, r.w_sat) flags.fully_stabilized[k], flags.unstable_any_seed[k] = !excluded && r.fully_stabilized, !excluded && r.unstable_any_seed flags.below_w_ib[k] = w_m < w_ib - flags.large_delta_prime_w[k] = abs(dp_mn) * w_m >= 1 || abs(dp_mn) * w_sat >= 1 + # Δ′(w) w at the root, dimensionless at any D_I. + flags.large_delta_prime_w[k] = any(x -> isfinite(x) && abs(terms(x).cl) * x >= 1, (w_m, w_sat)) for (key, val) in pairs((; psi, q=s.q, m, n, delta_prime=dp_mn, delta_prime_imag=imag(dp_complex), D_I, D_R, H=rg.H, D_nc, f_trap=g.f_trap, mu_ratio, zeff, g.Wc_prefac, chi_perp, v_te, R0=g.R_major, omega_star_i, omega_star_e, omega_hat, omega_hat_pi, diff --git a/src/Tearing/ModifiedRutherford/Terms.jl b/src/Tearing/ModifiedRutherford/Terms.jl index cab86e0f7..3887fbdcd 100644 --- a/src/Tearing/ModifiedRutherford/Terms.jl +++ b/src/Tearing/ModifiedRutherford/Terms.jl @@ -36,16 +36,18 @@ function curvature_term(D_R::Real, H::Real, D_I::Real, w_d::Real, w::Real; k1::R end """ - lutjens_curvature_term(D_R, D_I, K, W_d, w) -> Float64 + lutjens_curvature_term(D_R, D_I, K, W_d, w; model=:mercier_power) -> Float64 Curvature term of Lütjens, Luciani & Garbet (2001) in the form of Urso et al. (2010) Eq. 1, `r_s Δ_GGJ = 6.35 D_R / √((W/r_s)² + 0.65 W_d²)`, with `W/r_s = w/K` and `W_d` the transport width in the same `(r − r_s)/r_s` reference ([`layer_transport_width`](@ref)), referred to ψ_N as the outer Δ′ is (`K^(−2α)`, -`α = √(−D_I)`). Its `w → 0` limit is SLAYER's critical offset `−√2 π^(3/2) D_R/W_d` (6.35/√0.65 = √2 π^(3/2)); for -`w ≫ W_d` it is the large-aspect-ratio `6.35 D_R/W` (La Haye 2006). Stabilizing for `D_R < 0`. +`α = √(−D_I)`) and carried through [`classical_term`](@ref) as [`saturation_term`](@ref) is, so it has the units of +the other terms. Its `w → 0` limit is then `classical_term(−D_c K^(−2α), D_I, w)` with SLAYER's critical offset +`D_c = −√2 π^(3/2) D_R/W_d` (6.35/√0.65 = √2 π^(3/2)), so classical plus curvature change sign where SLAYER's gate does; +for `w ≫ W_d` it is the large-aspect-ratio `6.35 D_R/W` (La Haye 2006). Stabilizing for `D_R < 0`. """ -function lutjens_curvature_term(D_R::Real, D_I::Real, K::Real, W_d::Real, w::Real) - return K^(-2 * sqrt(-D_I)) * 6.35 * D_R / sqrt((w / K)^2 + 0.65 * W_d^2) +function lutjens_curvature_term(D_R::Real, D_I::Real, K::Real, W_d::Real, w::Real; model::Symbol=:mercier_power) + return classical_term(K^(-2 * sqrt(-D_I)) * 6.35 * D_R / sqrt((w / K)^2 + 0.65 * W_d^2), D_I, w; model) end """ diff --git a/test/runtests_modified_rutherford.jl b/test/runtests_modified_rutherford.jl index d40e6ec56..ed86262d1 100644 --- a/test/runtests_modified_rutherford.jl +++ b/test/runtests_modified_rutherford.jl @@ -44,8 +44,11 @@ using TOML W_d = MR.layer_transport_width(D_R, D_c) @test MR.lutjens_curvature_term(D_R, -0.25, K, W_d, 1e-9) * K ≈ -D_c rtol = 1e-3 @test MR.lutjens_curvature_term(D_R, -0.25, K, W_d, 1e3 * W_d * K) ≈ 6.35 * D_R / (1e3 * W_d * K) rtol = 1e-6 - # Referred to ψ_N as the outer Δ′: K^(−2α) at D_I < −¼. - @test MR.lutjens_curvature_term(D_R, -0.3, K, W_d, 1e-9) * K^(2 * sqrt(0.3)) ≈ -D_c rtol = 1e-3 + # Away from D_I = −¼, classical plus curvature still change sign where SLAYER's gate does, at any small w. + for D_I in (-0.3, -0.4), w in (1e-6, 1e-3 * W_d * K), f in (0.99, 1.01) + dp = f * D_c * K^(-2 * sqrt(-D_I)) + @test sign(MR.classical_term(dp, D_I, w) + MR.lutjens_curvature_term(D_R, D_I, K, W_d, w)) == sign(f - 1) + end @test MR.layer_transport_width(D_R, 0.0) == Inf end diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index 9a8d176f5..ceb8722f6 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -563,7 +563,8 @@ include("display_check.jl") @testset "modified Rutherford under [Tearing.ModifiedRutherford]" begin # The MRE alone: a [Tearing] holding only the sub-table runs no layer solve, and its deck replays. - settings = (; polarization=true, omega_model=:exb, omega_factor=0.5, delta_prime_factor=0.9, D_nc_factor=1.1, rho_theta_factor=1.2, nw=40) + settings = (; polarization=true, omega_model=:exb, omega_factor=0.5, delta_prime_factor=0.9, D_nc_factor=1.1, rho_theta_factor=1.2, nw=40, + curvature_model=:benjamin) mktempdir() do dir psi = collect(range(0.0, 1.0; length=101)) h5open(joinpath(dir, "kinetic.h5"), "w") do f @@ -594,6 +595,7 @@ include("display_check.jl") @test recorded["Tearing"] == Dict{String,Any}("ModifiedRutherford" => recorded["Tearing"]["ModifiedRutherford"]) @test recorded["Tearing"]["ModifiedRutherford"]["omega_factor"] == 0.5 @test recorded["Tearing"]["ModifiedRutherford"]["omega_model"] == "exb" + @test recorded["Tearing"]["ModifiedRutherford"]["curvature_model"] == "benjamin" @test isequal(read(f["Tearing/ModifiedRutherford/PerSurface/w_m"]), api.w_m) end end @@ -612,7 +614,6 @@ include("display_check.jl") api = solve(ModifiedRutherfordProblem(reference.ffs; tearing=reference.tearing, nw=40)) @test reference.modified_rutherford.problem.tearing === reference.tearing @test isequal(api.gamma, reference.modified_rutherford.gamma) && all(isfinite, api.gamma) - @test api.linear_unstable == (api.gamma .> 0) @test isequal(api.w_m, reference.modified_rutherford.w_m) # The default Lütjens curvature takes SLAYER's toroidal critical offset, so it equals a toroidal gate's at the same χ⊥. # χ⊥ varies by surface here, so the gate is built with the 3/1 surface's χ⊥ and compared there. @@ -622,8 +623,16 @@ include("display_check.jl") row = findfirst(x -> isapprox(x, api.psi[k]; atol=1e-8), gate.rational_psi) @test gate.params[row].dc_tmp ≈ api.D_c[k] rtol = 1e-10 ben = solve(ModifiedRutherfordProblem(reference.ffs; tearing=reference.tearing, nw=40, curvature_model=:benjamin)) - @test all(isnan, ben.D_c) && ben.classical ≈ api.classical && !(ben.curvature ≈ api.curvature) + @test all(isnan, ben.D_c) && !(ben.curvature ≈ api.curvature) @test_throws "curvature_model" solve(ModifiedRutherfordProblem(reference.ffs; curvature_model=:other)) + # A TearingResult saved after the MRE keeps Tearing/ModifiedRutherford and its recorded sub-table. + h5open(joinpath(dir, "reversed.h5"), "w") do f + save_to_hdf5(f, api) + save_to_hdf5(f, reference.tearing) + @test isequal(read(f["Tearing/ModifiedRutherford/PerSurface/w_m"]), api.w_m) && haskey(f, "Tearing/PerSurface") + recorded = TOML.parse(read(f["Input/gpec_toml_raw"]))["Tearing"] + @test recorded["ModifiedRutherford"]["nw"] == 40 && haskey(recorded, "model") + end end # A forward solve has no Δ′, so the deck stops at parse time. @@ -631,6 +640,9 @@ include("display_check.jl") deck_dir = _stage_deck(dir; extra_sections=Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => Dict{String,Any}()))) @test_throws "needs Δ′" GPEC.parse_deck(TOML.parsefile(joinpath(deck_dir, "gpec.toml")), deck_dir) end + # omega_ratio is the one optional (nothing-default) key: a deck value reaches the problem as a Float64. + @test GPEC.modified_rutherford_from_deck(Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => + Dict{String,Any}("omega_ratio" => 1)))).omega_ratio === 1.0 end @testset "Callen threshold under [Tearing.Callen]" begin @@ -650,13 +662,13 @@ include("display_check.jl") reference = GPEC.main([dir]) @test reference.tearing === nothing && reference.callen isa CallenResult - ric = solve(ForceBalanceProblem(reference.ffs.equil; GPEC.parse_deck(deck, dir).force_balance.keywords...), Riccati()) + ric = reference.callen.problem.delta_prime + @test ric isa ForceFreeStatesResult api = solve(CallenProblem(reference.pe; delta_prime=ric)) @test api.delta_c ≈ reference.callen.delta_c rtol = 1e-10 @test api.hw_v_crit ≈ reference.callen.hw_v_crit rtol = 1e-10 @test isequal(api.n_roots, reference.callen.n_roots) - cyl = solve(CallenProblem(reference.pe; delta_prime_source=:cylindrical)) - @test cyl.delta_c ≈ -2 .* cyl.m ./ cyl.rho_tor + @test !(solve(CallenProblem(reference.pe; delta_prime_source=:cylindrical)).delta_c ≈ api.delta_c) @test_throws "needs delta_prime" solve(CallenProblem(reference.pe)) mre = solve(ModifiedRutherfordProblem(reference.ffs; delta_prime_source=:cylindrical, saturation=true, nw=40)) @@ -664,7 +676,6 @@ include("display_check.jl") @test isequal(mre.w_m, reference.modified_rutherford.w_m) @test all(<(0), mre.delta_prime) @test all(isfinite, mre.C_sat) - @test all(all(sign.(mre.saturation[k, :]) .== -sign(mre.C_sat[k])) for k in eachindex(mre.psi) if mre.D_I[k] < 0) h5open(joinpath(dir, "gpec.h5")) do f @test read(f["Tearing/Callen/delta_c"]) ≈ api.delta_c rtol = 1e-10 recorded = TOML.parse(read(f["Input/gpec_toml_raw"]))["Tearing"] From 0038014a78dac1d85ceb1dd91b025311cbd2c951 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sun, 8 Nov 2026 00:17:22 -0500 Subject: [PATCH 14/26] Tearing - TEST - Check the recorded inner_model and the parsed omega_ratio by their real keys Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- test/runtests_solve_api.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index ceb8722f6..0466a2f4c 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -631,7 +631,7 @@ include("display_check.jl") save_to_hdf5(f, reference.tearing) @test isequal(read(f["Tearing/ModifiedRutherford/PerSurface/w_m"]), api.w_m) && haskey(f, "Tearing/PerSurface") recorded = TOML.parse(read(f["Input/gpec_toml_raw"]))["Tearing"] - @test recorded["ModifiedRutherford"]["nw"] == 40 && haskey(recorded, "model") + @test recorded["ModifiedRutherford"]["nw"] == 40 && recorded["inner_model"] == "SLAYER" end end @@ -642,7 +642,7 @@ include("display_check.jl") end # omega_ratio is the one optional (nothing-default) key: a deck value reaches the problem as a Float64. @test GPEC.modified_rutherford_from_deck(Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => - Dict{String,Any}("omega_ratio" => 1)))).omega_ratio === 1.0 + Dict{String,Any}("omega_ratio" => 1))))[:omega_ratio] == 1.0 end @testset "Callen threshold under [Tearing.Callen]" begin From f8ed6da3992d0e2c7c9d16fc234d06e62244308b Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sun, 8 Nov 2026 00:22:06 -0500 Subject: [PATCH 15/26] Tearing - TEST - List the modified_rutherford and callen fields of main's return Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- test/runtests_result_struct.jl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/runtests_result_struct.jl b/test/runtests_result_struct.jl index f38ada7aa..6869f36ee 100644 --- a/test/runtests_result_struct.jl +++ b/test/runtests_result_struct.jl @@ -45,8 +45,9 @@ include("display_check.jl") ret = GeneralizedPerturbedEquilibrium.main([dir]) # One field per stage, nothing for the sections the deck does not hold. - @test keys(ret) == (:equil, :local_stability, :ffs, :match, :resp, :pe, :kinetic, :error_fields, :tearing) - @test ret.equil === ret.ffs.equil && all(isnothing, (ret.match, ret.resp, ret.pe, ret.kinetic, ret.error_fields, ret.tearing)) + @test keys(ret) == (:equil, :local_stability, :ffs, :match, :resp, :pe, :kinetic, :error_fields, :tearing, :modified_rutherford, :callen) + @test ret.equil === ret.ffs.equil && + all(isnothing, (ret.match, ret.resp, ret.pe, ret.kinetic, ret.error_fields, ret.tearing, ret.modified_rutherford, ret.callen)) ffs = ret.ffs forward_result[] = ffs @test ffs isa FFS.ForceFreeStatesResult From 86fc2acc6fdea9cbcb0b4bfbcaa82258a7d2a7a1 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sun, 8 Nov 2026 00:27:04 -0500 Subject: [PATCH 16/26] =?UTF-8?q?Tearing=20-=20DOCS=20-=20State=20that=20t?= =?UTF-8?q?he=20L=C3=BCtjens=20term's=20classical=20factor=20is=20a=20conv?= =?UTF-8?q?ention=20for=20D=5FI=20=E2=89=A0=20=E2=88=92=C2=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- src/Tearing/ModifiedRutherford/Terms.jl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Tearing/ModifiedRutherford/Terms.jl b/src/Tearing/ModifiedRutherford/Terms.jl index 3887fbdcd..582e7e25f 100644 --- a/src/Tearing/ModifiedRutherford/Terms.jl +++ b/src/Tearing/ModifiedRutherford/Terms.jl @@ -44,7 +44,8 @@ Curvature term of Lütjens, Luciani & Garbet (2001) in the form of Urso et al. ( `α = √(−D_I)`) and carried through [`classical_term`](@ref) as [`saturation_term`](@ref) is, so it has the units of the other terms. Its `w → 0` limit is then `classical_term(−D_c K^(−2α), D_I, w)` with SLAYER's critical offset `D_c = −√2 π^(3/2) D_R/W_d` (6.35/√0.65 = √2 π^(3/2)), so classical plus curvature change sign where SLAYER's gate does; -for `w ≫ W_d` it is the large-aspect-ratio `6.35 D_R/W` (La Haye 2006). Stabilizing for `D_R < 0`. +for `w ≫ W_d` its Δ′ form is the large-aspect-ratio `6.35 D_R/W` (La Haye 2006). The source has no `D_I ≠ −¼` form; +the factor of `classical_term` is the convention that keeps the seam at every `D_I`. Stabilizing for `D_R < 0`. """ function lutjens_curvature_term(D_R::Real, D_I::Real, K::Real, W_d::Real, w::Real; model::Symbol=:mercier_power) return classical_term(K^(-2 * sqrt(-D_I)) * 6.35 * D_R / sqrt((w / K)^2 + 0.65 * W_d^2), D_I, w; model) From 8ccee21874c8689db5ca61db55cedf24afff5338 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sun, 8 Nov 2026 08:50:12 -0500 Subject: [PATCH 17/26] Tearing - TEST - Drop a project reference from the modified Rutherford test header Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- test/runtests_modified_rutherford.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/runtests_modified_rutherford.jl b/test/runtests_modified_rutherford.jl index ed86262d1..a810c750e 100644 --- a/test/runtests_modified_rutherford.jl +++ b/test/runtests_modified_rutherford.jl @@ -1,6 +1,6 @@ using TOML -# The modified Rutherford terms (physics.md of the mre-islands work order): the limits and signs each term must show +# The modified Rutherford terms: the limits and signs each term must show # alone, and the surface geometry against Fortran RDCON (MRE_flag output) on the DIII-D-like example. @testset "ModifiedRutherford terms" begin MR = GeneralizedPerturbedEquilibrium.ModifiedRutherford From a06e26deb63d12507c067c55fbdc06cab732f63d Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sun, 8 Nov 2026 10:49:53 -0500 Subject: [PATCH 18/26] Tearing.ModifiedRutherford - FEATURE! - Take w_sat as the first falling root above the island start and flag unsaturated, off-grid and gate-disagreeing surfaces An island seeded at w_m grows to the first root above it, not the largest; where F(w_lo) > 0 it starts from w_lo. New flags: unsaturated (F > 0 at w_max), outside_plasma (psi +- w_sat/2 leaves the psi grid) and gate_disagrees (the sign of F(w_lo) against the linear growth rate). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- docs/src/inner_layer.md | 3 +- src/Tearing/ModifiedRutherford/HDF5Output.jl | 10 ++++-- src/Tearing/ModifiedRutherford/Problem.jl | 34 ++++++++++++++------ test/runtests_modified_rutherford.jl | 12 +++++++ 4 files changed, 46 insertions(+), 13 deletions(-) diff --git a/docs/src/inner_layer.md b/docs/src/inner_layer.md index 79d534a81..4b76f77ac 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -117,7 +117,8 @@ F(w) = \Delta'(w) + \Delta_\mathrm{nc}(w) + \Delta_\mathrm{GGJ}(w) + \Delta_\mat the classical drive with its finite-pressure power law, the bootstrap drive with the transport threshold ``w_d``, the Glasser–Greene–Johnson curvature term and the ion-polarization term (Benjamin et al. 2026, Eqs. 7, 10–14 and C.1). Each term has a switch and a coefficient; the polarization term is off by default. A finite-width saturation term, `saturation = true` (off by default), replaces ``r_s\Delta'`` by ``r_s\Delta'(0) - C_\mathrm{sat}W/r_s`` with ``C_\mathrm{sat} = 0.8\alpha_s^2 - 0.27\beta_s - 0.09\alpha_s`` from the current-density gradients at the surface (Fitzpatrick 2023, Eq. 9.15), so that a surface with ``\Delta' > 0`` saturates. The curvature term defaults to the Lütjens et al. (2001) form ``r_s\Delta_\mathrm{GGJ} = 6.35 D_R/\sqrt{(W/r_s)^2 + 0.65 W_d^2}`` with ``W_d`` from SLAYER's toroidal critical offset, so that at small widths it equals the critical ``\Delta`` of the linear SLAYER layer; `curvature_model = "benjamin"` selects Benjamin et al. (2026) Eq. 12. The surface geometry follows the Fortran RDCON `mercier.f` averages, and the densities, temperatures, ``\omega_E``, ``Z_\mathrm{eff}`` and ``\chi_\perp`` come from the equilibrium's kinetic profiles. A `[Tearing]` section holding only this sub-table runs no layer solve. In a script, `solve(ModifiedRutherfordProblem(ffs; polarization=true))` returns the same [`ModifiedRutherfordResult`](@ref Tearing.ModifiedRutherford.ModifiedRutherfordResult). -The roots of ``F`` are bracketed on the grid and refined by Brent's method: the seed threshold ``w_m`` is the smallest crossing from ``F<0`` to ``F>0`` and the saturated width ``w_\mathrm{sat}`` the largest from ``F>0`` to ``F<0``. +The roots of ``F`` are bracketed on the grid and refined by Brent's method: the seed threshold ``w_m`` is the smallest crossing from ``F<0`` to ``F>0`` and the saturated width ``w_\mathrm{sat}`` the first crossing from ``F>0`` to ``F<0`` above where the island starts, ``w_\mathrm{lo}`` when ``F(w_\mathrm{lo})>0``, else ``w_m``. +Flags mark an island that does not saturate in the search range (`unsaturated`), one whose ``w_\mathrm{sat}`` reaches past the equilibrium grid (`outside_plasma`), and a sign of ``F(w_\mathrm{lo})`` that disagrees with the gate's growth rate (`gate_disagrees`). When the deck also has a `[Tearing]` layer solve, its uncoupled growth rates gate the roots (a surface with ``\gamma>0`` grows from noise, so it has no seed threshold ``w_m``, but keeps its saturated width) and its resistive layer width, converted to ``\psi_N`` with the layer's own radial label, sets the lower end of the search; in a script, pass it as `ModifiedRutherfordProblem(ffs; tearing)`. Surfaces with ``D_R>0`` are excluded as resistive-interchange unstable, and roots below the ion banana width or with ``|\Delta'|w\ge1`` are flagged. Lengths in metres (the layer width, the poloidal ion Larmor radius and the banana width) map to ``\psi_N`` with the toroidal-flux label of `radial_label`, as SLAYER's do. diff --git a/src/Tearing/ModifiedRutherford/HDF5Output.jl b/src/Tearing/ModifiedRutherford/HDF5Output.jl index 6f552650d..a12511248 100644 --- a/src/Tearing/ModifiedRutherford/HDF5Output.jl +++ b/src/Tearing/ModifiedRutherford/HDF5Output.jl @@ -38,8 +38,9 @@ end const _MRE_PER_SURFACE = (:psi, :q, :m, :n, :delta_prime, :delta_prime_imag, :D_I, :D_R, :H, :D_nc, :f_trap, :mu_ratio, :zeff, :Wc_prefac, :chi_perp, :v_te, :R0, :omega_star_i, :omega_star_e, :omega_hat, :omega_hat_pi, :beta_p, :rho_theta, :w_ib, :eps, :gamma, :delta_r, :w_lo, :D_c, :W_d_layer, :alpha_s, :beta_s, :C_sat, :w_m, :w_sat) -const _MRE_FLAGS = (:fully_stabilized, :unstable_any_seed, :interchange_unstable, :linear_unstable, :below_w_ib, :large_delta_prime_w, - :complex_delta_prime, :saturation_unconverged) +const _MRE_FLAGS = + (:fully_stabilized, :unstable_any_seed, :unsaturated, :outside_plasma, :gate_disagrees, :interchange_unstable, :linear_unstable, :below_w_ib, :large_delta_prime_w, + :complex_delta_prime, :saturation_unconverged) const _MRE_TERMS = (:F, :classical, :saturation, :bootstrap, :curvature, :polarization, :w_d) # Julia's surface × width matrices land in HDF5 as (width, surface). @@ -90,10 +91,13 @@ const MRE_H5_ANNOTATIONS = [ "PerSurface/C_sat" => (; long_name="saturation coefficient 0.8 alpha_s^2 - 0.27 beta_s - 0.09 alpha_s (Fitzpatrick 2023 Eq. 9.15)", dims=("surface",)), "PerSurface/w_m" => (; long_name="seed threshold: smallest F crossing from negative to positive, NaN if none", units="psi_N", dims=("surface",)), - "PerSurface/w_sat" => (; long_name="saturated width: largest F crossing from positive to negative, NaN if none", units="psi_N", + "PerSurface/w_sat" => (; long_name="saturated width: first falling F crossing above the island start (w_lo or w_m), NaN if none", units="psi_N", dims=("surface",)), "PerSurface/fully_stabilized" => (; long_name="1 if F < 0 on the whole search range", dims=("surface",)), "PerSurface/unstable_any_seed" => (; long_name="1 if F > 0 at w_lo", dims=("surface",)), + "PerSurface/unsaturated" => (; long_name="1 if F > 0 at w_max: no saturation in the search range", dims=("surface",)), + "PerSurface/outside_plasma" => (; long_name="1 if psi +- w_sat/2 leaves the equilibrium psi grid", dims=("surface",)), + "PerSurface/gate_disagrees" => (; long_name="1 if F(w_lo) > 0 with gamma < 0, or F(w_lo) < 0 with gamma > 0", dims=("surface",)), "PerSurface/interchange_unstable" => (; long_name="1 if D_R > 0 (excluded)", dims=("surface",)), "PerSurface/linear_unstable" => (; long_name="1 if the gate's growth rate is positive (no seed threshold, w_sat kept)", dims=("surface",)), "PerSurface/below_w_ib" => (; long_name="1 if w_m < w_ib", dims=("surface",)), diff --git a/src/Tearing/ModifiedRutherford/Problem.jl b/src/Tearing/ModifiedRutherford/Problem.jl index 7ad38c1c1..8072d7935 100644 --- a/src/Tearing/ModifiedRutherford/Problem.jl +++ b/src/Tearing/ModifiedRutherford/Problem.jl @@ -19,7 +19,8 @@ the Mercier, resistive-interchange and Pfirsch-Schlüter indices D_I, D_R = E + equilibrium's kinetic data, as every tearing layer reads it. A deck states it as `[Tearing.ModifiedRutherford]`. The roots of F are refined between grid points from `w_lo = max(δ_r, w_min)`: the seed threshold `w_m` is the -smallest crossing from F < 0 to F > 0 and the saturated width `w_sat` the largest from F > 0 to F < 0. Surfaces that +smallest crossing from F < 0 to F > 0 and the saturated width `w_sat` the first crossing from F > 0 to F < 0 above where +the island starts (`w_lo` when F(w_lo) > 0, else `w_m`). Surfaces that are resistive-interchange unstable (D_R > 0) get no roots; linearly tearing unstable ones (γ > 0 in `tearing`) grow from noise, so they get no seed threshold but keep `w_sat`. @@ -110,6 +111,9 @@ Output of `solve(::ModifiedRutherfordProblem)`. Per-surface vectors run core to - `w_m_star` - Smallest `w_m` over the surfaces. - `fully_stabilized` - F < 0 on the whole search range. - `unstable_any_seed` - F > 0 at `w_lo`. + - `unsaturated` - F > 0 at `w_max`: an island past its last rising root does not saturate in the search range. + - `outside_plasma` - ψ ± `w_sat`/2 leaves the equilibrium's ψ grid. + - `gate_disagrees` - F(w_lo) > 0 with γ < 0, or F(w_lo) < 0 with γ > 0. - `interchange_unstable`, `linear_unstable` - D_R > 0 (excluded), or γ > 0 (no seed threshold; `w_sat` kept). - `below_w_ib` - `w_m` below the ion banana width, where the polarization ordering fails. - `large_delta_prime_w` - |Δ′(w)| w ≥ 1 at `w_m` or `w_sat`, with Δ′(w) the classical term. @@ -158,6 +162,9 @@ struct ModifiedRutherfordResult{P} w_m_star::Float64 fully_stabilized::Vector{Bool} unstable_any_seed::Vector{Bool} + unsaturated::Vector{Bool} + outside_plasma::Vector{Bool} + gate_disagrees::Vector{Bool} interchange_unstable::Vector{Bool} linear_unstable::Vector{Bool} below_w_ib::Vector{Bool} @@ -209,23 +216,27 @@ end """ - mre_roots(F, w_lo, w_grid) -> (; w_m, w_sat, fully_stabilized, unstable_any_seed) + mre_roots(F, w_lo, w_grid) -> (; w_m, w_sat, fully_stabilized, unstable_any_seed, unsaturated) Roots of `F` on `[w_lo, maximum(w_grid)]`, bracketed on `w_lo` and the grid points above it and refined by Brent's -method: `w_m` is the smallest crossing from negative to positive, `w_sat` the largest from positive to negative -(`NaN` when absent). +method: `w_m` is the smallest crossing from negative to positive, and `w_sat` the first crossing from positive to +negative above where an island starts, `w_lo` when F(w_lo) > 0, else `w_m` (`NaN` when absent). `unsaturated` is +F > 0 at the top of the grid. """ function mre_roots(F, w_lo::Real, w_grid::AbstractVector) nodes = vcat(Float64(w_lo), filter(>(w_lo), w_grid)) vals = F.(nodes) - w_m, w_sat = NaN, NaN + rising, falling = Float64[], Float64[] for j in 1:(length(nodes)-1) a, b = vals[j], vals[j+1] isfinite(a) && isfinite(b) && a != 0 && (a * b < 0 || b == 0) || continue - root = find_zero(F, (nodes[j], nodes[j+1]), Brent()) - a < 0 ? (isnan(w_m) && (w_m = root)) : (w_sat = root) + push!(a < 0 ? rising : falling, find_zero(F, (nodes[j], nodes[j+1]), Brent())) end - return (; w_m, w_sat, fully_stabilized=all(<(0), vals), unstable_any_seed=vals[1] > 0) + w_m = isempty(rising) ? NaN : first(rising) + start = vals[1] > 0 ? nodes[1] : w_m + above = filter(>(start), falling) + w_sat = isempty(above) ? NaN : first(above) + return (; w_m, w_sat, fully_stabilized=all(<(0), vals), unstable_any_seed=vals[1] > 0, unsaturated=vals[end] > 0) end # SLAYER's toroidal critical offset D_c and the W_d it implies at one surface, with χ⊥ for every χ input. @@ -273,7 +284,8 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) H=vec0(), D_nc=vec0(), f_trap=vec0(), mu_ratio=vec0(), zeff=vec0(), Wc_prefac=vec0(), chi_perp=vec0(), v_te=vec0(), R0=vec0(), omega_star_i=vec0(), omega_star_e=vec0(), omega_hat=vec0(), omega_hat_pi=vec0(), beta_p=vec0(), rho_theta=vec0(), w_ib=vec0(), eps=vec0(), gamma=vec0(), delta_r=vec0(), w_lo=vec0(), D_c=vec0(), W_d_layer=vec0(), alpha_s=vec0(), beta_s=vec0(), C_sat=vec0(), w_m=vec0(), w_sat=vec0()) - flags = (; fully_stabilized=flag0(), unstable_any_seed=flag0(), interchange_unstable=flag0(), linear_unstable=flag0(), + flags = (; fully_stabilized=flag0(), unstable_any_seed=flag0(), unsaturated=flag0(), outside_plasma=flag0(), gate_disagrees=flag0(), + interchange_unstable=flag0(), linear_unstable=flag0(), below_w_ib=flag0(), large_delta_prime_w=flag0(), complex_delta_prime=flag0(), saturation_unconverged=flag0()) mats = Dict(k => zeros(ns, prob.nw) for k in (:F, :classical, :saturation, :bootstrap, :curvature, :polarization, :w_d)) @@ -367,6 +379,9 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) # A linearly unstable surface grows from noise, so it has no seed threshold; the MRE still sets its saturation. w_m, w_sat = excluded ? (NaN, NaN) : (flags.linear_unstable[k] ? NaN : r.w_m, r.w_sat) flags.fully_stabilized[k], flags.unstable_any_seed[k] = !excluded && r.fully_stabilized, !excluded && r.unstable_any_seed + flags.unsaturated[k] = !excluded && r.unsaturated + flags.outside_plasma[k] = isfinite(w_sat) && (psi - w_sat / 2 < psi_lim[1] || psi + w_sat / 2 > psi_lim[2]) + flags.gate_disagrees[k] = !excluded && isfinite(gamma) && (r.unstable_any_seed ? gamma < 0 : gamma > 0) flags.below_w_ib[k] = w_m < w_ib # Δ′(w) w at the root, dimensionless at any D_I. flags.large_delta_prime_w[k] = any(x -> isfinite(x) && abs(terms(x).cl) * x >= 1, (w_m, w_sat)) @@ -383,6 +398,7 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) out.H, out.D_nc, out.f_trap, out.mu_ratio, out.zeff, out.Wc_prefac, out.chi_perp, out.v_te, out.R0, out.omega_star_i, out.omega_star_e, out.omega_hat, out.omega_hat_pi, out.beta_p, out.rho_theta, out.w_ib, out.eps, out.gamma, out.delta_r, out.w_lo, out.D_c, out.W_d_layer, out.alpha_s, out.beta_s, out.C_sat, out.w_m, out.w_sat, w_m_star, Vector{Bool}(flags.fully_stabilized), Vector{Bool}(flags.unstable_any_seed), + Vector{Bool}(flags.unsaturated), Vector{Bool}(flags.outside_plasma), Vector{Bool}(flags.gate_disagrees), Vector{Bool}(flags.interchange_unstable), Vector{Bool}(flags.linear_unstable), Vector{Bool}(flags.below_w_ib), Vector{Bool}(flags.large_delta_prime_w), Vector{Bool}(flags.complex_delta_prime), Vector{Bool}(flags.saturation_unconverged), diff --git a/test/runtests_modified_rutherford.jl b/test/runtests_modified_rutherford.jl index a810c750e..f0c301ef6 100644 --- a/test/runtests_modified_rutherford.jl +++ b/test/runtests_modified_rutherford.jl @@ -93,6 +93,18 @@ using TOML s = MR.mre_roots(w -> (0.05 - w) / w, 1e-5, grid) @test s.unstable_any_seed && isnan(s.w_m) @test s.w_sat ≈ 0.05 rtol = 1e-10 + # Roots at 2e-3 (falling), 3e-3 (rising), 0.02 (falling), 0.05 (rising): from w_lo the island saturates at 2e-3; F > 0 at the top. + f4(w) = (2e-3 - w) * (w - 3e-3) * (0.02 - w) * (w - 0.05) / w^4 + u = MR.mre_roots(f4, 1e-5, grid) + @test u.unstable_any_seed + @test u.w_m ≈ 3e-3 rtol = 1e-10 + @test u.w_sat ≈ 2e-3 rtol = 1e-10 + @test u.unsaturated + # With F < 0 at w_lo the island starts at w_m = 2e-3 and saturates at the next falling root, 3e-3, not at 0.05. + v = MR.mre_roots(w -> -f4(w), 1e-5, grid) + @test v.w_m ≈ 2e-3 rtol = 1e-10 + @test v.w_sat ≈ 3e-3 rtol = 1e-10 + @test !v.unsaturated end @testset "Callen cubic: both positive roots solve it and merge at the critical vacuum width" begin From 0724fbcc1fffe79a66290aae2d8aa770f39842f0 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sun, 8 Nov 2026 10:51:20 -0500 Subject: [PATCH 19/26] Tearing - REFACTOR! - Move the Callen threshold into its own Tearing.Callen module with output under Tearing/Callen/PerSurface Callen shares no geometry or terms with the modified Rutherford equation. Its result names the surface rational_psi and rational_q, as TearingResult does, and its datasets move to Tearing/Callen/PerSurface/. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- docs/src/inner_layer.md | 8 +++- src/Tearing/Callen/Callen.jl | 22 +++++++++ src/Tearing/Callen/HDF5Output.jl | 47 +++++++++++++++++++ .../Callen.jl => Callen/Problem.jl} | 14 +++--- src/Tearing/ModifiedRutherford/HDF5Output.jl | 39 --------------- .../ModifiedRutherford/ModifiedRutherford.jl | 4 +- src/Tearing/Tearing.jl | 7 ++- test/runtests_modified_rutherford.jl | 9 ++-- test/runtests_solve_api.jl | 2 +- 9 files changed, 96 insertions(+), 56 deletions(-) create mode 100644 src/Tearing/Callen/Callen.jl create mode 100644 src/Tearing/Callen/HDF5Output.jl rename src/Tearing/{ModifiedRutherford/Callen.jl => Callen/Problem.jl} (94%) diff --git a/docs/src/inner_layer.md b/docs/src/inner_layer.md index 4b76f77ac..489af0fc3 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -128,7 +128,7 @@ The island frequency in ``\Delta_\mathrm{pol}`` is ``\omega_{*e}(1+\eta_e/4)`` ( The vacuum resonant field is the coils' resonant harmonic on the rational surface over its area. Its intrinsic index ``\Delta_c`` is the computed ``\Delta'_{mn}`` by default (`main` adds a Riccati solve for it, since the perturbed equilibrium needs a forward one; in a script, pass it as `CallenProblem(pe; delta_prime)`), or Callen's cylindrical ``-2m/\rho`` of Fortran GPEC 1.6.0 with `delta_prime_source = "cylindrical"`. The same switch on `[Tearing.ModifiedRutherford]` replaces ``\Delta'_{mn}`` by the cylindrical ``r_s\Delta' = -2m``, so the MRE then runs on any solve, forward included, as a reference case. -In a script, `solve(CallenProblem(pe))` returns the same [`CallenResult`](@ref Tearing.ModifiedRutherford.CallenResult). +In a script, `solve(CallenProblem(pe))` returns the same [`CallenResult`](@ref Tearing.Callen.CallenResult). ## Numerical notes @@ -207,3 +207,9 @@ Modules = [GeneralizedPerturbedEquilibrium.Tearing.CriticalResonantField] ```@autodocs Modules = [GeneralizedPerturbedEquilibrium.Tearing.ModifiedRutherford] ``` + +### Callen + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Tearing.Callen] +``` diff --git a/src/Tearing/Callen/Callen.jl b/src/Tearing/Callen/Callen.jl new file mode 100644 index 000000000..5eafbc541 --- /dev/null +++ b/src/Tearing/Callen/Callen.jl @@ -0,0 +1,22 @@ +# Callen's reduced modified Rutherford equation for coil-driven islands: the critical vacuum island width, the +# critical resonant field and the steady half-widths per resonant row of a single-n perturbed equilibrium. + +module Callen + +using HDF5 +import CommonSolve +import FastInterpolations + +import ...Utilities +import ...ForcingTerms +import ...Utilities: save_to_hdf5 +using ...Utilities.HDF5Annotations: replace_group! +using ...Utilities.PhysicalConstants: M_P, E_CHG +using ...InnerLayer.SLAYER: _slayer_profiles + +include("Problem.jl") +include("HDF5Output.jl") + +export CallenProblem, CallenResult, callen_cubic_roots + +end # module Callen diff --git a/src/Tearing/Callen/HDF5Output.jl b/src/Tearing/Callen/HDF5Output.jl new file mode 100644 index 000000000..c98806f64 --- /dev/null +++ b/src/Tearing/Callen/HDF5Output.jl @@ -0,0 +1,47 @@ +# HDF5Output.jl +# +# Writer for a `CallenResult`, under the caller's parent group: +# +# Tearing/ +# └── Callen/ +# └── PerSurface/ -- one dataset per field over the resonant rows + +""" + save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::CallenResult) + +Write `result` into `Tearing/Callen/PerSurface/` of `parent`, one dataset per field over the resonant rows, replacing any existing +one and keeping the rest of `Tearing/`, and record its deck section. +""" +function save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::CallenResult) + tearing = haskey(parent, "Tearing") ? parent["Tearing"] : create_group(parent, "Tearing") + g = replace_group!(tearing, "Callen") + for (key, _) in CALLEN_H5_ANNOTATIONS + g[key] = getfield(result, Symbol(basename(key))) + end + Utilities.HDF5Annotations.annotate!(g, CALLEN_H5_ANNOTATIONS) + Utilities.record_inputs!(parent, result) + return g +end + +const CALLEN_H5_ANNOTATIONS = [ + "PerSurface/rational_psi" => (; long_name="normalized poloidal flux of the rational surface", units="psi_N", dims=("surface",)), + "PerSurface/rational_q" => (; long_name="safety factor at the surface", dims=("surface",)), + "PerSurface/m" => (; long_name="resonant poloidal mode number", dims=("surface",)), + "PerSurface/n" => (; long_name="resonant toroidal mode number", dims=("surface",)), + "PerSurface/rho_tor" => (; long_name="toroidal-flux radius (Phi_tor/(pi B0))^(1/2)", units="m", dims=("surface",)), + "PerSurface/drho_dpsi" => (; long_name="d rho_tor / d psi_N", units="m", dims=("surface",)), + "PerSurface/rho_i" => (; long_name="ion gyroradius in the axis field", units="m", dims=("surface",)), + "PerSurface/w_pol" => (; long_name="polarization width 0.5 q rho_i/(rho/R0)^(1/2)", units="m", dims=("surface",)), + "PerSurface/delta_rmp" => (; long_name="Callen Delta'_RMP from the total resonant field", units="1/m", dims=("surface",)), + "PerSurface/delta_c" => + (; long_name="Callen intrinsic Delta' in 1/m of rho: computed Delta'_mn, or -2m/rho with delta_prime_source = cylindrical", units="1/m", dims=("surface",)), + "PerSurface/b_res" => (; long_name="total resonant field |flux|/area", units="T", dims=("surface",)), + "PerSurface/b_vac" => (; long_name="vacuum (coil) resonant field |flux|/area", units="T", dims=("surface",)), + "PerSurface/hw_isl" => (; long_name="total-field island half-width", units="psi_N", dims=("surface",)), + "PerSurface/hw_v" => (; long_name="vacuum island half-width", units="psi_N", dims=("surface",)), + "PerSurface/hw_v_crit" => (; long_name="critical vacuum island half-width", units="psi_N", dims=("surface",)), + "PerSurface/hw_min" => (; long_name="island half-width at the smaller positive root of the Callen cubic, NaN if none", units="psi_N", dims=("surface",)), + "PerSurface/hw_sat" => (; long_name="island half-width at the larger positive root of the Callen cubic, NaN if none", units="psi_N", dims=("surface",)), + "PerSurface/n_roots" => (; long_name="number of positive roots of the Callen cubic", dims=("surface",)), + "PerSurface/b_crit" => (; long_name="critical vacuum resonant field", units="T", dims=("surface",)) +] diff --git a/src/Tearing/ModifiedRutherford/Callen.jl b/src/Tearing/Callen/Problem.jl similarity index 94% rename from src/Tearing/ModifiedRutherford/Callen.jl rename to src/Tearing/Callen/Problem.jl index e9d87e554..20af84729 100644 --- a/src/Tearing/ModifiedRutherford/Callen.jl +++ b/src/Tearing/Callen/Problem.jl @@ -1,4 +1,4 @@ -# Callen.jl +# Problem.jl # # `solve(CallenProblem(pe))`: Callen's reduced modified Rutherford equation for RMP-driven islands (UW-CPTC 16-4, 2016), # in the Fortran GPEC 1.6.0 form, on every resonant row of a single-n perturbed equilibrium driven by coils. @@ -46,7 +46,7 @@ does not exist. ## Fields - `problem` - The producing problem. - - `psi`, `q`, `m`, `n` - Surface identity. + - `rational_psi`, `rational_q`, `m`, `n` - Surface identity. - `rho_tor`, `drho_dpsi` - Toroidal-flux radius ρ [m] and dρ/dψ_N [m]. - `rho_i`, `w_pol` - Ion gyroradius and polarization width [m]. - `delta_rmp`, `delta_c` - Δ_RMP and Δ_c [1/m]. @@ -59,8 +59,8 @@ does not exist. """ struct CallenResult{P} problem::P - psi::Vector{Float64} - q::Vector{Float64} + rational_psi::Vector{Float64} + rational_q::Vector{Float64} m::Vector{Int} n::Vector{Int} rho_tor::Vector{Float64} @@ -150,7 +150,7 @@ function CommonSolve.solve(prob::CallenProblem) nrow = length(resp.rational_psi) out = Dict( - k => fill(NaN, nrow) for k in (:psi, :q, :rho_tor, :drho_dpsi, :rho_i, :w_pol, :delta_rmp, :delta_c, :b_res, :b_vac, + k => fill(NaN, nrow) for k in (:rational_psi, :rational_q, :rho_tor, :drho_dpsi, :rho_i, :w_pol, :delta_rmp, :delta_c, :b_res, :b_vac, :hw_isl, :hw_v, :hw_v_crit, :hw_min, :hw_sat, :b_crit) ) m_out, n_out, n_roots = zeros(Int, nrow), zeros(Int, nrow), zeros(Int, nrow) @@ -172,13 +172,13 @@ function CommonSolve.solve(prob::CallenProblem) hw_v = sqrt(abs(4 * b_vac * area / (2π * shear * q * chi1))) # Roots are full widths in metres; the outputs are half-widths in ψ_N. x_min, x_sat = callen_cubic_roots(delta_c, delta_rmp * (2 * hw_v * drho)^2, -w_pol^2) - for (k, v) in pairs((; psi, q, rho_tor=rho, drho_dpsi=drho, rho_i, w_pol, delta_rmp, delta_c, b_res, b_vac, + for (k, v) in pairs((; rational_psi=psi, rational_q=q, rho_tor=rho, drho_dpsi=drho, rho_i, w_pol, delta_rmp, delta_c, b_res, b_vac, hw_isl=pe.island_half_width[row], hw_v, hw_v_crit=hw_v_crit / drho, hw_min=x_min / (2 * drho), hw_sat=x_sat / (2 * drho), b_crit=b_vac * (hw_v_crit / drho / hw_v)^2)) out[k][row] = v end m_out[row], n_out[row], n_roots[row] = m, n, count(!isnan, (x_min, x_sat)) end - return CallenResult(prob, out[:psi], out[:q], m_out, n_out, out[:rho_tor], out[:drho_dpsi], out[:rho_i], out[:w_pol], out[:delta_rmp], + return CallenResult(prob, out[:rational_psi], out[:rational_q], m_out, n_out, out[:rho_tor], out[:drho_dpsi], out[:rho_i], out[:w_pol], out[:delta_rmp], out[:delta_c], out[:b_res], out[:b_vac], out[:hw_isl], out[:hw_v], out[:hw_v_crit], out[:hw_min], out[:hw_sat], n_roots, out[:b_crit]) end diff --git a/src/Tearing/ModifiedRutherford/HDF5Output.jl b/src/Tearing/ModifiedRutherford/HDF5Output.jl index a12511248..6943bd11b 100644 --- a/src/Tearing/ModifiedRutherford/HDF5Output.jl +++ b/src/Tearing/ModifiedRutherford/HDF5Output.jl @@ -113,42 +113,3 @@ const MRE_H5_ANNOTATIONS = [ "Terms/polarization" => (; long_name="ion polarization term Delta_pol(w), including pol_sign", dims=_MRE_TERM_DIMS), "Terms/w_d" => (; long_name="transport threshold width w_d(w)", units="psi_N", dims=_MRE_TERM_DIMS) ] - -""" - save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::CallenResult) - -Write `result` into `Tearing/Callen/` of `parent`, one dataset per field over the resonant rows, replacing any existing -one and keeping the rest of `Tearing/`, and record its deck section. -""" -function save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::CallenResult) - tearing = haskey(parent, "Tearing") ? parent["Tearing"] : create_group(parent, "Tearing") - g = replace_group!(tearing, "Callen") - for (key, _) in CALLEN_H5_ANNOTATIONS - g[key] = getfield(result, Symbol(key)) - end - Utilities.HDF5Annotations.annotate!(g, CALLEN_H5_ANNOTATIONS) - Utilities.record_inputs!(parent, result) - return g -end - -const CALLEN_H5_ANNOTATIONS = [ - "psi" => (; long_name="normalized poloidal flux of the rational surface", units="psi_N", dims=("surface",)), - "q" => (; long_name="safety factor at the surface", dims=("surface",)), - "m" => (; long_name="resonant poloidal mode number", dims=("surface",)), - "n" => (; long_name="resonant toroidal mode number", dims=("surface",)), - "rho_tor" => (; long_name="toroidal-flux radius (Phi_tor/(pi B0))^(1/2)", units="m", dims=("surface",)), - "drho_dpsi" => (; long_name="d rho_tor / d psi_N", units="m", dims=("surface",)), - "rho_i" => (; long_name="ion gyroradius in the axis field", units="m", dims=("surface",)), - "w_pol" => (; long_name="polarization width 0.5 q rho_i/(rho/R0)^(1/2)", units="m", dims=("surface",)), - "delta_rmp" => (; long_name="Callen Delta'_RMP from the total resonant field", units="1/m", dims=("surface",)), - "delta_c" => (; long_name="Callen intrinsic Delta' in 1/m of rho: computed Delta'_mn, or -2m/rho with delta_prime_source = cylindrical", units="1/m", dims=("surface",)), - "b_res" => (; long_name="total resonant field |flux|/area", units="T", dims=("surface",)), - "b_vac" => (; long_name="vacuum (coil) resonant field |flux|/area", units="T", dims=("surface",)), - "hw_isl" => (; long_name="total-field island half-width", units="psi_N", dims=("surface",)), - "hw_v" => (; long_name="vacuum island half-width", units="psi_N", dims=("surface",)), - "hw_v_crit" => (; long_name="critical vacuum island half-width", units="psi_N", dims=("surface",)), - "hw_min" => (; long_name="island half-width at the smaller positive root of the Callen cubic, NaN if none", units="psi_N", dims=("surface",)), - "hw_sat" => (; long_name="island half-width at the larger positive root of the Callen cubic, NaN if none", units="psi_N", dims=("surface",)), - "n_roots" => (; long_name="number of positive roots of the Callen cubic", dims=("surface",)), - "b_crit" => (; long_name="critical vacuum resonant field", units="T", dims=("surface",)) -] diff --git a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl index 25686acf6..61a2be080 100644 --- a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl +++ b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl @@ -9,7 +9,6 @@ import FastInterpolations using FastInterpolations: cubic_interp, Series, PeriodicBC, DerivOp import ...Utilities -import ...ForcingTerms import ...Utilities: save_to_hdf5 using ...Utilities.HDF5Annotations: replace_group! using ...Utilities.PhysicalConstants: M_E, M_P, E_CHG, MU_0 @@ -20,13 +19,12 @@ using Roots: find_zero, Brent include("Geometry.jl") include("Terms.jl") include("Problem.jl") -include("Callen.jl") include("HDF5Output.jl") export MRESurfaceGeometry, mre_geometry, current_density_profile, sauter2002_trapped_fraction, bootstrap_coefficient export classical_term, saturation_coefficient, saturation_term, bootstrap_term, curvature_term, lutjens_curvature_term, layer_transport_width, polarization_term, reduced_polarization_term, transport_width -export mre_roots, ModifiedRutherfordProblem, ModifiedRutherfordResult, CallenProblem, CallenResult, callen_cubic_roots +export mre_roots, ModifiedRutherfordProblem, ModifiedRutherfordResult end # module ModifiedRutherford diff --git a/src/Tearing/Tearing.jl b/src/Tearing/Tearing.jl index cbb24f1bb..e9e76776c 100644 --- a/src/Tearing/Tearing.jl +++ b/src/Tearing/Tearing.jl @@ -8,6 +8,8 @@ # resonant field of error-field penetration # ModifiedRutherford -- terms of the steady modified Rutherford equation and # F(w) per surface, `solve(ModifiedRutherfordProblem(ffs; ...))` +# Callen -- Callen's reduced equation for coil-driven islands, +# `solve(CallenProblem(pe; ...))` # TearingProblem / TearingConfig / TearingResult -- the public solve, # `solve(TearingProblem(ffs; ...), model; config)`, with one # method per inner-layer model (SLAYERModel, GGJModel) @@ -42,11 +44,14 @@ include("LayerOverlap.jl") include("Dispersion/Dispersion.jl") include("CriticalResonantField/CriticalResonantField.jl") include("ModifiedRutherford/ModifiedRutherford.jl") +include("Callen/Callen.jl") import .Dispersion as Dispersion import .CriticalResonantField as CriticalResonantField import .ModifiedRutherford as ModifiedRutherford -using .ModifiedRutherford: ModifiedRutherfordProblem, ModifiedRutherfordResult, CallenProblem, CallenResult +import .Callen as Callen +using .ModifiedRutherford: ModifiedRutherfordProblem, ModifiedRutherfordResult +using .Callen: CallenProblem, CallenResult using .Dispersion: SurfaceCoupling, surface_coupling, MultiSurfaceCoupling, multi_surface_coupling, ScanResult, brute_force_scan, diff --git a/test/runtests_modified_rutherford.jl b/test/runtests_modified_rutherford.jl index f0c301ef6..dbc85c7c8 100644 --- a/test/runtests_modified_rutherford.jl +++ b/test/runtests_modified_rutherford.jl @@ -4,6 +4,7 @@ using TOML # alone, and the surface geometry against Fortran RDCON (MRE_flag output) on the DIII-D-like example. @testset "ModifiedRutherford terms" begin MR = GeneralizedPerturbedEquilibrium.ModifiedRutherford + CL = GeneralizedPerturbedEquilibrium.Tearing.Callen @testset "classical term: Mercier power and its zero-pressure limit" begin @test MR.classical_term(-3.0, -0.25, 0.01) ≈ -3.0 @@ -109,16 +110,16 @@ using TOML @testset "Callen cubic: both positive roots solve it and merge at the critical vacuum width" begin a, c = -40.0, -(1.5e-2)^2 - x_min, x_sat = MR.callen_cubic_roots(a, 2.0, c) + x_min, x_sat = CL.callen_cubic_roots(a, 2.0, c) @test 0 < x_min < x_sat @test abs(a * x_min^3 + 2.0 * x_min + c) < 1e-12 && abs(a * x_sat^3 + 2.0 * x_sat + c) < 1e-12 # The double root sits where 4p³ + 27q² = 0, i.e. b = 3|a| (c/2a)^(2/3). b_crit = 3 * abs(a) * (c / (2a))^(2 / 3) - @test all(isnan, MR.callen_cubic_roots(a, 0.99 * b_crit, c)) - lo, hi = MR.callen_cubic_roots(a, 1.0001 * b_crit, c) + @test all(isnan, CL.callen_cubic_roots(a, 0.99 * b_crit, c)) + lo, hi = CL.callen_cubic_roots(a, 1.0001 * b_crit, c) @test hi / lo < 1.05 # Callen 2016 Fig. 8 (DIII-D 158115, ρ = 71.88 cm): full widths w_min ≈ 1.28 cm, w_sat ≈ 2.24 cm. - w_min, w_sat = MR.callen_cubic_roots(-16 / 71.88, 26.4 / 71.88 * 2.45^2, -1.5^2) + w_min, w_sat = CL.callen_cubic_roots(-16 / 71.88, 26.4 / 71.88 * 2.45^2, -1.5^2) @test w_min ≈ 1.28 rtol = 0.1 @test w_sat ≈ 2.24 rtol = 0.1 end diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index 0466a2f4c..63a4308e3 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -677,7 +677,7 @@ include("display_check.jl") @test all(<(0), mre.delta_prime) @test all(isfinite, mre.C_sat) h5open(joinpath(dir, "gpec.h5")) do f - @test read(f["Tearing/Callen/delta_c"]) ≈ api.delta_c rtol = 1e-10 + @test read(f["Tearing/Callen/PerSurface/delta_c"]) ≈ api.delta_c rtol = 1e-10 recorded = TOML.parse(read(f["Input/gpec_toml_raw"]))["Tearing"] @test haskey(recorded, "Callen") && recorded["ModifiedRutherford"]["delta_prime_source"] == "cylindrical" @test recorded["ModifiedRutherford"]["saturation"] && read(f["Tearing/ModifiedRutherford/PerSurface/C_sat"]) ≈ mre.C_sat From 93dd3db8d7abdabc0416c0a587e3985593b0323e Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sun, 8 Nov 2026 10:56:09 -0500 Subject: [PATCH 20/26] InnerLayer - API - Add layer_plasma, one view of the kinetic plasma for every layer consumer layer_plasma returns the profiles, Z_eff, the main-ion mass and charge, the mass per electron, the resistivity closure and the chi splines once; the tearing solve, the layer-overlap scan, Callen, layer_eta and layer_rho use it in place of the SLAYER private helpers. Values passed to the layers are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- src/InnerLayer/InnerLayer.jl | 2 +- src/InnerLayer/LayerPlasma.jl | 42 ++++++++++++++++++----- src/Tearing/Callen/Callen.jl | 2 +- src/Tearing/Callen/Problem.jl | 6 ++-- src/Tearing/LayerOverlap.jl | 12 +++---- src/Tearing/Tearing.jl | 3 +- src/Tearing/TearingProblem.jl | 27 +++++++-------- test/runtests_slayer_inputs.jl | 3 +- test/runtests_slayer_profile_transport.jl | 2 +- test/runtests_slayer_runner.jl | 11 +++--- 10 files changed, 65 insertions(+), 45 deletions(-) diff --git a/src/InnerLayer/InnerLayer.jl b/src/InnerLayer/InnerLayer.jl index a7205b6cd..91332d2cc 100644 --- a/src/InnerLayer/InnerLayer.jl +++ b/src/InnerLayer/InnerLayer.jl @@ -34,7 +34,7 @@ model_name(::GGJModel{S}) where {S} = "ggj_$S" model_name(::SLAYERModel{S}) where {S} = "slayer_$S" export InnerLayerModel, InnerLayerParameters, InnerLayerResponse, solve_inner, solve_inner_profile, layer_response -export layer_eta, layer_rho, layer_omega_E +export layer_plasma, layer_eta, layer_rho, layer_omega_E export GGJ, GGJModel, GGJParameters export mercier_di, mercier_dr, inner_Q, rescale_delta export solve_ray, pick_smax, delta_convergence, solution_profile, asymptotic_profile diff --git a/src/InnerLayer/LayerPlasma.jl b/src/InnerLayer/LayerPlasma.jl index f8dd77105..aab90c9e5 100644 --- a/src/InnerLayer/LayerPlasma.jl +++ b/src/InnerLayer/LayerPlasma.jl @@ -15,6 +15,34 @@ function _require_layer_inputs(who::AbstractString, equil; given...) "(kinetic_file in [Equilibrium], or attach_kinetic_profiles!)")) end +""" + layer_plasma(equil; control=nothing, log=true) -> NamedTuple + +The plasma every inner layer sees, from the equilibrium's kinetic data `equil.kinetic`. + +## Fields + + - `data` - The kinetic data. + - `profiles` - `KineticProfiles` splines of n_e, T_e, T_i and Ω_E on the data's ψ grid. + - `zeff` - Z_eff(ψ) from the data's profile or the n_i/n_e closure, else the scalar `zeff_default`. + - `mu_i`, `z_i` - Main-ion mass in proton masses (fraction-weighted over the species) and charge. + - `mu_rho` - Mean ion mass per electron in proton masses at `zeff`, so ρ = μ m_p n_e (a number or a ψ-callable). + - `resistivity_model`, `lnLambda_form` - The data's resistivity closure and Coulomb-log form. + - `chi_perp_e`, `chi_perp_i`, `chi_tor` - χ(ψ) splines the `SLAYERModel` `control` reads (`nothing` when absent or + unread, so the caller uses `chi_*_default`; all `nothing` without `control`). + - `has_omega_E` - Whether the data carry Ω_E. + +`log = false` skips the note on where Z_eff came from. +""" +function layer_plasma(equil; control=nothing, log::Bool=true) + data = _layer_kinetic_data(equil, "plasma") + loaded = SLAYER._slayer_profiles(data, control) + zeff = log ? SLAYER._layer_zeff(loaded, data) : something(loaded.zeff, data.zeff_default) + return (; data, loaded.profiles, zeff, mu_i=SLAYER._layer_ion_mass(data), z_i=Float64(first(data.ion_species).z), + mu_rho=SLAYER._layer_mass_per_electron(data, zeff), resistivity_model=SLAYER._build_resistivity_model(data.resistivity_model), + data.lnLambda_form, loaded.chi_perp_e, loaded.chi_perp_i, loaded.chi_tor, loaded.zeff_source, loaded.has_omega_E) +end + """ layer_eta(equil, surfaces) -> Vector{Float64} @@ -22,10 +50,9 @@ Resistivity η in Ω·m at each rational surface of `surfaces` (core to edge), f its resistivity closure, the one the SLAYER layer uses. """ function layer_eta(equil, surfaces) - data = _layer_kinetic_data(equil, "resistivity η") - loaded = SLAYER._slayer_profiles(data, nothing) - return SLAYER.layer_resistivity(equil, surfaces, loaded.profiles; zeff=SLAYER._layer_zeff(loaded, data), - resistivity_model=SLAYER._build_resistivity_model(data.resistivity_model), lnLambda_form=data.lnLambda_form) + _layer_kinetic_data(equil, "resistivity η") + plasma = layer_plasma(equil) + return SLAYER.layer_resistivity(equil, surfaces, plasma.profiles; plasma.zeff, plasma.resistivity_model, plasma.lnLambda_form) end """ @@ -36,11 +63,10 @@ Ion mass density ρ = μ·m_p·n_e in kg/m³ at each rational surface of `surfac layer uses. """ function layer_rho(equil, surfaces) - data = _layer_kinetic_data(equil, "mass density ρ") - loaded = SLAYER._slayer_profiles(data, nothing) - mu = SLAYER._layer_mass_per_electron(data, SLAYER._layer_zeff(loaded, data)) + _layer_kinetic_data(equil, "mass density ρ") + plasma = layer_plasma(equil) _at(x, ψ) = x isa Real ? x : x(ψ) - return Float64[_at(mu, s.psifac) * Utilities.M_P * loaded.profiles.n_e(s.psifac) for s in surfaces] + return Float64[_at(plasma.mu_rho, s.psifac) * Utilities.M_P * plasma.profiles.n_e(s.psifac) for s in surfaces] end """ diff --git a/src/Tearing/Callen/Callen.jl b/src/Tearing/Callen/Callen.jl index 5eafbc541..481803dfb 100644 --- a/src/Tearing/Callen/Callen.jl +++ b/src/Tearing/Callen/Callen.jl @@ -12,7 +12,7 @@ import ...ForcingTerms import ...Utilities: save_to_hdf5 using ...Utilities.HDF5Annotations: replace_group! using ...Utilities.PhysicalConstants: M_P, E_CHG -using ...InnerLayer.SLAYER: _slayer_profiles +using ...InnerLayer: layer_plasma include("Problem.jl") include("HDF5Output.jl") diff --git a/src/Tearing/Callen/Problem.jl b/src/Tearing/Callen/Problem.jl index 20af84729..a98cbd7b7 100644 --- a/src/Tearing/Callen/Problem.jl +++ b/src/Tearing/Callen/Problem.jl @@ -139,10 +139,8 @@ function CommonSolve.solve(prob::CallenProblem) prob.delta_prime_source === :cylindrical || (ric !== nothing && !isempty(ric.delta_prime.matrix)) || throw(ArgumentError("CallenProblem with delta_prime_source = :computed needs delta_prime, a Riccati or Galerkin solve carrying Δ′")) - T_i_of = _slayer_profiles(data, nothing).profiles.T_i - species = data.ion_species - m_i = sum(s.fraction * s.m for s in species) / sum(s.fraction for s in species) * M_P - z_i = Float64(first(species).z) + plasma = layer_plasma(equil; log=false) + T_i_of, m_i, z_i = plasma.profiles.T_i, plasma.mu_i * M_P, plasma.z_i R0, B0 = equil.ro, equil.params.bt0 chi1 = 2π * equil.psio qspl = equil.profiles.q_spline diff --git a/src/Tearing/LayerOverlap.jl b/src/Tearing/LayerOverlap.jl index 3375731a4..67327e4da 100644 --- a/src/Tearing/LayerOverlap.jl +++ b/src/Tearing/LayerOverlap.jl @@ -403,14 +403,12 @@ Eq. (100) is anchored to. Caveats are logged as warnings and notes as info. function layer_overlap_scan(equil, model::SLAYERModel, ns) data = equil.kinetic data === nothing && error("psilim_from_layer_overlap needs kinetic profiles: set kinetic_file in [Equilibrium] or attach_kinetic_profiles!") - loaded = _slayer_profiles(data, model) - chi = _slayer_chi_inputs(loaded, model, data; warn=false) - zeff = loaded.zeff === nothing ? data.zeff_default : loaded.zeff + plasma = layer_plasma(equil; control=model, log=false) + chi = _slayer_chi_inputs(plasma, model, data; warn=false) scans = [ - resistive_layer_overlap(equil, loaded.profiles; n_tor=n, rs_method=:flux, chi..., tau_E=data.tau_E, - P_perp_model=model.P_perp_model, mu_i=_layer_ion_mass(data), mu_rho=_layer_mass_per_electron(data, zeff), zeff=zeff, - theta=model.theta_sample, - resistivity_model=_build_resistivity_model(data.resistivity_model), lnLambda_form=data.lnLambda_form) + resistive_layer_overlap(equil, plasma.profiles; n_tor=n, rs_method=:flux, chi..., tau_E=data.tau_E, + P_perp_model=model.P_perp_model, plasma.mu_i, plasma.mu_rho, plasma.zeff, theta=model.theta_sample, + plasma.resistivity_model, plasma.lnLambda_form) for n in ns ] for (n, scan) in zip(ns, scans) diff --git a/src/Tearing/Tearing.jl b/src/Tearing/Tearing.jl index e9e76776c..b9b10f383 100644 --- a/src/Tearing/Tearing.jl +++ b/src/Tearing/Tearing.jl @@ -37,8 +37,7 @@ import ..InnerLayer as InnerLayer using .InnerLayer: InnerLayerParameters, InnerLayerResponse, solve_inner, layer_response, SLAYERModel, SLAYERParameters, build_slayer_inputs, GGJModel, GGJParameters, - LayerWidths, slayer_layer_thickness -using .InnerLayer.SLAYER: _slayer_profiles, _layer_zeff, _layer_ion_mass, _layer_mass_per_electron, _build_resistivity_model + LayerWidths, slayer_layer_thickness, layer_plasma include("LayerOverlap.jl") include("Dispersion/Dispersion.jl") diff --git a/src/Tearing/TearingProblem.jl b/src/Tearing/TearingProblem.jl index 1d1b5a75d..dea53e673 100644 --- a/src/Tearing/TearingProblem.jl +++ b/src/Tearing/TearingProblem.jl @@ -39,16 +39,15 @@ end TearingProblem(ffs; eta=nothing, rho=nothing, kwargs...) = TearingProblem{typeof(ffs),typeof(eta),typeof(rho)}(; ffs=ffs, eta=eta, rho=rho, kwargs...) -function _tearing_params(model::SLAYERModel, equil, surfaces, loaded) - data = equil.kinetic +function _tearing_params(model::SLAYERModel, equil, surfaces, plasma) + data = plasma.data # χ⊥,e/χ⊥,i/χ_φ from the kinetic file when present, else the kinetic data's defaults. - chi = _slayer_chi_inputs(loaded, model, data) - zeff = _layer_zeff(loaded, data) + chi = _slayer_chi_inputs(plasma, model, data) # B_T = F(ψ)/(2π·R₀) per surface comes from the equilibrium's F-spline (bt=nothing). - return build_slayer_inputs(equil, surfaces, loaded.profiles; - mu_i=_layer_ion_mass(data), - mu_rho=_layer_mass_per_electron(data, zeff), - zeff=zeff, + return build_slayer_inputs(equil, surfaces, plasma.profiles; + plasma.mu_i, + plasma.mu_rho, + plasma.zeff, chi_perp_e=chi.chi_perp_e, chi_perp_i=chi.chi_perp_i, chi_perp_Dc=model.chi_perp_Dc, @@ -59,8 +58,8 @@ function _tearing_params(model::SLAYERModel, equil, surfaces, loaded) dgeo_val=model.D_geo, dc_type=model.D_c_type, theta=model.theta_sample, - resistivity_model=_build_resistivity_model(data.resistivity_model), - lnLambda_form=data.lnLambda_form) + plasma.resistivity_model, + plasma.lnLambda_form) end """ @@ -93,10 +92,10 @@ function CommonSolve.solve(prob::TearingProblem, model::SLAYERModel; config::Tea equil.kinetic === nothing && error("TearingProblem: the equilibrium carries no kinetic profiles; set kinetic_file in [Equilibrium] or attach_kinetic_profiles!") - loaded = _slayer_profiles(equil.kinetic, model) + plasma = layer_plasma(equil; control=model) _warn_past_layer_overlap(ffs, model) - params = _tearing_params(model, equil, surfaces, loaded) + params = _tearing_params(model, equil, surfaces, plasma) # Prefer the full inter-surface Δ' matrix; fall back to a diagonal of each surface's scalar delta_prime. dpm = ffs.delta_prime === nothing ? Matrix{ComplexF64}(undef, 0, 0) : ffs.delta_prime.matrix @@ -125,8 +124,8 @@ function CommonSolve.solve(prob::TearingProblem, model::SLAYERModel; config::Tea rational_q = Float64[surfaces[p.ising].q for p in params] # E×B rotation per unit n at each surface, for the Doppler shifts and the lab-frame roots; # empty when the kinetic file carries none. - omega_E = loaded.has_omega_E ? Float64[loaded.profiles.omega_E(ψ) for ψ in rational_psi] : Float64[] - loaded.has_omega_E || @warn "SLAYER: the kinetic file has no omega_E; surfaces without an omega_E_kHz override assume Ω_E = 0, so their lab-frame ω equals their E×B-frame ω." + omega_E = plasma.has_omega_E ? Float64[plasma.profiles.omega_E(ψ) for ψ in rational_psi] : Float64[] + plasma.has_omega_E || @warn "SLAYER: the kinetic file has no omega_E; surfaces without an omega_E_kHz override assume Ω_E = 0, so their lab-frame ω equals their E×B-frame ω." result = run_slayer_from_inputs(model, params, dp, prob, config; rational_psi=rational_psi, rational_q=rational_q, omega_E=omega_E) return result diff --git a/test/runtests_slayer_inputs.jl b/test/runtests_slayer_inputs.jl index 89b27dfbd..ab38e7718 100644 --- a/test/runtests_slayer_inputs.jl +++ b/test/runtests_slayer_inputs.jl @@ -179,13 +179,12 @@ # The model key, through the same assembly TearingProblem uses. Tearing = GeneralizedPerturbedEquilibrium.Tearing - loaded = (; profiles, chi_perp_e=nothing, chi_perp_i=nothing, chi_tor=nothing, zeff=nothing, zeff_source=:scalar) model(; c...) = SLAYERModel(; P_perp_model=:chi_perp_e, D_R=0.01, D_c_type=:fitzpatrick, c...) attached = equil.kinetic equil.kinetic = Equilibrium.KineticProfileData(; psi=psi_pts, n_e=fill(5.0e19, length(psi_pts)), T_e=1000.0 .* (1.0 .- 0.7 .* psi_pts), T_i=1000.0 .* (1.0 .- 0.6 .* psi_pts), chi_e_default=2.0, chi_phi_default=1.5) - params(m) = Tearing._tearing_params(m, equil, sings, loaded)[1] + params(m) = Tearing._tearing_params(m, equil, sings, GeneralizedPerturbedEquilibrium.InnerLayer.layer_plasma(equil; control=m, log=false))[1] without, with = params(model()), params(model(; chi_perp_Dc=6.0)) equil.kinetic = attached @test with.dc_tmp != without.dc_tmp diff --git a/test/runtests_slayer_profile_transport.jl b/test/runtests_slayer_profile_transport.jl index 0e9f0b25a..8ec9c40c1 100644 --- a/test/runtests_slayer_profile_transport.jl +++ b/test/runtests_slayer_profile_transport.jl @@ -1,7 +1,7 @@ using Test using GeneralizedPerturbedEquilibrium.Equilibrium: KineticProfileData using GeneralizedPerturbedEquilibrium: SLAYERModel -using GeneralizedPerturbedEquilibrium.Tearing: _slayer_profiles +using GeneralizedPerturbedEquilibrium.InnerLayer.SLAYER: _slayer_profiles @testset "SLAYER loads only transport profiles used by its model" begin psi = collect(range(0.0, 1.0; length=5)) diff --git a/test/runtests_slayer_runner.jl b/test/runtests_slayer_runner.jl index 434047642..a6b4a1f02 100644 --- a/test/runtests_slayer_runner.jl +++ b/test/runtests_slayer_runner.jl @@ -117,7 +117,7 @@ @testset "SLAYER view of the kinetic profiles" begin kin_path = joinpath(dirname(@__DIR__), "examples", "DIIID-like_ideal_example", "TkMkr_D3Dlike_Hmode_kinetic.h5") data = GeneralizedPerturbedEquilibrium.Equilibrium.read_kinetic_file(kin_path) - view = Tearing._slayer_profiles(data, SLAYERModel()) + view = GeneralizedPerturbedEquilibrium.InnerLayer.SLAYER._slayer_profiles(data, SLAYERModel()) j = length(data.psi) ÷ 2 @test view.profiles.n_e(data.psi[j]) ≈ data.n_e[j] @test view.profiles.omega_E(data.psi[j]) ≈ data.omega_E[j] @@ -126,16 +126,17 @@ @test view.has_omega_E # Without an E×B rotation profile the view records its absence and carries zero rotation. no_rotation = GeneralizedPerturbedEquilibrium.Equilibrium.KineticProfileData(; psi=data.psi, n_e=data.n_e, T_e=data.T_e, T_i=data.T_i) - bare_view = Tearing._slayer_profiles(no_rotation, SLAYERModel()) + bare_view = GeneralizedPerturbedEquilibrium.InnerLayer.SLAYER._slayer_profiles(no_rotation, SLAYERModel()) @test !bare_view.has_omega_E @test bare_view.profiles.omega_E(data.psi[j]) == 0.0 - @test_throws "missing required dataset 'T_i'" Tearing._slayer_profiles( + @test_throws "missing required dataset 'T_i'" GeneralizedPerturbedEquilibrium.InnerLayer.SLAYER._slayer_profiles( GeneralizedPerturbedEquilibrium.Equilibrium.KineticProfileData(; psi=data.psi, n_e=data.n_e, T_e=data.T_e), SLAYERModel()) # A non-positive chi_e stops the run only where it is used: chi_perp_Dc replaces it in the critical Δ. bad_chi_e = GeneralizedPerturbedEquilibrium.Equilibrium.KineticProfileData(; psi=data.psi, n_e=data.n_e, T_e=data.T_e, T_i=data.T_i, chi_e=fill(-1.0, length(data.psi))) - @test_throws "chi_e" Tearing._slayer_profiles(bad_chi_e, SLAYERModel(; P_perp_model=:tau_E, D_c_type=:toroidal)) - @test Tearing._slayer_profiles(bad_chi_e, SLAYERModel(; P_perp_model=:tau_E, D_c_type=:toroidal, chi_perp_Dc=1.4)).chi_perp_e === nothing + @test_throws "chi_e" GeneralizedPerturbedEquilibrium.InnerLayer.SLAYER._slayer_profiles(bad_chi_e, SLAYERModel(; P_perp_model=:tau_E, D_c_type=:toroidal)) + @test GeneralizedPerturbedEquilibrium.InnerLayer.SLAYER._slayer_profiles(bad_chi_e, SLAYERModel(; P_perp_model=:tau_E, D_c_type=:toroidal, chi_perp_Dc=1.4)).chi_perp_e === + nothing end # Δ′ is unified across formalisms, so a Galerkin run feeds SLAYER exactly as a Riccati one From e264905709f0059725a245a42a199576c916db64 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sun, 8 Nov 2026 10:56:10 -0500 Subject: [PATCH 21/26] Tearing.ModifiedRutherford - REFACTOR! - Split the surface loop, build the result by keyword and drive the writer from its annotations The surface loop becomes _surface_inputs, _mre_terms, _gate and _surface_roots, and ModifiedRutherfordResult is built by keyword. The surface identity is rational_psi and rational_q, as in Tearing/PerSurface/; the writer emits every annotated field. The plasma comes from layer_plasma, so the ion mass is the one the layers use. Both Tearing subgroups are listed in hdf5-conventions.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- docs/development/hdf5-conventions.md | 4 +- docs/src/inner_layer.md | 2 +- src/Tearing/ModifiedRutherford/HDF5Output.jl | 27 +- .../ModifiedRutherford/ModifiedRutherford.jl | 4 +- src/Tearing/ModifiedRutherford/Problem.jl | 281 +++++++++--------- test/runtests_solve_api.jl | 2 +- 6 files changed, 149 insertions(+), 171 deletions(-) diff --git a/docs/development/hdf5-conventions.md b/docs/development/hdf5-conventions.md index 403fb7ffc..0273f888b 100644 --- a/docs/development/hdf5-conventions.md +++ b/docs/development/hdf5-conventions.md @@ -46,7 +46,7 @@ Top level (11 groups): | `PerturbedEquilibrium/` | `ForcingModes/`, `Response/`, `ResponseMatrices/`, `SingularCoupling/`, `Energies/`, `TorqueResponse/` (kinetic forward solves: ψ-resolved `T_xe`, `T_source`), control-surface spectra | | `KineticForces/` | `/` (torque/energy profiles, `dTdpsi_ell` resolved by bounce harmonic on the `ell` scale, `KineticMatrices/`); multi-ion runs add `PerSpecies///` with the same per-method layout, summing to the top-level total | | `ErrorFields/` | `CoilSensitivities/` (per-coil-set control-surface spectra and their rigid shift/tilt derivatives, `DominantMode/` full-window projection); `MonteCarlo/` (intrinsic and corrected `\|δ\|` histograms over the sampled tolerances, per batch and averaged); `Risk/` (threshold density, `P(lock\|δ)`, locking probabilities, `ToleranceScan/`); `NTV/` (correction-coil overlap and NTV torque couplings per kAt) | -| `Tearing/` | `PerSurface/` (+ `DpMatrix/`), `Roots/`, `LayerWidths/`, `Diagnostics/{ValidRoots,Poles,FilteredRoots}` (+ `*Winding` with `root_classification = winding`), `Scan/Surface_/`, `CriticalResonantField/` (+ `Scan/Surface_/`) | +| `Tearing/` | `PerSurface/` (+ `DpMatrix/`), `Roots/`, `LayerWidths/`, `Diagnostics/{ValidRoots,Poles,FilteredRoots}` (+ `*Winding` with `root_classification = winding`), `Scan/Surface_/`, `CriticalResonantField/` (+ `Scan/Surface_/`), `ModifiedRutherford/` (`w`, `w_m_star`, `PerSurface/`, `Terms/` surface × width), `Callen/PerSurface/` | | `SurfaceGeometries/` | `{Plasma,Wall}/{x,y,z}` point clouds | Reserved (documented, not yet written): `ForceFreeStates/Solutions/RiccatiIntegration/` — the third integrator backend slot alongside `ForwardIntegration` and `GalerkinIntegration`. @@ -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/Utilities/HDF5Schema.jl` for the main writer; alongside `write_galerkin!`, the PerturbedEquilibrium writer, `KineticForces/Output.jl`, and `Tearing/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/HDF5Output.jl`, `Tearing/ModifiedRutherford/HDF5Output.jl` and `Tearing/Callen/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/src/inner_layer.md b/docs/src/inner_layer.md index 489af0fc3..378be18ea 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -105,7 +105,7 @@ The DIII-D-like kinetic file of the examples carries analytic χ profiles of the | `Tearing/CriticalResonantField/br_crit`, `br_crit_tesla`, `Q_peak`, `Q0` | the penetration threshold as ``b_r/B_\phi`` and in tesla, the Q of the balance maximum and the natural rotation | | `Tearing/ModifiedRutherford/w`, `Terms/F`, `Terms/`, `Terms/w_d`, `PerSurface/` | the modified Rutherford width grid, ``F(w)`` and each switched term per surface, the transport width, and the surface inputs | | `Tearing/ModifiedRutherford/w_m_star`, `PerSurface/w_m`, `PerSurface/w_sat`, `PerSurface/` | the seed threshold and saturated widths per surface, their minimum over the surfaces, and the validity and gate flags (0/1) | -| `Tearing/Callen/` | Callen's critical vacuum island width, critical resonant field and cubic roots per resonant row | +| `Tearing/Callen/PerSurface/` | Callen's critical vacuum island width, critical resonant field and cubic roots per resonant row | ## Modified Rutherford diagnostics diff --git a/src/Tearing/ModifiedRutherford/HDF5Output.jl b/src/Tearing/ModifiedRutherford/HDF5Output.jl index 6943bd11b..0218c15e9 100644 --- a/src/Tearing/ModifiedRutherford/HDF5Output.jl +++ b/src/Tearing/ModifiedRutherford/HDF5Output.jl @@ -18,39 +18,24 @@ Write `result` into `Tearing/ModifiedRutherford/` of `parent`, replacing any exi function save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::ModifiedRutherfordResult) tearing = haskey(parent, "Tearing") ? parent["Tearing"] : create_group(parent, "Tearing") g = replace_group!(tearing, "ModifiedRutherford") - g["w"] = result.w - g["w_m_star"] = result.w_m_star - for key in _MRE_PER_SURFACE - g["PerSurface/$key"] = getfield(result, key) - end - # Flags as 0/1 integers, readable without an HDF5 boolean type. - for key in _MRE_FLAGS - g["PerSurface/$key"] = Int8.(getfield(result, key)) - end - for key in _MRE_TERMS - g["Terms/$key"] = getfield(result, key) + # One dataset per annotated field; flags as 0/1 integers, readable without an HDF5 boolean type. + for (key, _) in MRE_H5_ANNOTATIONS + value = getfield(result, Symbol(basename(key))) + g[key] = value isa AbstractVector{Bool} ? Int8.(value) : value end Utilities.HDF5Annotations.annotate!(g, MRE_H5_ANNOTATIONS) Utilities.record_inputs!(parent, result) return g end -const _MRE_PER_SURFACE = (:psi, :q, :m, :n, :delta_prime, :delta_prime_imag, :D_I, :D_R, :H, :D_nc, :f_trap, :mu_ratio, :zeff, :Wc_prefac, - :chi_perp, :v_te, :R0, :omega_star_i, :omega_star_e, :omega_hat, :omega_hat_pi, :beta_p, :rho_theta, :w_ib, :eps, :gamma, :delta_r, :w_lo, :D_c, :W_d_layer, - :alpha_s, :beta_s, :C_sat, :w_m, :w_sat) -const _MRE_FLAGS = - (:fully_stabilized, :unstable_any_seed, :unsaturated, :outside_plasma, :gate_disagrees, :interchange_unstable, :linear_unstable, :below_w_ib, :large_delta_prime_w, - :complex_delta_prime, :saturation_unconverged) -const _MRE_TERMS = (:F, :classical, :saturation, :bootstrap, :curvature, :polarization, :w_d) - # Julia's surface × width matrices land in HDF5 as (width, surface). const _MRE_TERM_DIMS = ("w", "surface") const MRE_H5_ANNOTATIONS = [ "w" => (; long_name="full island width grid", units="psi_N", dims=("w",)), "w_m_star" => (; long_name="smallest seed-threshold full width over the surfaces, NaN if none", units="psi_N"), - "PerSurface/psi" => (; long_name="normalized poloidal flux of the rational surface", units="psi_N", dims=("surface",)), - "PerSurface/q" => (; long_name="safety factor at the surface", dims=("surface",)), + "PerSurface/rational_psi" => (; long_name="normalized poloidal flux of the rational surface", units="psi_N", dims=("surface",)), + "PerSurface/rational_q" => (; long_name="safety factor at the surface", dims=("surface",)), "PerSurface/m" => (; long_name="resonant poloidal mode number", dims=("surface",)), "PerSurface/n" => (; long_name="resonant toroidal mode number", dims=("surface",)), "PerSurface/delta_prime" => (; diff --git a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl index 61a2be080..9ff5f89d6 100644 --- a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl +++ b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl @@ -12,8 +12,8 @@ import ...Utilities import ...Utilities: save_to_hdf5 using ...Utilities.HDF5Annotations: replace_group! using ...Utilities.PhysicalConstants: M_E, M_P, E_CHG, MU_0 -using ...InnerLayer.SLAYER: _slayer_profiles, radial_label, build_slayer_inputs, _layer_zeff, _layer_ion_mass, _layer_mass_per_electron, - _build_resistivity_model +using ...InnerLayer: layer_plasma +using ...InnerLayer.SLAYER: radial_label, build_slayer_inputs using Roots: find_zero, Brent include("Geometry.jl") diff --git a/src/Tearing/ModifiedRutherford/Problem.jl b/src/Tearing/ModifiedRutherford/Problem.jl index 8072d7935..44c1131c3 100644 --- a/src/Tearing/ModifiedRutherford/Problem.jl +++ b/src/Tearing/ModifiedRutherford/Problem.jl @@ -88,13 +88,14 @@ ModifiedRutherfordProblem(ffs; kwargs...) = ModifiedRutherfordProblem(; ffs=ffs, """ ModifiedRutherfordResult -Output of `solve(::ModifiedRutherfordProblem)`. Per-surface vectors run core to edge; matrices are surface × width. +Output of `solve(::ModifiedRutherfordProblem)`, built by keyword. Per-surface vectors run core to edge; matrices are +surface × width. ## Fields - `problem` - The producing problem. - `w` - Width grid in ψ_N. - - `psi`, `q`, `m`, `n` - Surface identity. + - `rational_psi`, `rational_q`, `m`, `n` - Surface identity. - `delta_prime`, `delta_prime_imag`, `D_I`, `D_R`, `H` - Outer-region and layer indices (Δ′ after `delta_prime_factor`). - `D_nc`, `f_trap`, `mu_ratio`, `zeff` - Bootstrap coefficient, Sauter 2002 trapped fraction, μ_e/(ν_e + μ_e), Z_eff. - `Wc_prefac`, `chi_perp`, `v_te`, `R0` - Inputs of w_d. @@ -121,11 +122,11 @@ Output of `solve(::ModifiedRutherfordProblem)`. Per-surface vectors run core to - `saturation_unconverged` - `C_sat` not converged in the fit window (see [`saturation_coefficient`](@ref)). - `F`, `classical`, `saturation`, `bootstrap`, `curvature`, `polarization`, `w_d` - F(w), each term, and w_d(w). """ -struct ModifiedRutherfordResult{P} +@kwdef struct ModifiedRutherfordResult{P} problem::P w::Vector{Float64} - psi::Vector{Float64} - q::Vector{Float64} + rational_psi::Vector{Float64} + rational_q::Vector{Float64} m::Vector{Int} n::Vector{Int} delta_prime::Vector{Float64} @@ -239,13 +240,20 @@ function mre_roots(F, w_lo::Real, w_grid::AbstractVector) return (; w_m, w_sat, fully_stabilized=all(<(0), vals), unstable_any_seed=vals[1] > 0, unsaturated=vals[end] > 0) end +# Per-surface scalars, flags and width rows of the result, in field order. +const _MRE_SCALARS = (:rational_psi, :rational_q, :m, :n, :delta_prime, :delta_prime_imag, :D_I, :D_R, :H, :D_nc, :f_trap, :mu_ratio, :zeff, + :Wc_prefac, :chi_perp, :v_te, :R0, :omega_star_i, :omega_star_e, :omega_hat, :omega_hat_pi, :beta_p, :rho_theta, :w_ib, :eps, :gamma, + :delta_r, :w_lo, :D_c, :W_d_layer, :alpha_s, :beta_s, :C_sat, :w_m, :w_sat) +const _MRE_FLAGS = (:fully_stabilized, :unstable_any_seed, :unsaturated, :outside_plasma, :gate_disagrees, :interchange_unstable, + :linear_unstable, :below_w_ib, :large_delta_prime_w, :complex_delta_prime, :saturation_unconverged) +const _MRE_TERMS = (:F, :classical, :saturation, :bootstrap, :curvature, :polarization, :w_d) + # SLAYER's toroidal critical offset D_c and the W_d it implies at one surface, with χ⊥ for every χ input. -function _layer_critical_offset(equil, s, loaded, data, chi_perp) - zeff = _layer_zeff(loaded, data) +function _layer_critical_offset(equil, s, plasma, chi_perp) p = only( - build_slayer_inputs(equil, [s], loaded.profiles; mu_i=_layer_ion_mass(data), mu_rho=_layer_mass_per_electron(data, zeff), zeff, - chi_perp_e=chi_perp, chi_perp_i=chi_perp, chi_perp_Dc=chi_perp, chi_tor=chi_perp, tau_E=data.tau_E, P_perp_model=:chi_perp_e, - dc_type=:toroidal, resistivity_model=_build_resistivity_model(data.resistivity_model), lnLambda_form=data.lnLambda_form) + build_slayer_inputs(equil, [s], plasma.profiles; plasma.mu_i, plasma.mu_rho, plasma.zeff, chi_perp_e=chi_perp, chi_perp_i=chi_perp, + chi_perp_Dc=chi_perp, chi_tor=chi_perp, tau_E=plasma.data.tau_E, P_perp_model=:chi_perp_e, dc_type=:toroidal, + plasma.resistivity_model, plasma.lnLambda_form) ) return p.dc_tmp, layer_transport_width(p.dr_val, p.dc_tmp) end @@ -253,154 +261,139 @@ end # The linear tearing row at ψ in `tearing`, or `nothing`. _tearing_row(tearing, psi) = findfirst(p -> isapprox(p, psi; atol=1e-8), tearing.rational_psi) -function CommonSolve.solve(prob::ModifiedRutherfordProblem) - validate(prob) - ffs = prob.ffs - equil = ffs.equil - data = equil.kinetic - surfaces = ffs.surfaces - ns = length(surfaces) - w = exp.(range(log(prob.w_min), log(prob.w_max); length=prob.nw)) - - # One plasma for every tearing consumer: the SLAYER view supplies n_e, T_e, T_i, Ω_E and the Z_eff closure. - loaded = _slayer_profiles(data, nothing) - psi_xs = collect(Float64, data.psi) +# What every surface shares: the plasma, the radial label, the current profile and the gate. +function _mre_context(prob::ModifiedRutherfordProblem) + equil = prob.ffs.equil + plasma = layer_plasma(equil; log=false) + data = plasma.data usable(v) = v !== nothing && !all(iszero, v) usable(data.chi_e) || data.chi_e_default !== nothing || throw(ArgumentError("ModifiedRutherfordProblem needs χ⊥,e: a chi_e profile or chi_e_default in the kinetic data")) - species = data.ion_species - m_i = sum(s.fraction * s.m for s in species) / sum(s.fraction for s in species) * M_P - z_i = Float64(first(species).z) gate = prob.tearing # Metres map to ψ_N with the toroidal-flux radial label's dr/dψ, the label SLAYER's layer widths use. r_at, dr_dpsi = radial_label(equil) - J_z = current_density_profile(equil) - psi_lim = (first(equil.profiles.xs), last(equil.profiles.xs)) - chi_gate = gate === nothing || !hasproperty(gate.model, :chi_perp_Dc) ? nothing : gate.model.chi_perp_Dc + return (; equil, plasma, data, psi_xs=collect(Float64, data.psi), usable, gate, r_at, dr_dpsi, J_z=current_density_profile(equil), + psi_lim=(first(equil.profiles.xs), last(equil.profiles.xs)), + chi_gate=gate === nothing || !hasproperty(gate.model, :chi_perp_Dc) ? nothing : gate.model.chi_perp_Dc) +end - vec0() = zeros(ns) - flag0() = falses(ns) - out = (; psi=vec0(), q=vec0(), m=zeros(Int, ns), n=zeros(Int, ns), delta_prime=vec0(), delta_prime_imag=vec0(), D_I=vec0(), D_R=vec0(), - H=vec0(), D_nc=vec0(), f_trap=vec0(), mu_ratio=vec0(), zeff=vec0(), Wc_prefac=vec0(), chi_perp=vec0(), v_te=vec0(), R0=vec0(), - omega_star_i=vec0(), omega_star_e=vec0(), omega_hat=vec0(), omega_hat_pi=vec0(), beta_p=vec0(), rho_theta=vec0(), w_ib=vec0(), - eps=vec0(), gamma=vec0(), delta_r=vec0(), w_lo=vec0(), D_c=vec0(), W_d_layer=vec0(), alpha_s=vec0(), beta_s=vec0(), C_sat=vec0(), w_m=vec0(), w_sat=vec0()) - flags = (; fully_stabilized=flag0(), unstable_any_seed=flag0(), unsaturated=flag0(), outside_plasma=flag0(), gate_disagrees=flag0(), - interchange_unstable=flag0(), linear_unstable=flag0(), - below_w_ib=flag0(), large_delta_prime_w=flag0(), complex_delta_prime=flag0(), - saturation_unconverged=flag0()) - mats = Dict(k => zeros(ns, prob.nw) for k in (:F, :classical, :saturation, :bootstrap, :curvature, :polarization, :w_d)) +# Geometry, indices, plasma and term inputs at the k-th rational surface. +function _surface_inputs(prob::ModifiedRutherfordProblem, ctx, k, s) + (; equil, plasma, data, psi_xs, usable, r_at, dr_dpsi) = ctx + rg = s.restype + rg === nothing && throw(ArgumentError("ModifiedRutherfordProblem: the surface at ψ = $(s.psifac) has no ResistGeometry")) + psi = s.psifac + m, n = s.m[1], s.n[1] + g = mre_geometry(equil, psi, s.q1) + D_I = rg.E + rg.F + rg.H - 0.25 + D_R = rg.E + rg.F + rg.H^2 + D_I < 0 || @warn "ModifiedRutherfordProblem: D_I = $D_I ≥ 0 at the $m/$n surface; its terms are NaN" - for (k, s) in enumerate(surfaces) - rg = s.restype - rg === nothing && throw(ArgumentError("ModifiedRutherfordProblem: the surface at ψ = $(s.psifac) has no ResistGeometry")) - psi = s.psifac - m, n = s.m[1], s.n[1] - g = mre_geometry(equil, psi, s.q1) - D_I = rg.E + rg.F + rg.H - 0.25 - D_R = rg.E + rg.F + rg.H^2 - D_I < 0 || @warn "ModifiedRutherfordProblem: D_I = $D_I ≥ 0 at the $m/$n surface; its terms are NaN" + # Plasma at the surface; the ion density falls back to n_e where the data have no n_i. + prof = plasma.profiles + n_e, dn_e = Float64(prof.n_e(psi)), Float64(prof.n_e(psi; deriv=DerivOp(1))) + T_e, dT_e = Float64(prof.T_e(psi)), Float64(prof.T_e(psi; deriv=DerivOp(1))) + T_i, dT_i = Float64(prof.T_i(psi)), Float64(prof.T_i(psi; deriv=DerivOp(1))) + n_i, dn_i = usable(data.n_i) ? _profile_at(psi_xs, data.n_i, psi) : (n_e, dn_e) + zeff = plasma.zeff isa Real ? Float64(plasma.zeff) : Float64(plasma.zeff(psi)) + chi_perp = prob.chi_perp_factor * (usable(data.chi_e) ? first(_profile_at(psi_xs, data.chi_e, psi)) : data.chi_e_default) + ctx.chi_gate === nothing || isapprox(ctx.chi_gate, chi_perp; rtol=1e-6) || + @warn "ModifiedRutherfordProblem: the gate's chi_perp_Dc = $(ctx.chi_gate) differs from the w_d χ⊥ = $chi_perp at the $m/$n surface" + D_nc, mu_ratio = bootstrap_coefficient(g, zeff) - # Plasma at the surface; the ion density falls back to n_e where the data have no n_i. - n_e, dn_e = Float64(loaded.profiles.n_e(psi)), Float64(loaded.profiles.n_e(psi; deriv=DerivOp(1))) - T_e, dT_e = Float64(loaded.profiles.T_e(psi)), Float64(loaded.profiles.T_e(psi; deriv=DerivOp(1))) - T_i, dT_i = Float64(loaded.profiles.T_i(psi)), Float64(loaded.profiles.T_i(psi; deriv=DerivOp(1))) - n_i, dn_i = usable(data.n_i) ? _profile_at(psi_xs, data.n_i, psi) : (n_e, dn_e) - zeff = loaded.zeff === nothing ? data.zeff_default : Float64(loaded.zeff(psi)) - chi_perp = prob.chi_perp_factor * (usable(data.chi_e) ? first(_profile_at(psi_xs, data.chi_e, psi)) : data.chi_e_default) - chi_gate === nothing || isapprox(chi_gate, chi_perp; rtol=1e-6) || - @warn "ModifiedRutherfordProblem: the gate's chi_perp_Dc = $chi_gate differs from the w_d χ⊥ = $chi_perp at the $m/$n surface" + # Polarization inputs (Benjamin App. C); lengths in metres map to ψ_N with dr/dψ. + m_i, z_i = plasma.mu_i * M_P, plasma.z_i + v_te = sqrt(2 * E_CHG * T_e / M_E) + v_ti = sqrt(2 * E_CHG * T_i / m_i) + rho_theta = prob.rho_theta_factor * m_i * v_ti / (g.avg_bpol * E_CHG) + rho_theta_psi = rho_theta / abs(dr_dpsi(psi)) + # Diamagnetic frequencies from the density gradients, in SLAYER's sign convention (charge −1 for electrons). + omega_star_i = -n * T_i * (dn_i / n_i) / (z_i * g.psio) + omega_star_e = n * T_e * (dn_e / n_e) / g.psio + omega_model = + prob.omega_model === :electron_diamagnetic ? omega_star_e * (1 + 0.25 * (dT_e / T_e) / (dn_e / n_e)) : + n * (plasma.has_omega_E ? Float64(prof.omega_E(psi)) : 0.0) - D_nc, mu_ratio = bootstrap_coefficient(g, zeff) - D_nc *= prob.D_nc_factor + # Cylindrical: r_s Δ′ = −2m, referred back to ψ_N by K^(−2α), K = r_s dψ_N/dr, α = √(−D_I) (as delta_prime_to_rs_reference). + K = r_at(psi) / abs(dr_dpsi(psi)) + dp_complex = prob.delta_prime_source === :computed ? prob.ffs.delta_prime.matrix[k, k] : complex(-2 * m * K^(-2 * sqrt(max(-D_I, 0.0)))) + # Saturation coefficients from the equilibrium current in the same radial label. + sc = saturation_coefficient(ctx.J_z, r_at, x -> abs(dr_dpsi(x)), x -> Float64(equil.profiles.q_spline(x)), + x -> Float64(equil.profiles.q_deriv(x)), psi; window=prob.saturation_window, ctx.psi_lim) + # SLAYER's toroidal critical offset at this surface with this χ⊥, so the curvature term meets the gate at small w. + D_c, W_d_layer = prob.curvature_model === :lutjens ? _layer_critical_offset(equil, s, plasma, chi_perp) : (NaN, NaN) + return (; rational_psi=psi, rational_q=s.q, m, n, g, D_I, D_R, H=rg.H, D_nc=prob.D_nc_factor * D_nc, mu_ratio, zeff, chi_perp, v_te, + omega_star_i, omega_star_e, omega_hat=something(prob.omega_ratio, prob.omega_factor * omega_model / omega_star_i), + omega_hat_pi=1 + (dT_i / T_i) / (dn_i / n_i), beta_p=2 * MU_0 * n_i * E_CHG * T_i / g.avg_bpolsq, rho_theta, rho_theta_psi, + w_ib=sqrt(g.eps) * rho_theta_psi, gradient_ratio=(dn_i / n_i) / (g.q1 / g.q), K, dp_complex, + delta_prime=prob.delta_prime_factor * real(dp_complex), sc, D_c, W_d_layer) +end - # Polarization inputs (Benjamin App. C); lengths in metres map to ψ_N with dr/dψ. - v_te = sqrt(2 * E_CHG * T_e / M_E) - v_ti = sqrt(2 * E_CHG * T_i / m_i) - rho_theta = prob.rho_theta_factor * m_i * v_ti / (g.avg_bpol * E_CHG) - rho_theta_psi = rho_theta / abs(dr_dpsi(psi)) - w_ib = sqrt(g.eps) * rho_theta_psi - beta_p = 2 * MU_0 * n_i * E_CHG * T_i / g.avg_bpolsq - gradient_ratio = (dn_i / n_i) / (g.q1 / g.q) - # Diamagnetic frequencies from the density gradients, in SLAYER's sign convention (charge −1 for electrons). - omega_star_i = -n * T_i * (dn_i / n_i) / (z_i * g.psio) - omega_star_e = n * T_e * (dn_e / n_e) / g.psio - omega_hat_pi = 1 + (dT_i / T_i) / (dn_i / n_i) - omega_model = - prob.omega_model === :electron_diamagnetic ? omega_star_e * (1 + 0.25 * (dT_e / T_e) / (dn_e / n_e)) : - n * (loaded.has_omega_E ? Float64(loaded.profiles.omega_E(psi)) : 0.0) - omega_hat = something(prob.omega_ratio, prob.omega_factor * omega_model / omega_star_i) +# Each term and their switched sum F at the full width w, from a surface's inputs. +function _mre_terms(prob::ModifiedRutherfordProblem, si, w) + (; g, D_I, D_R, n, m) = si + wd = transport_width(w; prob.c_wd, si.chi_perp, si.v_te, R0=g.R_major, g.q, g.q1, n, m, g.Wc_prefac) + cl = D_I < 0 ? classical_term(si.delta_prime, D_I, w; model=prob.delta_prime_model) : NaN + st = D_I < 0 ? saturation_term(si.sc.C_sat, si.K, D_I, w; model=prob.delta_prime_model) : NaN + bs = bootstrap_term(si.D_nc, wd, w; prob.k1) + cv = + D_I >= 0 ? NaN : + prob.curvature_model === :lutjens ? lutjens_curvature_term(D_R, D_I, si.K, si.W_d_layer, w; model=prob.delta_prime_model) : + curvature_term(D_R, si.H, D_I, wd, w; prob.k1) + pl = + prob.pol_sign * ( + prob.pol_model === :full ? + polarization_term(w; prob.c_pol, g.eps, si.rho_theta_psi, si.beta_p, si.gradient_ratio, si.omega_hat, si.omega_hat_pi, si.w_ib) : + reduced_polarization_term(w; prob.c_pol, prob.C_pol, si.w_ib) + ) + F = prob.classical * cl + prob.saturation * st + prob.bootstrap * bs + prob.curvature * cv + prob.polarization * pl + return (; F, classical=cl, saturation=st, bootstrap=bs, curvature=cv, polarization=pl, w_d=wd) +end - # Cylindrical: r_s Δ′ = −2m, referred back to ψ_N by K^(−2α), K = r_s dψ_N/dr, α = √(−D_I) (as delta_prime_to_rs_reference). - K = r_at(psi) / abs(dr_dpsi(psi)) - dp_complex = prob.delta_prime_source === :computed ? ffs.delta_prime.matrix[k, k] : complex(-2 * m * K^(-2 * sqrt(max(-D_I, 0.0)))) - dp_mn = prob.delta_prime_factor * real(dp_complex) - # Saturation coefficients from the equilibrium current in the same radial label. - sc = saturation_coefficient(J_z, r_at, x -> abs(dr_dpsi(x)), x -> Float64(equil.profiles.q_spline(x)), - x -> Float64(equil.profiles.q_deriv(x)), psi; window=prob.saturation_window, psi_lim) - flags.saturation_unconverged[k] = !sc.converged - # SLAYER's toroidal critical offset at this surface with this χ⊥, so the curvature term meets the gate at small w. - D_c, W_d_layer = prob.curvature_model === :lutjens ? _layer_critical_offset(equil, s, loaded, data, chi_perp) : (NaN, NaN) - function terms(wj) - wd = transport_width(wj; prob.c_wd, chi_perp, v_te, R0=g.R_major, g.q, g.q1, n, m, g.Wc_prefac) - cl = D_I < 0 ? classical_term(dp_mn, D_I, wj; model=prob.delta_prime_model) : NaN - st = D_I < 0 ? saturation_term(sc.C_sat, K, D_I, wj; model=prob.delta_prime_model) : NaN - bs = bootstrap_term(D_nc, wd, wj; prob.k1) - cv = - D_I >= 0 ? NaN : - prob.curvature_model === :lutjens ? lutjens_curvature_term(D_R, D_I, K, W_d_layer, wj; model=prob.delta_prime_model) : - curvature_term(D_R, rg.H, D_I, wd, wj; prob.k1) - pl = - prob.pol_sign * ( - prob.pol_model === :full ? - polarization_term(wj; prob.c_pol, g.eps, rho_theta_psi, beta_p, - gradient_ratio, omega_hat, omega_hat_pi, w_ib) : - reduced_polarization_term(wj; prob.c_pol, prob.C_pol, w_ib) - ) - F = prob.classical * cl + prob.saturation * st + prob.bootstrap * bs + prob.curvature * cv + prob.polarization * pl - return (; F, cl, st, bs, cv, pl, wd) - end - for (j, wj) in enumerate(w) - t = terms(wj) - mats[:F][k, j], mats[:w_d][k, j] = t.F, t.wd - mats[:classical][k, j], mats[:saturation][k, j], mats[:bootstrap][k, j] = t.cl, t.st, t.bs - mats[:curvature][k, j], mats[:polarization][k, j] = t.cv, t.pl - end +# The linear tearing growth rate and resistive layer width in ψ_N from `tearing`, and the bottom of the root search. +function _gate(prob::ModifiedRutherfordProblem, ctx, psi) + gate = ctx.gate + row = gate === nothing ? nothing : _tearing_row(gate, psi) + gamma = row === nothing ? NaN : gate.gamma[row] + delta_r = row === nothing || isempty(gate.layer_widths) ? NaN : gate.layer_widths[row].delta_s_m / abs(ctx.dr_dpsi(psi)) + return (; gamma, delta_r, w_lo=isnan(delta_r) ? prob.w_min : max(delta_r, prob.w_min)) +end - # Gates: the linear tearing growth rate and resistive layer width from `tearing`, and resistive interchange. - row = gate === nothing ? nothing : _tearing_row(gate, psi) - gamma = row === nothing ? NaN : gate.gamma[row] - delta_r = row === nothing || isempty(gate.layer_widths) ? NaN : gate.layer_widths[row].delta_s_m / abs(dr_dpsi(psi)) - w_lo = isnan(delta_r) ? prob.w_min : max(delta_r, prob.w_min) - flags.interchange_unstable[k] = D_R > 0 - flags.linear_unstable[k] = gamma > 0 - flags.complex_delta_prime[k] = abs(imag(dp_complex)) > 1e-3 * abs(real(dp_complex)) - r = mre_roots(wj -> terms(wj).F, w_lo, w) - excluded = flags.interchange_unstable[k] || D_I >= 0 - # A linearly unstable surface grows from noise, so it has no seed threshold; the MRE still sets its saturation. - w_m, w_sat = excluded ? (NaN, NaN) : (flags.linear_unstable[k] ? NaN : r.w_m, r.w_sat) - flags.fully_stabilized[k], flags.unstable_any_seed[k] = !excluded && r.fully_stabilized, !excluded && r.unstable_any_seed - flags.unsaturated[k] = !excluded && r.unsaturated - flags.outside_plasma[k] = isfinite(w_sat) && (psi - w_sat / 2 < psi_lim[1] || psi + w_sat / 2 > psi_lim[2]) - flags.gate_disagrees[k] = !excluded && isfinite(gamma) && (r.unstable_any_seed ? gamma < 0 : gamma > 0) - flags.below_w_ib[k] = w_m < w_ib +# Roots and flags at one surface. +function _surface_roots(prob::ModifiedRutherfordProblem, ctx, si, gt, w) + F(x) = _mre_terms(prob, si, x).F + r = mre_roots(F, gt.w_lo, w) + interchange_unstable = si.D_R > 0 + linear_unstable = gt.gamma > 0 + excluded = interchange_unstable || si.D_I >= 0 + # A linearly unstable surface grows from noise, so it has no seed threshold; the MRE still sets its saturation. + w_m, w_sat = excluded ? (NaN, NaN) : (linear_unstable ? NaN : r.w_m, r.w_sat) + psi = si.rational_psi + return (; w_m, w_sat, fully_stabilized=!excluded && r.fully_stabilized, unstable_any_seed=!excluded && r.unstable_any_seed, + unsaturated=!excluded && r.unsaturated, outside_plasma=isfinite(w_sat) && (psi - w_sat / 2 < ctx.psi_lim[1] || psi + w_sat / 2 > ctx.psi_lim[2]), + gate_disagrees=!excluded && isfinite(gt.gamma) && (r.unstable_any_seed ? gt.gamma < 0 : gt.gamma > 0), interchange_unstable, + linear_unstable, below_w_ib=w_m < si.w_ib, # Δ′(w) w at the root, dimensionless at any D_I. - flags.large_delta_prime_w[k] = any(x -> isfinite(x) && abs(terms(x).cl) * x >= 1, (w_m, w_sat)) + large_delta_prime_w=any(x -> isfinite(x) && abs(_mre_terms(prob, si, x).classical) * x >= 1, (w_m, w_sat)), + complex_delta_prime=abs(imag(si.dp_complex)) > 1e-3 * abs(real(si.dp_complex)), saturation_unconverged=!si.sc.converged) +end - for (key, val) in pairs((; psi, q=s.q, m, n, delta_prime=dp_mn, delta_prime_imag=imag(dp_complex), D_I, D_R, H=rg.H, D_nc, - f_trap=g.f_trap, mu_ratio, zeff, g.Wc_prefac, chi_perp, v_te, R0=g.R_major, omega_star_i, omega_star_e, omega_hat, omega_hat_pi, - beta_p, rho_theta, w_ib, g.eps, gamma, delta_r, w_lo, D_c, W_d_layer, sc.alpha_s, sc.beta_s, sc.C_sat, w_m, w_sat)) - getfield(out, key)[k] = val - end +function CommonSolve.solve(prob::ModifiedRutherfordProblem) + validate(prob) + ctx = _mre_context(prob) + w = exp.(range(log(prob.w_min), log(prob.w_max); length=prob.nw)) + rows = map(enumerate(prob.ffs.surfaces)) do (k, s) + si = _surface_inputs(prob, ctx, k, s) + gt = _gate(prob, ctx, si.rational_psi) + terms = [_mre_terms(prob, si, wj) for wj in w] + extra = (; si.g.f_trap, si.g.Wc_prefac, R0=si.g.R_major, si.g.eps, delta_prime_imag=imag(si.dp_complex), si.sc.alpha_s, si.sc.beta_s, si.sc.C_sat) + rowterms = NamedTuple{_MRE_TERMS}(Tuple(getfield.(terms, key) for key in _MRE_TERMS)) + merge(si, extra, gt, _surface_roots(prob, ctx, si, gt, w), rowterms) end - valid = filter(!isnan, out.w_m) - w_m_star = isempty(valid) ? NaN : minimum(valid) - return ModifiedRutherfordResult(prob, collect(w), out.psi, out.q, out.m, out.n, out.delta_prime, out.delta_prime_imag, out.D_I, out.D_R, - out.H, out.D_nc, out.f_trap, out.mu_ratio, out.zeff, out.Wc_prefac, out.chi_perp, out.v_te, out.R0, out.omega_star_i, - out.omega_star_e, out.omega_hat, out.omega_hat_pi, out.beta_p, out.rho_theta, out.w_ib, out.eps, out.gamma, out.delta_r, out.w_lo, - out.D_c, out.W_d_layer, out.alpha_s, out.beta_s, out.C_sat, out.w_m, out.w_sat, w_m_star, Vector{Bool}(flags.fully_stabilized), Vector{Bool}(flags.unstable_any_seed), - Vector{Bool}(flags.unsaturated), Vector{Bool}(flags.outside_plasma), Vector{Bool}(flags.gate_disagrees), - Vector{Bool}(flags.interchange_unstable), Vector{Bool}(flags.linear_unstable), Vector{Bool}(flags.below_w_ib), - Vector{Bool}(flags.large_delta_prime_w), Vector{Bool}(flags.complex_delta_prime), - Vector{Bool}(flags.saturation_unconverged), - mats[:F], mats[:classical], mats[:saturation], mats[:bootstrap], mats[:curvature], mats[:polarization], mats[:w_d]) + column(key) = [getfield(r, key) for r in rows] + matrix(key) = isempty(rows) ? zeros(0, length(w)) : permutedims(reduce(hcat, column(key))) + w_m = column(:w_m) + return ModifiedRutherfordResult(; problem=prob, w=collect(w), (key => column(key) for key in _MRE_SCALARS)..., + w_m_star=all(isnan, w_m) ? NaN : minimum(filter(!isnan, w_m)), (key => Vector{Bool}(column(key)) for key in _MRE_FLAGS)..., + (key => matrix(key) for key in _MRE_TERMS)...) end diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index 63a4308e3..be45112ce 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -620,7 +620,7 @@ include("display_check.jl") @test all(isfinite, api.D_c) && all(>(0), api.W_d_layer) k = findfirst(==(3), api.m) gate = solve(TearingProblem(reference.ffs), SLAYERModel(; D_c_type=:toroidal, chi_perp_Dc=api.chi_perp[k])) - row = findfirst(x -> isapprox(x, api.psi[k]; atol=1e-8), gate.rational_psi) + row = findfirst(x -> isapprox(x, api.rational_psi[k]; atol=1e-8), gate.rational_psi) @test gate.params[row].dc_tmp ≈ api.D_c[k] rtol = 1e-10 ben = solve(ModifiedRutherfordProblem(reference.ffs; tearing=reference.tearing, nw=40, curvature_model=:benjamin)) @test all(isnan, ben.D_c) && !(ben.curvature ≈ api.curvature) From 40a13645491526e8143cb39871bcbf6be448deea Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sun, 8 Nov 2026 10:56:10 -0500 Subject: [PATCH 22/26] Repo - MINOR - Time the Callen Riccati solve as its own stage and make the MRE harness case a variant of diiid_slayer_n1_toroidal Info/Runtimes gains callen_delta_prime. The Callen case reads Tearing/Callen/PerSurface/ and comments its override. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- regression-harness/cases/diiid_n1_callen.toml | 10 +++++----- regression-harness/cases/diiid_slayer_n1_mre.toml | 12 ++++++------ src/GeneralizedPerturbedEquilibrium.jl | 10 +++++++--- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/regression-harness/cases/diiid_n1_callen.toml b/regression-harness/cases/diiid_n1_callen.toml index 8392af2ff..f8f271abb 100644 --- a/regression-harness/cases/diiid_n1_callen.toml +++ b/regression-harness/cases/diiid_n1_callen.toml @@ -9,10 +9,10 @@ example_dir = "examples/DIIID-like_ideal_example" remove_sections = ["LocalStability", "Ballooning", "ErrorFields", "KineticForces"] [overrides] -"Tearing.Callen.delta_prime_source" = "cylindrical" +"Tearing.Callen.delta_prime_source" = "cylindrical" # Intrinsic Delta' -2m/rho, as Fortran GPEC 1.6.0; no extra Delta' solve [quantities.callen_hw_v] -h5path = "Tearing/Callen/hw_v" +h5path = "Tearing/Callen/PerSurface/hw_v" type = "real_vector" extract = "all_real" label = "Callen vacuum island half-width (psi_N)" @@ -21,7 +21,7 @@ order = 10 class = "physics_converged" [quantities.callen_hw_v_crit] -h5path = "Tearing/Callen/hw_v_crit" +h5path = "Tearing/Callen/PerSurface/hw_v_crit" type = "real_vector" extract = "all_real" label = "Callen critical vacuum half-width (psi_N)" @@ -30,7 +30,7 @@ order = 12 class = "physics_converged" [quantities.callen_b_crit] -h5path = "Tearing/Callen/b_crit" +h5path = "Tearing/Callen/PerSurface/b_crit" type = "real_vector" extract = "all_real" label = "Callen critical vacuum resonant field (T)" @@ -39,7 +39,7 @@ order = 14 class = "physics_converged" [quantities.callen_n_roots] -h5path = "Tearing/Callen/n_roots" +h5path = "Tearing/Callen/PerSurface/n_roots" type = "real_vector" extract = "all_real" label = "Callen positive cubic roots per surface" diff --git a/regression-harness/cases/diiid_slayer_n1_mre.toml b/regression-harness/cases/diiid_slayer_n1_mre.toml index 8f7e7fc98..7bd0d9a6d 100644 --- a/regression-harness/cases/diiid_slayer_n1_mre.toml +++ b/regression-harness/cases/diiid_slayer_n1_mre.toml @@ -1,17 +1,17 @@ # Regression case: DIII-D-like H-mode n=1 modified Rutherford equation (MRE) behind the toroidal SLAYER gate. -# Same deck as diiid_slayer_n1_toroidal plus [Tearing.ModifiedRutherford] with polarization and saturation on, so -# the classical, saturation, bootstrap, Lütjens curvature and polarization terms, the gate and the roots are tracked. +# A variant of diiid_slayer_n1_toroidal (its deck, overrides and quantities) plus [Tearing.ModifiedRutherford] with +# polarization and saturation on, so the classical, saturation, bootstrap, Lütjens curvature and polarization terms, +# the gate and the roots are tracked. # Each [quantities.*] block names an HDF5 path in the run output, how to extract it, # and the noise floor below which a difference is treated as zero. [case] name = "diiid_slayer_n1_mre" +base = "diiid_slayer_n1_toroidal" # Inherit example_dir, [quantities.*] and [overrides] from this case description = "DIII-D-like H-mode, n=1, modified Rutherford equation with polarization and saturation behind the toroidal SLAYER gate" -example_dir = "examples/DIIID-like_SLAYER_example" [overrides] -"SLAYER.D_c_type" = "toroidal" -"Tearing.ModifiedRutherford.polarization" = true -"Tearing.ModifiedRutherford.saturation" = true +"Tearing.ModifiedRutherford.polarization" = true # Ion polarization term on (off by default) +"Tearing.ModifiedRutherford.saturation" = true # Finite-width saturation term on (off by default) # Surface coefficients the terms are built from. [quantities.mre_D_nc] diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index c86c25876..c8a75164c 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -235,10 +235,14 @@ function main_from_inputs( end if deck.callen !== nothing + # The perturbed equilibrium needs a forward solve, so the computed Δ′ comes from a second, Riccati solve. + ric = nothing + if get(deck.callen, :delta_prime_source, :computed) === :computed + ric = _timed_stage(runtimes, "callen_delta_prime", "Callen Δ′ (Riccati)") do + solve(ForceBalanceProblem(equil; deck.force_balance.keywords...), Riccati(); verbose) + end + end callen = _timed_stage(runtimes, "callen", "Callen threshold") do - # The perturbed equilibrium needs a forward solve, so the computed Δ′ comes from a second, Riccati solve. - computed = get(deck.callen, :delta_prime_source, :computed) === :computed - ric = computed ? solve(ForceBalanceProblem(equil; deck.force_balance.keywords...), Riccati(); verbose) : nothing solve(CallenProblem(pe; delta_prime=ric, deck.callen...)) end save_to_hdf5(output_file, callen) From 97922e65d96fff919bb482a93d0da02269461e97 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sun, 8 Nov 2026 13:28:09 -0500 Subject: [PATCH 23/26] Tearing.ModifiedRutherford - FEATURE - Keep every saturated width with the seed threshold that reaches it When F crosses zero more than once, w_sat is the first of several saturated solutions. The result now keeps all of them (w_sat_all, branch-padded) with the rising crossing below each (w_seed_all), the seed above which the island grows past w_max (w_seed_unsaturated), the count n_sat and a multiple_saturated flag, and writes them under PerSurface. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- docs/src/inner_layer.md | 2 + src/Tearing/ModifiedRutherford/HDF5Output.jl | 9 +++++ src/Tearing/ModifiedRutherford/Problem.jl | 40 +++++++++++++++++--- test/runtests_modified_rutherford.jl | 12 ++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/docs/src/inner_layer.md b/docs/src/inner_layer.md index 378be18ea..7859daab6 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -105,6 +105,7 @@ The DIII-D-like kinetic file of the examples carries analytic χ profiles of the | `Tearing/CriticalResonantField/br_crit`, `br_crit_tesla`, `Q_peak`, `Q0` | the penetration threshold as ``b_r/B_\phi`` and in tesla, the Q of the balance maximum and the natural rotation | | `Tearing/ModifiedRutherford/w`, `Terms/F`, `Terms/`, `Terms/w_d`, `PerSurface/` | the modified Rutherford width grid, ``F(w)`` and each switched term per surface, the transport width, and the surface inputs | | `Tearing/ModifiedRutherford/w_m_star`, `PerSurface/w_m`, `PerSurface/w_sat`, `PerSurface/` | the seed threshold and saturated widths per surface, their minimum over the surfaces, and the validity and gate flags (0/1) | +| `PerSurface/w_sat_all`, `PerSurface/w_seed_all`, `PerSurface/w_seed_unsaturated`, `PerSurface/n_sat` | every saturated width per surface (branch × surface, NaN-padded) with the seed threshold that reaches it, the seed above which the island grows past `w_max`, and the number of saturated widths | | `Tearing/Callen/PerSurface/` | Callen's critical vacuum island width, critical resonant field and cubic roots per resonant row | ## Modified Rutherford diagnostics @@ -119,6 +120,7 @@ the classical drive with its finite-pressure power law, the bootstrap drive with The roots of ``F`` are bracketed on the grid and refined by Brent's method: the seed threshold ``w_m`` is the smallest crossing from ``F<0`` to ``F>0`` and the saturated width ``w_\mathrm{sat}`` the first crossing from ``F>0`` to ``F<0`` above where the island starts, ``w_\mathrm{lo}`` when ``F(w_\mathrm{lo})>0``, else ``w_m``. Flags mark an island that does not saturate in the search range (`unsaturated`), one whose ``w_\mathrm{sat}`` reaches past the equilibrium grid (`outside_plasma`), and a sign of ``F(w_\mathrm{lo})`` that disagrees with the gate's growth rate (`gate_disagrees`). +When ``F`` crosses zero more than once, every saturated width is kept: an island seeded between `w_seed_all[k]` and the next seed threshold settles at `w_sat_all[k]`, and `multiple_saturated` marks surfaces where ``w_\mathrm{sat}`` is the first of several. When the deck also has a `[Tearing]` layer solve, its uncoupled growth rates gate the roots (a surface with ``\gamma>0`` grows from noise, so it has no seed threshold ``w_m``, but keeps its saturated width) and its resistive layer width, converted to ``\psi_N`` with the layer's own radial label, sets the lower end of the search; in a script, pass it as `ModifiedRutherfordProblem(ffs; tearing)`. Surfaces with ``D_R>0`` are excluded as resistive-interchange unstable, and roots below the ion banana width or with ``|\Delta'|w\ge1`` are flagged. Lengths in metres (the layer width, the poloidal ion Larmor radius and the banana width) map to ``\psi_N`` with the toroidal-flux label of `radial_label`, as SLAYER's do. diff --git a/src/Tearing/ModifiedRutherford/HDF5Output.jl b/src/Tearing/ModifiedRutherford/HDF5Output.jl index 0218c15e9..a8a43bbb4 100644 --- a/src/Tearing/ModifiedRutherford/HDF5Output.jl +++ b/src/Tearing/ModifiedRutherford/HDF5Output.jl @@ -30,6 +30,7 @@ end # Julia's surface × width matrices land in HDF5 as (width, surface). const _MRE_TERM_DIMS = ("w", "surface") +const _MRE_BRANCH_DIMS = ("branch", "surface") const MRE_H5_ANNOTATIONS = [ "w" => (; long_name="full island width grid", units="psi_N", dims=("w",)), @@ -89,6 +90,14 @@ const MRE_H5_ANNOTATIONS = [ "PerSurface/large_delta_prime_w" => (; long_name="1 if |Delta'(w)| w >= 1 at w_m or w_sat, Delta'(w) the classical term", dims=("surface",)), "PerSurface/saturation_unconverged" => (; long_name="1 if C_sat moves more than 20% between half and double the fit window, or the window leaves the grid", dims=("surface",)), + "PerSurface/w_sat_all" => (; long_name="every saturated width (falling F crossing) in increasing order, NaN-padded; w_sat is the first", + units="psi_N", dims=_MRE_BRANCH_DIMS), + "PerSurface/w_seed_all" => (; long_name="seed threshold reaching each w_sat_all entry (rising crossing below it), NaN if F > 0 from w_lo", + units="psi_N", dims=_MRE_BRANCH_DIMS), + "PerSurface/w_seed_unsaturated" => (; long_name="seed above which the island grows past w_max (F > 0 there), NaN otherwise", units="psi_N", + dims=("surface",)), + "PerSurface/n_sat" => (; long_name="number of saturated widths", dims=("surface",)), + "PerSurface/multiple_saturated" => (; long_name="1 if more than one saturated width (see w_sat_all)", dims=("surface",)), "PerSurface/complex_delta_prime" => (; long_name="1 if |Im Delta'| > 1e-3 |Re Delta'|", dims=("surface",)), "Terms/F" => (; long_name="modified Rutherford F(w), the sum of the switched-on terms", dims=_MRE_TERM_DIMS), "Terms/classical" => (; long_name="classical term Delta'(w)", dims=_MRE_TERM_DIMS), diff --git a/src/Tearing/ModifiedRutherford/Problem.jl b/src/Tearing/ModifiedRutherford/Problem.jl index 44c1131c3..d5d5b3b74 100644 --- a/src/Tearing/ModifiedRutherford/Problem.jl +++ b/src/Tearing/ModifiedRutherford/Problem.jl @@ -20,7 +20,8 @@ equilibrium's kinetic data, as every tearing layer reads it. A deck states it as The roots of F are refined between grid points from `w_lo = max(δ_r, w_min)`: the seed threshold `w_m` is the smallest crossing from F < 0 to F > 0 and the saturated width `w_sat` the first crossing from F > 0 to F < 0 above where -the island starts (`w_lo` when F(w_lo) > 0, else `w_m`). Surfaces that +the island starts (`w_lo` when F(w_lo) > 0, else `w_m`). Every saturated width and the seed threshold that reaches it are +kept as well (`w_sat_all`, `w_seed_all`), so a surface with several saturated solutions shows all of them. Surfaces that are resistive-interchange unstable (D_R > 0) get no roots; linearly tearing unstable ones (γ > 0 in `tearing`) grow from noise, so they get no seed threshold but keep `w_sat`. @@ -109,6 +110,11 @@ surface × width. (r − r_s)/r_s reference, used by `curvature_model = :lutjens` (`NaN` for `:benjamin`). - `alpha_s`, `beta_s`, `C_sat` - Current-gradient coefficients of the saturation term and its coefficient. - `w_m`, `w_sat` - Seed threshold and saturated full widths in ψ_N; `NaN` when absent or excluded, and `w_m` `NaN` when γ > 0. + - `w_sat_all`, `w_seed_all` - Every saturated width per surface (surface × branch, increasing, `NaN`-padded) and the + seed threshold that reaches each: seeds between `w_seed_all[s, k]` and the next threshold settle at `w_sat_all[s, k]`; + `w_seed_all` is `NaN` on a branch F > 0 reaches from `w_lo`. `w_sat` is the first column. + - `w_seed_unsaturated` - Seed above which the island grows past `w_max`, when F > 0 there; `NaN` otherwise. + - `n_sat` - Number of saturated widths; `multiple_saturated` when more than one. - `w_m_star` - Smallest `w_m` over the surfaces. - `fully_stabilized` - F < 0 on the whole search range. - `unstable_any_seed` - F > 0 at `w_lo`. @@ -160,10 +166,15 @@ surface × width. C_sat::Vector{Float64} w_m::Vector{Float64} w_sat::Vector{Float64} + w_sat_all::Matrix{Float64} + w_seed_all::Matrix{Float64} + w_seed_unsaturated::Vector{Float64} + n_sat::Vector{Int} w_m_star::Float64 fully_stabilized::Vector{Bool} unstable_any_seed::Vector{Bool} unsaturated::Vector{Bool} + multiple_saturated::Vector{Bool} outside_plasma::Vector{Bool} gate_disagrees::Vector{Bool} interchange_unstable::Vector{Bool} @@ -217,12 +228,17 @@ end """ - mre_roots(F, w_lo, w_grid) -> (; w_m, w_sat, fully_stabilized, unstable_any_seed, unsaturated) + mre_roots(F, w_lo, w_grid) -> (; w_m, w_sat, w_sat_all, w_seed_all, w_seed_unsaturated, fully_stabilized, unstable_any_seed, unsaturated) Roots of `F` on `[w_lo, maximum(w_grid)]`, bracketed on `w_lo` and the grid points above it and refined by Brent's method: `w_m` is the smallest crossing from negative to positive, and `w_sat` the first crossing from positive to negative above where an island starts, `w_lo` when F(w_lo) > 0, else `w_m` (`NaN` when absent). `unsaturated` is F > 0 at the top of the grid. + +Every saturated branch is returned too: `w_sat_all` holds each falling crossing in increasing order, and `w_seed_all[k]` +the rising crossing just below `w_sat_all[k]` (`NaN` when F > 0 from `w_lo` up to it). An island seeded between +`w_seed_all[k]` and the next seed threshold settles at `w_sat_all[k]`. `w_seed_unsaturated` is the rising crossing +above the last falling one when F > 0 at the top of the grid: seeds above it grow past the grid (`NaN` otherwise). """ function mre_roots(F, w_lo::Real, w_grid::AbstractVector) nodes = vcat(Float64(w_lo), filter(>(w_lo), w_grid)) @@ -237,14 +253,20 @@ function mre_roots(F, w_lo::Real, w_grid::AbstractVector) start = vals[1] > 0 ? nodes[1] : w_m above = filter(>(start), falling) w_sat = isempty(above) ? NaN : first(above) - return (; w_m, w_sat, fully_stabilized=all(<(0), vals), unstable_any_seed=vals[1] > 0, unsaturated=vals[end] > 0) + # F is continuous, so rising and falling crossings alternate; each falling one is reached from the rising one below it. + seed_below(x) = (b = filter(<(x), rising); isempty(b) ? NaN : last(b)) + w_seed_all = seed_below.(above) + top = isempty(above) ? filter(>(-Inf), rising) : filter(>(last(above)), rising) + w_seed_unsaturated = vals[end] > 0 && !isempty(top) ? last(top) : NaN + return (; w_m, w_sat, w_sat_all=above, w_seed_all, w_seed_unsaturated, fully_stabilized=all(<(0), vals), unstable_any_seed=vals[1] > 0, + unsaturated=vals[end] > 0) end # Per-surface scalars, flags and width rows of the result, in field order. const _MRE_SCALARS = (:rational_psi, :rational_q, :m, :n, :delta_prime, :delta_prime_imag, :D_I, :D_R, :H, :D_nc, :f_trap, :mu_ratio, :zeff, :Wc_prefac, :chi_perp, :v_te, :R0, :omega_star_i, :omega_star_e, :omega_hat, :omega_hat_pi, :beta_p, :rho_theta, :w_ib, :eps, :gamma, - :delta_r, :w_lo, :D_c, :W_d_layer, :alpha_s, :beta_s, :C_sat, :w_m, :w_sat) -const _MRE_FLAGS = (:fully_stabilized, :unstable_any_seed, :unsaturated, :outside_plasma, :gate_disagrees, :interchange_unstable, + :delta_r, :w_lo, :D_c, :W_d_layer, :alpha_s, :beta_s, :C_sat, :w_m, :w_sat, :w_seed_unsaturated, :n_sat) +const _MRE_FLAGS = (:fully_stabilized, :unstable_any_seed, :unsaturated, :multiple_saturated, :outside_plasma, :gate_disagrees, :interchange_unstable, :linear_unstable, :below_w_ib, :large_delta_prime_w, :complex_delta_prime, :saturation_unconverged) const _MRE_TERMS = (:F, :classical, :saturation, :bootstrap, :curvature, :polarization, :w_d) @@ -368,8 +390,10 @@ function _surface_roots(prob::ModifiedRutherfordProblem, ctx, si, gt, w) excluded = interchange_unstable || si.D_I >= 0 # A linearly unstable surface grows from noise, so it has no seed threshold; the MRE still sets its saturation. w_m, w_sat = excluded ? (NaN, NaN) : (linear_unstable ? NaN : r.w_m, r.w_sat) + w_sat_all, w_seed_all = excluded ? (Float64[], Float64[]) : (r.w_sat_all, r.w_seed_all) psi = si.rational_psi - return (; w_m, w_sat, fully_stabilized=!excluded && r.fully_stabilized, unstable_any_seed=!excluded && r.unstable_any_seed, + return (; w_m, w_sat, w_sat_all, w_seed_all, w_seed_unsaturated=excluded ? NaN : r.w_seed_unsaturated, n_sat=length(w_sat_all), + multiple_saturated=length(w_sat_all) > 1, fully_stabilized=!excluded && r.fully_stabilized, unstable_any_seed=!excluded && r.unstable_any_seed, unsaturated=!excluded && r.unsaturated, outside_plasma=isfinite(w_sat) && (psi - w_sat / 2 < ctx.psi_lim[1] || psi + w_sat / 2 > ctx.psi_lim[2]), gate_disagrees=!excluded && isfinite(gt.gamma) && (r.unstable_any_seed ? gt.gamma < 0 : gt.gamma > 0), interchange_unstable, linear_unstable, below_w_ib=w_m < si.w_ib, @@ -392,8 +416,12 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) end column(key) = [getfield(r, key) for r in rows] matrix(key) = isempty(rows) ? zeros(0, length(w)) : permutedims(reduce(hcat, column(key))) + # Branches padded with NaN to the most found on any surface. + nbranch = maximum((r.n_sat for r in rows); init=1) + branches(key) = [k <= length(getfield(r, key)) ? getfield(r, key)[k] : NaN for r in rows, k in 1:nbranch] w_m = column(:w_m) return ModifiedRutherfordResult(; problem=prob, w=collect(w), (key => column(key) for key in _MRE_SCALARS)..., + w_sat_all=branches(:w_sat_all), w_seed_all=branches(:w_seed_all), w_m_star=all(isnan, w_m) ? NaN : minimum(filter(!isnan, w_m)), (key => Vector{Bool}(column(key)) for key in _MRE_FLAGS)..., (key => matrix(key) for key in _MRE_TERMS)...) end diff --git a/test/runtests_modified_rutherford.jl b/test/runtests_modified_rutherford.jl index dbc85c7c8..cf8c55fed 100644 --- a/test/runtests_modified_rutherford.jl +++ b/test/runtests_modified_rutherford.jl @@ -101,11 +101,23 @@ using TOML @test u.w_m ≈ 3e-3 rtol = 1e-10 @test u.w_sat ≈ 2e-3 rtol = 1e-10 @test u.unsaturated + # Every branch: 2e-3 from any seed below 3e-3, 0.02 from seeds in (3e-3, 0.05), growth past the grid above 0.05. + @test u.w_sat_all ≈ [2e-3, 0.02] rtol = 1e-10 + @test isnan(u.w_seed_all[1]) + @test u.w_seed_all[2] ≈ 3e-3 rtol = 1e-10 + @test u.w_seed_unsaturated ≈ 0.05 rtol = 1e-10 # With F < 0 at w_lo the island starts at w_m = 2e-3 and saturates at the next falling root, 3e-3, not at 0.05. v = MR.mre_roots(w -> -f4(w), 1e-5, grid) @test v.w_m ≈ 2e-3 rtol = 1e-10 @test v.w_sat ≈ 3e-3 rtol = 1e-10 @test !v.unsaturated + @test v.w_sat_all ≈ [3e-3, 0.05] rtol = 1e-10 + @test v.w_seed_all ≈ [2e-3, 0.02] rtol = 1e-10 + @test isnan(v.w_seed_unsaturated) + # One rising root and F > 0 at the top: w_m is also the seed of unbounded growth; a stable branch has none. + @test MR.mre_roots(w -> (w - 3e-3) / w, 1e-5, grid).w_seed_unsaturated ≈ 3e-3 rtol = 1e-10 + @test isnan(s.w_seed_unsaturated) + @test s.w_sat_all ≈ [0.05] rtol = 1e-10 end @testset "Callen cubic: both positive roots solve it and merge at the critical vacuum width" begin From d7b4451f11362c2b89ef9aa2982edf6ba7b442ab Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sun, 8 Nov 2026 13:43:21 -0500 Subject: [PATCH 24/26] Tearing.ModifiedRutherford - FEATURE - Report the resonant field of the vacuum island as wide as each seed threshold b_m, b_seed_all and b_seed_unsaturated are the resonant area-weighted fields [T] whose vacuum islands are as wide as w_m, each saturated branch's seed and the unsaturated seed, B = pi |n q'| chi1 w^2/(8A), with b_m_star their minimum. The new public vacuum_island_field inverts the perturbed equilibrium's island width; the API test checks it against that width and field at every surface. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- docs/src/inner_layer.md | 1 + src/Tearing/ModifiedRutherford/HDF5Output.jl | 7 ++++ .../ModifiedRutherford/ModifiedRutherford.jl | 3 +- src/Tearing/ModifiedRutherford/Problem.jl | 34 ++++++++++++++++--- test/runtests_solve_api.jl | 7 ++++ 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/docs/src/inner_layer.md b/docs/src/inner_layer.md index 7859daab6..5574cb0bd 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -106,6 +106,7 @@ The DIII-D-like kinetic file of the examples carries analytic χ profiles of the | `Tearing/ModifiedRutherford/w`, `Terms/F`, `Terms/`, `Terms/w_d`, `PerSurface/` | the modified Rutherford width grid, ``F(w)`` and each switched term per surface, the transport width, and the surface inputs | | `Tearing/ModifiedRutherford/w_m_star`, `PerSurface/w_m`, `PerSurface/w_sat`, `PerSurface/` | the seed threshold and saturated widths per surface, their minimum over the surfaces, and the validity and gate flags (0/1) | | `PerSurface/w_sat_all`, `PerSurface/w_seed_all`, `PerSurface/w_seed_unsaturated`, `PerSurface/n_sat` | every saturated width per surface (branch × surface, NaN-padded) with the seed threshold that reaches it, the seed above which the island grows past `w_max`, and the number of saturated widths | +| `PerSurface/b_m`, `PerSurface/b_seed_all`, `PerSurface/b_seed_unsaturated`, `b_m_star` | the resonant area-weighted field [T] whose vacuum island is as wide as each seed threshold, ``B = \pi |n q'| \chi_1 w^2/(8A)`` (the inverse of the perturbed equilibrium's island width), to compare with `PerturbedEquilibrium` resonant fields and error-field thresholds | | `Tearing/Callen/PerSurface/` | Callen's critical vacuum island width, critical resonant field and cubic roots per resonant row | ## Modified Rutherford diagnostics diff --git a/src/Tearing/ModifiedRutherford/HDF5Output.jl b/src/Tearing/ModifiedRutherford/HDF5Output.jl index a8a43bbb4..8b50f8ff4 100644 --- a/src/Tearing/ModifiedRutherford/HDF5Output.jl +++ b/src/Tearing/ModifiedRutherford/HDF5Output.jl @@ -35,6 +35,7 @@ const _MRE_BRANCH_DIMS = ("branch", "surface") const MRE_H5_ANNOTATIONS = [ "w" => (; long_name="full island width grid", units="psi_N", dims=("w",)), "w_m_star" => (; long_name="smallest seed-threshold full width over the surfaces, NaN if none", units="psi_N"), + "b_m_star" => (; long_name="smallest b_m over the surfaces, NaN if none", units="T"), "PerSurface/rational_psi" => (; long_name="normalized poloidal flux of the rational surface", units="psi_N", dims=("surface",)), "PerSurface/rational_q" => (; long_name="safety factor at the surface", dims=("surface",)), "PerSurface/m" => (; long_name="resonant poloidal mode number", dims=("surface",)), @@ -96,6 +97,12 @@ const MRE_H5_ANNOTATIONS = [ units="psi_N", dims=_MRE_BRANCH_DIMS), "PerSurface/w_seed_unsaturated" => (; long_name="seed above which the island grows past w_max (F > 0 there), NaN otherwise", units="psi_N", dims=("surface",)), + "PerSurface/b_m" => (; long_name="resonant area-weighted field whose vacuum island has full width w_m, B = pi |n q'| chi1 w^2/(8 A)", + units="T", dims=("surface",)), + "PerSurface/b_seed_all" => (; long_name="resonant area-weighted field whose vacuum island is as wide as each w_seed_all entry", units="T", + dims=_MRE_BRANCH_DIMS), + "PerSurface/b_seed_unsaturated" => (; long_name="resonant area-weighted field whose vacuum island is as wide as w_seed_unsaturated", + units="T", dims=("surface",)), "PerSurface/n_sat" => (; long_name="number of saturated widths", dims=("surface",)), "PerSurface/multiple_saturated" => (; long_name="1 if more than one saturated width (see w_sat_all)", dims=("surface",)), "PerSurface/complex_delta_prime" => (; long_name="1 if |Im Delta'| > 1e-3 |Re Delta'|", dims=("surface",)), diff --git a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl index 9ff5f89d6..47d135a97 100644 --- a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl +++ b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl @@ -9,6 +9,7 @@ import FastInterpolations using FastInterpolations: cubic_interp, Series, PeriodicBC, DerivOp import ...Utilities +import ...Equilibrium import ...Utilities: save_to_hdf5 using ...Utilities.HDF5Annotations: replace_group! using ...Utilities.PhysicalConstants: M_E, M_P, E_CHG, MU_0 @@ -25,6 +26,6 @@ export MRESurfaceGeometry, mre_geometry, current_density_profile, sauter2002_tra export classical_term, saturation_coefficient, saturation_term, bootstrap_term, curvature_term, lutjens_curvature_term, layer_transport_width, polarization_term, reduced_polarization_term, transport_width -export mre_roots, ModifiedRutherfordProblem, ModifiedRutherfordResult +export mre_roots, vacuum_island_field, ModifiedRutherfordProblem, ModifiedRutherfordResult end # module ModifiedRutherford diff --git a/src/Tearing/ModifiedRutherford/Problem.jl b/src/Tearing/ModifiedRutherford/Problem.jl index d5d5b3b74..a43359114 100644 --- a/src/Tearing/ModifiedRutherford/Problem.jl +++ b/src/Tearing/ModifiedRutherford/Problem.jl @@ -116,6 +116,10 @@ surface × width. - `w_seed_unsaturated` - Seed above which the island grows past `w_max`, when F > 0 there; `NaN` otherwise. - `n_sat` - Number of saturated widths; `multiple_saturated` when more than one. - `w_m_star` - Smallest `w_m` over the surfaces. + - `b_m`, `b_seed_all`, `b_seed_unsaturated`, `b_m_star` - Resonant area-weighted field [T] whose vacuum island is as wide as + `w_m`, each `w_seed_all` and `w_seed_unsaturated` ([`vacuum_island_field`](@ref)), comparable with the perturbed + equilibrium's resonant field and with error-field thresholds; `b_m_star` is the smallest `b_m` over the surfaces (not + necessarily at the `w_m_star` surface). - `fully_stabilized` - F < 0 on the whole search range. - `unstable_any_seed` - F > 0 at `w_lo`. - `unsaturated` - F > 0 at `w_max`: an island past its last rising root does not saturate in the search range. @@ -170,7 +174,11 @@ surface × width. w_seed_all::Matrix{Float64} w_seed_unsaturated::Vector{Float64} n_sat::Vector{Int} + b_m::Vector{Float64} + b_seed_all::Matrix{Float64} + b_seed_unsaturated::Vector{Float64} w_m_star::Float64 + b_m_star::Float64 fully_stabilized::Vector{Bool} unstable_any_seed::Vector{Bool} unsaturated::Vector{Bool} @@ -262,10 +270,22 @@ function mre_roots(F, w_lo::Real, w_grid::AbstractVector) unsaturated=vals[end] > 0) end +""" + vacuum_island_field(equil, psi, n, q1, w) -> Float64 + +Resonant area-weighted field [T] whose vacuum island at the surface `psi` (toroidal mode number `n`, dq/dψ_N `q1`) has +full width `w` in ψ_N: the inverse of the perturbed equilibrium's island width, B = π |n q1| χ₁ w²/(8A), with χ₁ = 2πψ₀ +and A the flux-surface area. +""" +function vacuum_island_field(equil, psi::Real, n::Integer, q1::Real, w::Real) + area = Equilibrium.flux_surface_area(equil, Float64(psi), length(equil.rzphi_ys) - 1) + return π * abs(n * q1) * 2π * equil.psio * w^2 / (8 * area) +end + # Per-surface scalars, flags and width rows of the result, in field order. const _MRE_SCALARS = (:rational_psi, :rational_q, :m, :n, :delta_prime, :delta_prime_imag, :D_I, :D_R, :H, :D_nc, :f_trap, :mu_ratio, :zeff, :Wc_prefac, :chi_perp, :v_te, :R0, :omega_star_i, :omega_star_e, :omega_hat, :omega_hat_pi, :beta_p, :rho_theta, :w_ib, :eps, :gamma, - :delta_r, :w_lo, :D_c, :W_d_layer, :alpha_s, :beta_s, :C_sat, :w_m, :w_sat, :w_seed_unsaturated, :n_sat) + :delta_r, :w_lo, :D_c, :W_d_layer, :alpha_s, :beta_s, :C_sat, :w_m, :w_sat, :w_seed_unsaturated, :n_sat, :b_m, :b_seed_unsaturated) const _MRE_FLAGS = (:fully_stabilized, :unstable_any_seed, :unsaturated, :multiple_saturated, :outside_plasma, :gate_disagrees, :interchange_unstable, :linear_unstable, :below_w_ib, :large_delta_prime_w, :complex_delta_prime, :saturation_unconverged) const _MRE_TERMS = (:F, :classical, :saturation, :bootstrap, :curvature, :polarization, :w_d) @@ -344,7 +364,8 @@ function _surface_inputs(prob::ModifiedRutherfordProblem, ctx, k, s) x -> Float64(equil.profiles.q_deriv(x)), psi; window=prob.saturation_window, ctx.psi_lim) # SLAYER's toroidal critical offset at this surface with this χ⊥, so the curvature term meets the gate at small w. D_c, W_d_layer = prob.curvature_model === :lutjens ? _layer_critical_offset(equil, s, plasma, chi_perp) : (NaN, NaN) - return (; rational_psi=psi, rational_q=s.q, m, n, g, D_I, D_R, H=rg.H, D_nc=prob.D_nc_factor * D_nc, mu_ratio, zeff, chi_perp, v_te, + return (; rational_psi=psi, rational_q=s.q, m, n, g, b_per_w2=vacuum_island_field(equil, psi, n, s.q1, 1.0), D_I, D_R, H=rg.H, D_nc=prob.D_nc_factor * D_nc, mu_ratio, zeff, + chi_perp, v_te, omega_star_i, omega_star_e, omega_hat=something(prob.omega_ratio, prob.omega_factor * omega_model / omega_star_i), omega_hat_pi=1 + (dT_i / T_i) / (dn_i / n_i), beta_p=2 * MU_0 * n_i * E_CHG * T_i / g.avg_bpolsq, rho_theta, rho_theta_psi, w_ib=sqrt(g.eps) * rho_theta_psi, gradient_ratio=(dn_i / n_i) / (g.q1 / g.q), K, dp_complex, @@ -391,8 +412,12 @@ function _surface_roots(prob::ModifiedRutherfordProblem, ctx, si, gt, w) # A linearly unstable surface grows from noise, so it has no seed threshold; the MRE still sets its saturation. w_m, w_sat = excluded ? (NaN, NaN) : (linear_unstable ? NaN : r.w_m, r.w_sat) w_sat_all, w_seed_all = excluded ? (Float64[], Float64[]) : (r.w_sat_all, r.w_seed_all) + w_seed_unsaturated = excluded ? NaN : r.w_seed_unsaturated psi = si.rational_psi - return (; w_m, w_sat, w_sat_all, w_seed_all, w_seed_unsaturated=excluded ? NaN : r.w_seed_unsaturated, n_sat=length(w_sat_all), + # Resonant field of the vacuum island as wide as each seed threshold; B ∝ w². + b(x) = si.b_per_w2 * x^2 + return (; w_m, w_sat, w_sat_all, w_seed_all, w_seed_unsaturated, n_sat=length(w_sat_all), b_m=b(w_m), b_seed_all=b.(w_seed_all), + b_seed_unsaturated=b(w_seed_unsaturated), multiple_saturated=length(w_sat_all) > 1, fully_stabilized=!excluded && r.fully_stabilized, unstable_any_seed=!excluded && r.unstable_any_seed, unsaturated=!excluded && r.unsaturated, outside_plasma=isfinite(w_sat) && (psi - w_sat / 2 < ctx.psi_lim[1] || psi + w_sat / 2 > ctx.psi_lim[2]), gate_disagrees=!excluded && isfinite(gt.gamma) && (r.unstable_any_seed ? gt.gamma < 0 : gt.gamma > 0), interchange_unstable, @@ -421,7 +446,8 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) branches(key) = [k <= length(getfield(r, key)) ? getfield(r, key)[k] : NaN for r in rows, k in 1:nbranch] w_m = column(:w_m) return ModifiedRutherfordResult(; problem=prob, w=collect(w), (key => column(key) for key in _MRE_SCALARS)..., - w_sat_all=branches(:w_sat_all), w_seed_all=branches(:w_seed_all), + w_sat_all=branches(:w_sat_all), w_seed_all=branches(:w_seed_all), b_seed_all=branches(:b_seed_all), + b_m_star=all(isnan, column(:b_m)) ? NaN : minimum(filter(!isnan, column(:b_m))), w_m_star=all(isnan, w_m) ? NaN : minimum(filter(!isnan, w_m)), (key => Vector{Bool}(column(key)) for key in _MRE_FLAGS)..., (key => matrix(key) for key in _MRE_TERMS)...) end diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index be45112ce..a2d0aef9b 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -243,6 +243,13 @@ include("display_check.jl") # The deck reads and normalizes its forcing file exactly as a fresh RMPField does. @test pe.resonant_area_weighted_field ≈ reference.resonant_area_weighted_field rtol = 1e-10 + # The MRE's seed field inverts the perturbed equilibrium's vacuum-island width at every surface. + MRE = GeneralizedPerturbedEquilibrium.Tearing.ModifiedRutherford + for (row, k) in enumerate(resp.rational_surface_idx) + sk = ffs.surfaces[k] + @test MRE.vacuum_island_field(equil, resp.rational_psi[row], resp.rational_n[row], sk.q1, 2 * pe.island_half_width[row]) ≈ + abs(pe.resonant_area_weighted_field[row]) rtol = 1e-8 + end @test pe.forcing_b ≈ reference.forcing_b rtol = 1e-10 # One response serves any number of fields: forcing it again reproduces the result. From 02de80f02935b3c8130825fe57c5ab0d9580167d Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sun, 8 Nov 2026 15:13:14 -0500 Subject: [PATCH 25/26] Tearing - FEATURE - Add the coil forcing to the modified Rutherford equation, with its critical fields and a Callen preset - Tearing.ResonantForcing: the resonant forcing of a coil-driven perturbed equilibrium (vacuum and total resonant fields, Callen's Delta_RMP, vacuum island width, toroidal-flux radius) shared by Callen and the MRE, and forcing_thresholds for the critical fields. - ModifiedRutherfordProblem takes pe; the forcing term Delta_RMP (drho/dpsi_N)(w_v/w)^2 enters F (forcing = false switches it off), so roots and branches include the applied field. b_crit (a forced window opens below the unforced seed threshold) and b_trigger (it reaches that threshold) follow from the unforced F. - delta_prime_model and pol_model gain :callen; CALLEN_PRESET (deck preset = "callen") reduces F to Callen's steady state, whose roots and b_crit equal CallenProblem's. - main shares one Riccati Delta' solve between the MRE and Callen when the force-free-states solve has none. - HDF5 output, docs, tests and the diiid_n1_callen harness case extended. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- docs/development/hdf5-conventions.md | 2 +- docs/src/inner_layer.md | 22 ++- regression-harness/cases/diiid_n1_callen.toml | 34 +++- src/Deck.jl | 21 ++- src/GeneralizedPerturbedEquilibrium.jl | 23 +-- src/Tearing/Callen/Callen.jl | 4 +- src/Tearing/Callen/Problem.jl | 50 ++---- src/Tearing/ModifiedRutherford/HDF5Output.jl | 15 +- .../ModifiedRutherford/ModifiedRutherford.jl | 3 +- src/Tearing/ModifiedRutherford/Problem.jl | 106 +++++++++--- src/Tearing/ResonantForcing.jl | 151 ++++++++++++++++++ src/Tearing/Tearing.jl | 2 + test/runtests_modified_rutherford.jl | 29 ++++ test/runtests_solve_api.jl | 63 ++++++-- 14 files changed, 427 insertions(+), 98 deletions(-) create mode 100644 src/Tearing/ResonantForcing.jl diff --git a/docs/development/hdf5-conventions.md b/docs/development/hdf5-conventions.md index 0273f888b..dddde30ec 100644 --- a/docs/development/hdf5-conventions.md +++ b/docs/development/hdf5-conventions.md @@ -46,7 +46,7 @@ Top level (11 groups): | `PerturbedEquilibrium/` | `ForcingModes/`, `Response/`, `ResponseMatrices/`, `SingularCoupling/`, `Energies/`, `TorqueResponse/` (kinetic forward solves: ψ-resolved `T_xe`, `T_source`), control-surface spectra | | `KineticForces/` | `/` (torque/energy profiles, `dTdpsi_ell` resolved by bounce harmonic on the `ell` scale, `KineticMatrices/`); multi-ion runs add `PerSpecies///` with the same per-method layout, summing to the top-level total | | `ErrorFields/` | `CoilSensitivities/` (per-coil-set control-surface spectra and their rigid shift/tilt derivatives, `DominantMode/` full-window projection); `MonteCarlo/` (intrinsic and corrected `\|δ\|` histograms over the sampled tolerances, per batch and averaged); `Risk/` (threshold density, `P(lock\|δ)`, locking probabilities, `ToleranceScan/`); `NTV/` (correction-coil overlap and NTV torque couplings per kAt) | -| `Tearing/` | `PerSurface/` (+ `DpMatrix/`), `Roots/`, `LayerWidths/`, `Diagnostics/{ValidRoots,Poles,FilteredRoots}` (+ `*Winding` with `root_classification = winding`), `Scan/Surface_/`, `CriticalResonantField/` (+ `Scan/Surface_/`), `ModifiedRutherford/` (`w`, `w_m_star`, `PerSurface/`, `Terms/` surface × width), `Callen/PerSurface/` | +| `Tearing/` | `PerSurface/` (+ `DpMatrix/`), `Roots/`, `LayerWidths/`, `Diagnostics/{ValidRoots,Poles,FilteredRoots}` (+ `*Winding` with `root_classification = winding`), `Scan/Surface_/`, `CriticalResonantField/` (+ `Scan/Surface_/`), `ModifiedRutherford/` (`w`, `w_m_star`, `b_m_star`, `b_crit_star`, `PerSurface/` with branch × surface `w_sat_all`-type arrays, `Terms/` surface × width), `Callen/PerSurface/` | | `SurfaceGeometries/` | `{Plasma,Wall}/{x,y,z}` point clouds | Reserved (documented, not yet written): `ForceFreeStates/Solutions/RiccatiIntegration/` — the third integrator backend slot alongside `ForwardIntegration` and `GalerkinIntegration`. diff --git a/docs/src/inner_layer.md b/docs/src/inner_layer.md index 5574cb0bd..efe4d4728 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -107,6 +107,8 @@ The DIII-D-like kinetic file of the examples carries analytic χ profiles of the | `Tearing/ModifiedRutherford/w_m_star`, `PerSurface/w_m`, `PerSurface/w_sat`, `PerSurface/` | the seed threshold and saturated widths per surface, their minimum over the surfaces, and the validity and gate flags (0/1) | | `PerSurface/w_sat_all`, `PerSurface/w_seed_all`, `PerSurface/w_seed_unsaturated`, `PerSurface/n_sat` | every saturated width per surface (branch × surface, NaN-padded) with the seed threshold that reaches it, the seed above which the island grows past `w_max`, and the number of saturated widths | | `PerSurface/b_m`, `PerSurface/b_seed_all`, `PerSurface/b_seed_unsaturated`, `b_m_star` | the resonant area-weighted field [T] whose vacuum island is as wide as each seed threshold, ``B = \pi |n q'| \chi_1 w^2/(8A)`` (the inverse of the perturbed equilibrium's island width), to compare with `PerturbedEquilibrium` resonant fields and error-field thresholds | +| `PerSurface/b_vac`, `b_res`, `delta_rmp`, `w_v`, `Terms/forcing` | with a perturbed equilibrium, the vacuum and total resonant fields, Callen's ``\Delta'_\mathrm{RMP}``, the vacuum island width and the forcing term per width | +| `PerSurface/b_crit`, `b_trigger`, `w_crit`, `b_crit_star` | the vacuum resonant fields [T] at which a forced window opens below the unforced seed threshold (at width `w_crit`) and at which it reaches that threshold, and the smallest `b_crit` over the surfaces | | `Tearing/Callen/PerSurface/` | Callen's critical vacuum island width, critical resonant field and cubic roots per resonant row | ## Modified Rutherford diagnostics @@ -114,7 +116,7 @@ The DIII-D-like kinetic file of the examples carries analytic χ profiles of the `[Tearing.ModifiedRutherford]` evaluates the steady modified Rutherford equation on a grid of full island widths ``w`` (in ``\psi_N``) for each rational surface of a single-``n`` Riccati or Galerkin run with Δ′: ```math -F(w) = \Delta'(w) + \Delta_\mathrm{nc}(w) + \Delta_\mathrm{GGJ}(w) + \Delta_\mathrm{pol}(w; \omega), +F(w) = \Delta'(w) + \Delta_\mathrm{nc}(w) + \Delta_\mathrm{GGJ}(w) + \Delta_\mathrm{pol}(w; \omega) + \Delta_\mathrm{RMP}(w), ``` the classical drive with its finite-pressure power law, the bootstrap drive with the transport threshold ``w_d``, the Glasser–Greene–Johnson curvature term and the ion-polarization term (Benjamin et al. 2026, Eqs. 7, 10–14 and C.1). Each term has a switch and a coefficient; the polarization term is off by default. A finite-width saturation term, `saturation = true` (off by default), replaces ``r_s\Delta'`` by ``r_s\Delta'(0) - C_\mathrm{sat}W/r_s`` with ``C_\mathrm{sat} = 0.8\alpha_s^2 - 0.27\beta_s - 0.09\alpha_s`` from the current-density gradients at the surface (Fitzpatrick 2023, Eq. 9.15), so that a surface with ``\Delta' > 0`` saturates. The curvature term defaults to the Lütjens et al. (2001) form ``r_s\Delta_\mathrm{GGJ} = 6.35 D_R/\sqrt{(W/r_s)^2 + 0.65 W_d^2}`` with ``W_d`` from SLAYER's toroidal critical offset, so that at small widths it equals the critical ``\Delta`` of the linear SLAYER layer; `curvature_model = "benjamin"` selects Benjamin et al. (2026) Eq. 12. The surface geometry follows the Fortran RDCON `mercier.f` averages, and the densities, temperatures, ``\omega_E``, ``Z_\mathrm{eff}`` and ``\chi_\perp`` come from the equilibrium's kinetic profiles. A `[Tearing]` section holding only this sub-table runs no layer solve. In a script, `solve(ModifiedRutherfordProblem(ffs; polarization=true))` returns the same [`ModifiedRutherfordResult`](@ref Tearing.ModifiedRutherford.ModifiedRutherfordResult). @@ -127,9 +129,19 @@ Surfaces with ``D_R>0`` are excluded as resistive-interchange unstable, and root Lengths in metres (the layer width, the poloidal ion Larmor radius and the banana width) map to ``\psi_N`` with the toroidal-flux label of `radial_label`, as SLAYER's do. The island frequency in ``\Delta_\mathrm{pol}`` is ``\omega_{*e}(1+\eta_e/4)`` (Wilson 1996) by default, or the lab-frame ``n\omega_E`` with `omega_model = "exb"`. +### Applied-field forcing and critical fields + +With a coil-driven `[PerturbedEquilibrium]` in the same deck (or `ModifiedRutherfordProblem(ffs; pe)` in a script), the applied field forces the island through Callen's term ``\Delta_\mathrm{RMP}(w) = \Delta'_\mathrm{RMP}(d\rho/d\psi_N)(w_v/w)^2`` (Callen 2016, Eq. 17), with ``w_v`` the vacuum island width and the island locked in phase with the field; `forcing = false` switches it off. The roots and branches are then those of the forced ``F``. +The perturbed equilibrium needs a forward solve, so `main` adds one Riccati solve for ``\Delta'`` and shares it between the MRE and `[Tearing.Callen]`; the forward solve then needs `vac_flag = true`. +Since the forcing is proportional to the vacuum resonant field ``B_\mathrm{vac}``, the forced ``F`` is positive where ``k B_\mathrm{vac} > G(w) = -F_0(w)w^2``, with ``F_0`` the unforced sum and ``k = \Delta'_\mathrm{RMP}(d\rho/d\psi_N)w_v^2/B_\mathrm{vac}``. +`b_crit` is the field at which a forced window first opens below the unforced seed threshold (the smallest local minimum of ``G``), and `b_trigger` the field at which that window reaches the unforced seed threshold, so that a forced island grows on to the intrinsic saturated width (the largest ``G`` between the window and that threshold). +Both are 0 on a surface that is unstable at ``w_\mathrm{lo}`` without forcing. +Callen's ``-w_\mathrm{pol}^2/w^3`` beats the forcing at small widths and so gives a window away from ``w_\mathrm{lo}``; without such a term ``G`` rises from ``w_\mathrm{lo}``, the window opens there (`w_crit` equals `w_lo`), and `b_crit` depends on ``w_\mathrm{lo}``, which the `[Tearing]` gate sets to the resistive layer width. `b_trigger` does not. +`preset = "callen"` (`CALLEN_PRESET` in a script) reduces ``F`` to Callen's steady state ``\Delta_c + \Delta'_\mathrm{RMP}(w_v/w)^2 - w_\mathrm{pol}^2/w^3`` in his units (`delta_prime_model = "callen"`, `pol_model = "callen"`, the other terms off): its roots are then the full widths of `[Tearing.Callen]`'s cubic roots, and its `b_crit` is Callen's critical field. + `[Tearing.Callen]` evaluates Callen's reduced equation for RMP-driven islands on a single-``n`` perturbed equilibrium driven by coils, in the form of Fortran GPEC: the critical vacuum island half-width, the critical vacuum resonant field and the half-widths of the two positive roots of the steady cubic ``\Delta_c w^3 + \Delta'_\mathrm{RMP}w_\mathrm{vac}^2w - w_\mathrm{pol}^2 = 0`` (Callen 2016, Eq. 17), which is posed in the full width ``w`` with ``w_\mathrm{vac} = 2h_v``. The vacuum resonant field is the coils' resonant harmonic on the rational surface over its area. -Its intrinsic index ``\Delta_c`` is the computed ``\Delta'_{mn}`` by default (`main` adds a Riccati solve for it, since the perturbed equilibrium needs a forward one; in a script, pass it as `CallenProblem(pe; delta_prime)`), or Callen's cylindrical ``-2m/\rho`` of Fortran GPEC 1.6.0 with `delta_prime_source = "cylindrical"`. +Its intrinsic index ``\Delta_c`` is the computed ``\Delta'_{mn}`` by default (from the Riccati solve `main` shares with the MRE; in a script, pass it as `CallenProblem(pe; delta_prime)`), or Callen's cylindrical ``-2m/\rho`` of Fortran GPEC 1.6.0 with `delta_prime_source = "cylindrical"`. The same switch on `[Tearing.ModifiedRutherford]` replaces ``\Delta'_{mn}`` by the cylindrical ``r_s\Delta' = -2m``, so the MRE then runs on any solve, forward included, as a reference case. In a script, `solve(CallenProblem(pe))` returns the same [`CallenResult`](@ref Tearing.Callen.CallenResult). @@ -216,3 +228,9 @@ Modules = [GeneralizedPerturbedEquilibrium.Tearing.ModifiedRutherford] ```@autodocs Modules = [GeneralizedPerturbedEquilibrium.Tearing.Callen] ``` + +### ResonantForcing + +```@autodocs +Modules = [GeneralizedPerturbedEquilibrium.Tearing.ResonantForcing] +``` diff --git a/regression-harness/cases/diiid_n1_callen.toml b/regression-harness/cases/diiid_n1_callen.toml index f8f271abb..d9c32fb09 100644 --- a/regression-harness/cases/diiid_n1_callen.toml +++ b/regression-harness/cases/diiid_n1_callen.toml @@ -1,15 +1,18 @@ -# Regression case: DIII-D-like n=1 Callen vacuum-island threshold on the coil-driven perturbed equilibrium. +# Regression case: DIII-D-like n=1 Callen vacuum-island threshold on the coil-driven perturbed equilibrium, and the +# modified Rutherford equation's Callen preset on the same forcing. # The cylindrical intrinsic Δ′ (−2m/ρ) keeps the Fortran GPEC parity case and needs no extra Δ′ solve. # Each [quantities.*] block names an HDF5 path in the run output, how to extract it, # and the noise floor below which a difference is treated as zero. [case] name = "diiid_n1_callen" -description = "DIII-D-like, n=1, Callen vacuum-island threshold and cubic roots on the coil-driven perturbed equilibrium" +description = "DIII-D-like, n=1, Callen vacuum-island threshold and cubic roots, and the MRE Callen preset, on the coil-driven perturbed equilibrium" example_dir = "examples/DIIID-like_ideal_example" remove_sections = ["LocalStability", "Ballooning", "ErrorFields", "KineticForces"] [overrides] "Tearing.Callen.delta_prime_source" = "cylindrical" # Intrinsic Delta' -2m/rho, as Fortran GPEC 1.6.0; no extra Delta' solve +"Tearing.ModifiedRutherford.preset" = "callen" # Reduce F(w) to Callen's steady state with the coil forcing +"Tearing.ModifiedRutherford.delta_prime_source" = "cylindrical" # Intrinsic Delta' -2m/rho, as the Callen section [quantities.callen_hw_v] h5path = "Tearing/Callen/PerSurface/hw_v" @@ -47,6 +50,33 @@ noise_threshold = 0 order = 16 class = "physics_converged" +[quantities.mre_callen_w_m] +h5path = "Tearing/ModifiedRutherford/PerSurface/w_m" +type = "real_vector" +extract = "all_real" +label = "MRE Callen preset: forced seed threshold full width (psi_N)" +noise_threshold = 1e-10 +order = 18 +class = "physics_converged" + +[quantities.mre_callen_w_sat] +h5path = "Tearing/ModifiedRutherford/PerSurface/w_sat" +type = "real_vector" +extract = "all_real" +label = "MRE Callen preset: forced saturated full width (psi_N)" +noise_threshold = 1e-10 +order = 20 +class = "physics_converged" + +[quantities.mre_callen_b_crit] +h5path = "Tearing/ModifiedRutherford/PerSurface/b_crit" +type = "real_vector" +extract = "all_real" +label = "MRE Callen preset: critical vacuum resonant field (T)" +noise_threshold = 1e-12 +order = 22 +class = "physics_converged" + [quantities.runtime] h5path = "" type = "runtime" diff --git a/src/Deck.jl b/src/Deck.jl index 387da1110..a81ac5275 100644 --- a/src/Deck.jl +++ b/src/Deck.jl @@ -382,11 +382,16 @@ end """ modified_rutherford_from_deck(inputs) -> Union{Nothing,Dict{Symbol,Any}} -The `ModifiedRutherfordProblem` keywords of `[Tearing.ModifiedRutherford]`; `nothing` without that section. +The `ModifiedRutherfordProblem` keywords of `[Tearing.ModifiedRutherford]`; `nothing` without that section. The key +`preset = "callen"` expands to `CALLEN_PRESET`, under the section's other keys. """ function modified_rutherford_from_deck(inputs::AbstractDict) haskey(inputs, "Tearing") && haskey(inputs["Tearing"], "ModifiedRutherford") || return nothing - return section_keywords(inputs["Tearing"]["ModifiedRutherford"], "Tearing.ModifiedRutherford", _struct_fields(ModifiedRutherfordProblem, (:ffs, :tearing))) + table = inputs["Tearing"]["ModifiedRutherford"] + preset = get(table, "preset", "none") + preset in ("none", "callen") || throw(ArgumentError("[Tearing.ModifiedRutherford] preset must be \"none\" or \"callen\", got \"$preset\"")) + kwargs = section_keywords(_without(table, "preset"), "Tearing.ModifiedRutherford", _struct_fields(ModifiedRutherfordProblem, (:ffs, :tearing, :pe))) + return preset == "callen" ? merge(Dict{Symbol,Any}(pairs(ModifiedRutherford.CALLEN_PRESET)), kwargs) : kwargs end """ @@ -499,11 +504,12 @@ function parse_deck(inputs::AbstractDict, path::AbstractString) has_ffs || error("[Tearing.ModifiedRutherford] needs a [ForceFreeStates] solve") (; keywords, algorithm) = force_balance first(keywords[:nn]) == last(keywords[:nn]) || error("[Tearing.ModifiedRutherford] is single-n: set [ForceFreeStates] nn to one toroidal mode number") + # Without a Δ′ of its own (a forward solve), the run adds a Riccati solve, which needs vac_flag. get(modified_rutherford, :delta_prime_source, :computed) === :cylindrical || - (algorithm isa Galerkin && algorithm.rpec_flag) || (algorithm isa Riccati && get(keywords, :vac_flag, false)) || + (algorithm isa Galerkin && algorithm.rpec_flag) || get(keywords, :vac_flag, false) || error( - "[Tearing.ModifiedRutherford] needs Δ′: use integrator = \"Riccati\" with vac_flag = true, \"Galerkin\" with [Galerkin] rpec_flag = true, " * - "or delta_prime_source = \"cylindrical\"" + "[Tearing.ModifiedRutherford] needs Δ′: set [ForceFreeStates] vac_flag = true (Riccati or Forward), use \"Galerkin\" with " * + "[Galerkin] rpec_flag = true, or set delta_prime_source = \"cylindrical\"" ) isempty(get(get(inputs, "Equilibrium", Dict{String,Any}()), "kinetic_file", "")) && error("[Tearing.ModifiedRutherford] needs kinetic profiles: set kinetic_file in [Equilibrium]") @@ -511,6 +517,9 @@ function parse_deck(inputs::AbstractDict, path::AbstractString) perturbed_equilibrium = perturbed_equilibrium_from_deck(inputs) forcing = forcing_terms_control(inputs, path) callen = callen_from_deck(inputs) + modified_rutherford === nothing || perturbed_equilibrium === nothing || get(modified_rutherford, :forcing, true) === false || + forcing.forcing_data_format == "coil" || + error("[Tearing.ModifiedRutherford] forcing needs a coil forcing: set [ForcingTerms] forcing_data_format = \"coil\", or forcing = false") if callen !== nothing perturbed_equilibrium === nothing && error("[Tearing.Callen] needs a [PerturbedEquilibrium] section") first(force_balance.keywords[:nn]) == last(force_balance.keywords[:nn]) || @@ -678,7 +687,7 @@ deck_sections(m::ForceFreeStates.MatchResult) = merge(deck_sections(m.model), Dict{String,Any}("Match" => merge(_deck_table(m.problem, (:ffs,)), Dict{String,Any}("inner_model" => "GGJ")))) deck_sections(kf::KineticForcesResult) = deck_sections(kf.control) deck_sections(t::TearingResult) = t.model === nothing ? Dict{String,Any}() : _tearing_sections(t.problem, t.problem.critical_resonant_field, t.model, t.config) -deck_sections(r::ModifiedRutherfordResult) = Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => _deck_table(r.problem, (:ffs, :tearing)))) +deck_sections(r::ModifiedRutherfordResult) = Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => _deck_table(r.problem, (:ffs, :tearing, :pe)))) deck_sections(r::CallenResult) = Dict{String,Any}("Tearing" => Dict{String,Any}("Callen" => _deck_table(r.problem, (:pe, :delta_prime)))) deck_sections(ef::ErrorFieldsResult) = _error_fields_sections(ef.settings, ef.tolerances === nothing ? "" : _RECORDED_TOLERANCES) diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index c8a75164c..aad456183 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -226,24 +226,27 @@ function main_from_inputs( save_to_hdf5(output_file, tearing) end + # The perturbed equilibrium needs a forward solve, so a computed Δ′ for the MRE or Callen comes from one shared Riccati solve. + computed(section) = section !== nothing && get(section, :delta_prime_source, :computed) === :computed + has_delta_prime = ffs !== nothing && ffs.delta_prime !== nothing && !isempty(ffs.delta_prime.matrix) + ric = has_delta_prime ? ffs : nothing + if !has_delta_prime && (computed(deck.modified_rutherford) || computed(deck.callen)) + ric = _timed_stage(runtimes, "tearing_delta_prime", "Tearing Δ′ (Riccati)") do + solve(ForceBalanceProblem(equil; deck.force_balance.keywords...), Riccati(); verbose) + end + end + if deck.modified_rutherford !== nothing modified_rutherford = _timed_stage(runtimes, "modified_rutherford", "Modified Rutherford") do - # With [Tearing], its linear solve gates the roots on the same Δ′. - solve(ModifiedRutherfordProblem(ffs; tearing, deck.modified_rutherford...)) + # With [Tearing], its linear solve gates the roots; with [PerturbedEquilibrium], the coil field forces them. + solve(ModifiedRutherfordProblem(something(ric, ffs); tearing, pe, deck.modified_rutherford...)) end save_to_hdf5(output_file, modified_rutherford) end if deck.callen !== nothing - # The perturbed equilibrium needs a forward solve, so the computed Δ′ comes from a second, Riccati solve. - ric = nothing - if get(deck.callen, :delta_prime_source, :computed) === :computed - ric = _timed_stage(runtimes, "callen_delta_prime", "Callen Δ′ (Riccati)") do - solve(ForceBalanceProblem(equil; deck.force_balance.keywords...), Riccati(); verbose) - end - end callen = _timed_stage(runtimes, "callen", "Callen threshold") do - solve(CallenProblem(pe; delta_prime=ric, deck.callen...)) + solve(CallenProblem(pe; delta_prime=computed(deck.callen) ? ric : nothing, deck.callen...)) end save_to_hdf5(output_file, callen) end diff --git a/src/Tearing/Callen/Callen.jl b/src/Tearing/Callen/Callen.jl index 481803dfb..c32d0d0a1 100644 --- a/src/Tearing/Callen/Callen.jl +++ b/src/Tearing/Callen/Callen.jl @@ -5,14 +5,12 @@ module Callen using HDF5 import CommonSolve -import FastInterpolations import ...Utilities -import ...ForcingTerms import ...Utilities: save_to_hdf5 using ...Utilities.HDF5Annotations: replace_group! -using ...Utilities.PhysicalConstants: M_P, E_CHG using ...InnerLayer: layer_plasma +using ..ResonantForcing: resonant_forcing, callen_polarization_width include("Problem.jl") include("HDF5Output.jl") diff --git a/src/Tearing/Callen/Problem.jl b/src/Tearing/Callen/Problem.jl index a98cbd7b7..f679b7f05 100644 --- a/src/Tearing/Callen/Problem.jl +++ b/src/Tearing/Callen/Problem.jl @@ -104,17 +104,6 @@ function callen_cubic_roots(a::Real, b::Real, c::Real) return (roots[1], roots[2]) end -# Vacuum resonant field |B_vac| [T] of the coil forcing on the surface ψ, harmonic m. -function _vacuum_resonant_field(pe, equil, psi, n, m, area) - rmp = pe.forcing - rmp isa ForcingTerms.RMPSource && rmp.ctrl.forcing_data_format == "coil" || - throw(ArgumentError("CallenProblem needs a single coil forcing (forcing_data_format = \"coil\") for the vacuum resonant field")) - cfg = ForcingTerms.CoilConfig(rmp.ctrl) - grid = ForcingTerms.CoilForcingGrid(equil, cfg, n; psi) - amplitude = only(ForcingTerms.coil_forcing_modes(pe.coil_sets, grid, n, m, m)).amplitude - return abs(rmp.scale * amplitude) / area -end - # Δ′_mn of the surface at ψ from a force-free-states result, referred to the radius ρ in 1/m: K^(2α) Δ′/ρ, K = ρ dψ_N/dρ. function _callen_delta_c(ric, psi, rho, drho) k = findfirst(s -> isapprox(s.psifac, psi; atol=1e-8), ric.surfaces) @@ -140,43 +129,24 @@ function CommonSolve.solve(prob::CallenProblem) throw(ArgumentError("CallenProblem with delta_prime_source = :computed needs delta_prime, a Riccati or Galerkin solve carrying Δ′")) plasma = layer_plasma(equil; log=false) - T_i_of, m_i, z_i = plasma.profiles.T_i, plasma.mu_i * M_P, plasma.z_i - R0, B0 = equil.ro, equil.params.bt0 - chi1 = 2π * equil.psio - qspl = equil.profiles.q_spline - psi0 = first(equil.profiles.xs) - - nrow = length(resp.rational_psi) - out = Dict( - k => fill(NaN, nrow) for k in (:rational_psi, :rational_q, :rho_tor, :drho_dpsi, :rho_i, :w_pol, :delta_rmp, :delta_c, :b_res, :b_vac, - :hw_isl, :hw_v, :hw_v_crit, :hw_min, :hw_sat, :b_crit) - ) - m_out, n_out, n_roots = zeros(Int, nrow), zeros(Int, nrow), zeros(Int, nrow) + rf = resonant_forcing(pe) + nrow = length(rf.rational_psi) + out = Dict(k => fill(NaN, nrow) for k in (:rho_i, :w_pol, :delta_c, :hw_v_crit, :hw_min, :hw_sat, :b_crit)) + n_roots = zeros(Int, nrow) for row in 1:nrow - psi, q, m, n, area = resp.rational_psi[row], resp.rational_q[row], resp.rational_m_res[row], resp.rational_n[row], resp.rational_area[row] - q1 = ffs.surfaces[resp.rational_surface_idx[row]].q1 - # ρ = (Φ_tor/πB₀)^(1/2) with Φ_tor = 2πψ₀ ∫q dψ_N; dρ/dψ_N = qψ₀/(B₀ρ). - rho = sqrt(FastInterpolations.integrate(qspl, psi0, psi) * 2 * equil.psio / B0) - drho = q * equil.psio / (B0 * rho) - rho_i = sqrt(2 * E_CHG * Float64(T_i_of(psi)) / m_i) / (z_i * E_CHG * B0 / m_i) - w_pol = 0.5 * q * rho_i / sqrt(rho / R0) - b_res = abs(pe.resonant_area_weighted_field[row]) - delta_rmp = abs(pe.delta_prime[row]) / (2π * R0 * q) * B0 / b_res + psi, m, rho, drho, delta_rmp, hw_v, b_vac = rf.rational_psi[row], rf.m[row], rf.rho[row], rf.drho[row], rf.delta_rmp[row], rf.hw_v[row], rf.b_vac[row] + rho_i, w_pol = callen_polarization_width(equil, plasma, psi, rf.rational_q[row]) delta_c = prob.delta_prime_source === :cylindrical ? -2 * m / rho : _callen_delta_c(ric, psi, rho, drho) # With Δ_c ≥ 0 any vacuum island lets a seed above h_min grow, so the critical vacuum width is zero. hw_v_crit = delta_c < 0 ? 0.5 * w_pol^(2 / 3) * ((27 / 4) * abs(delta_c))^(1 / 6) / sqrt(delta_rmp) : 0.0 - b_vac = _vacuum_resonant_field(pe, equil, psi, n, m, area) - shear = n * q1 / q - hw_v = sqrt(abs(4 * b_vac * area / (2π * shear * q * chi1))) # Roots are full widths in metres; the outputs are half-widths in ψ_N. x_min, x_sat = callen_cubic_roots(delta_c, delta_rmp * (2 * hw_v * drho)^2, -w_pol^2) - for (k, v) in pairs((; rational_psi=psi, rational_q=q, rho_tor=rho, drho_dpsi=drho, rho_i, w_pol, delta_rmp, delta_c, b_res, b_vac, - hw_isl=pe.island_half_width[row], hw_v, hw_v_crit=hw_v_crit / drho, hw_min=x_min / (2 * drho), hw_sat=x_sat / (2 * drho), + for (k, v) in pairs((; rho_i, w_pol, delta_c, hw_v_crit=hw_v_crit / drho, hw_min=x_min / (2 * drho), hw_sat=x_sat / (2 * drho), b_crit=b_vac * (hw_v_crit / drho / hw_v)^2)) out[k][row] = v end - m_out[row], n_out[row], n_roots[row] = m, n, count(!isnan, (x_min, x_sat)) + n_roots[row] = count(!isnan, (x_min, x_sat)) end - return CallenResult(prob, out[:rational_psi], out[:rational_q], m_out, n_out, out[:rho_tor], out[:drho_dpsi], out[:rho_i], out[:w_pol], out[:delta_rmp], - out[:delta_c], out[:b_res], out[:b_vac], out[:hw_isl], out[:hw_v], out[:hw_v_crit], out[:hw_min], out[:hw_sat], n_roots, out[:b_crit]) + return CallenResult(prob, rf.rational_psi, rf.rational_q, rf.m, rf.n, rf.rho, rf.drho, out[:rho_i], out[:w_pol], rf.delta_rmp, out[:delta_c], rf.b_res, + rf.b_vac, rf.hw_isl, rf.hw_v, out[:hw_v_crit], out[:hw_min], out[:hw_sat], n_roots, out[:b_crit]) end diff --git a/src/Tearing/ModifiedRutherford/HDF5Output.jl b/src/Tearing/ModifiedRutherford/HDF5Output.jl index 8b50f8ff4..276d036a1 100644 --- a/src/Tearing/ModifiedRutherford/HDF5Output.jl +++ b/src/Tearing/ModifiedRutherford/HDF5Output.jl @@ -5,7 +5,8 @@ # Tearing/ # └── ModifiedRutherford/ # ├── w -- width grid (ψ_N) -# ├── w_m_star -- smallest seed threshold over the surfaces +# ├── w_m_star, b_m_star -- smallest seed threshold and its field over the surfaces +# ├── b_crit_star -- smallest critical field of the forcing over the surfaces # ├── PerSurface/ -- surface identity, indices, term inputs, roots and flags # └── Terms/ -- F(w), each term and w_d(w), surface × width @@ -36,6 +37,7 @@ const MRE_H5_ANNOTATIONS = [ "w" => (; long_name="full island width grid", units="psi_N", dims=("w",)), "w_m_star" => (; long_name="smallest seed-threshold full width over the surfaces, NaN if none", units="psi_N"), "b_m_star" => (; long_name="smallest b_m over the surfaces, NaN if none", units="T"), + "b_crit_star" => (; long_name="smallest b_crit over the surfaces, NaN if none", units="T"), "PerSurface/rational_psi" => (; long_name="normalized poloidal flux of the rational surface", units="psi_N", dims=("surface",)), "PerSurface/rational_q" => (; long_name="safety factor at the surface", dims=("surface",)), "PerSurface/m" => (; long_name="resonant poloidal mode number", dims=("surface",)), @@ -105,6 +107,16 @@ const MRE_H5_ANNOTATIONS = [ units="T", dims=("surface",)), "PerSurface/n_sat" => (; long_name="number of saturated widths", dims=("surface",)), "PerSurface/multiple_saturated" => (; long_name="1 if more than one saturated width (see w_sat_all)", dims=("surface",)), + "PerSurface/b_vac" => (; long_name="vacuum (coil) resonant area-weighted field of the perturbed equilibrium, NaN without one", units="T", dims=("surface",)), + "PerSurface/b_res" => (; long_name="total resonant area-weighted field of the perturbed equilibrium, NaN without one", units="T", dims=("surface",)), + "PerSurface/delta_rmp" => (; long_name="Callen Delta_RMP = |Delta|/(2 pi R0 q) B0/|B_res| in 1/m of rho, NaN without a perturbed equilibrium", + units="1/m", dims=("surface",)), + "PerSurface/w_v" => (; long_name="vacuum island full width at the applied field, NaN without a perturbed equilibrium", units="psi_N", dims=("surface",)), + "PerSurface/b_crit" => (; long_name="vacuum resonant field at which a forced window F > 0 opens below the unforced seed threshold", units="T", + dims=("surface",)), + "PerSurface/b_trigger" => (; long_name="vacuum resonant field at which the forced window reaches the unforced seed threshold", units="T", + dims=("surface",)), + "PerSurface/w_crit" => (; long_name="full width where the forced window opens at b_crit, NaN if none", units="psi_N", dims=("surface",)), "PerSurface/complex_delta_prime" => (; long_name="1 if |Im Delta'| > 1e-3 |Re Delta'|", dims=("surface",)), "Terms/F" => (; long_name="modified Rutherford F(w), the sum of the switched-on terms", dims=_MRE_TERM_DIMS), "Terms/classical" => (; long_name="classical term Delta'(w)", dims=_MRE_TERM_DIMS), @@ -112,5 +124,6 @@ const MRE_H5_ANNOTATIONS = [ "Terms/bootstrap" => (; long_name="bootstrap term Delta_nc(w)", dims=_MRE_TERM_DIMS), "Terms/curvature" => (; long_name="curvature term Delta_GGJ(w) of curvature_model", dims=_MRE_TERM_DIMS), "Terms/polarization" => (; long_name="ion polarization term Delta_pol(w), including pol_sign", dims=_MRE_TERM_DIMS), + "Terms/forcing" => (; long_name="coil forcing term Delta_RMP (drho/dpsi_N) (w_v/w)^2, 0 without a perturbed equilibrium", dims=_MRE_TERM_DIMS), "Terms/w_d" => (; long_name="transport threshold width w_d(w)", units="psi_N", dims=_MRE_TERM_DIMS) ] diff --git a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl index 47d135a97..561879741 100644 --- a/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl +++ b/src/Tearing/ModifiedRutherford/ModifiedRutherford.jl @@ -16,6 +16,7 @@ using ...Utilities.PhysicalConstants: M_E, M_P, E_CHG, MU_0 using ...InnerLayer: layer_plasma using ...InnerLayer.SLAYER: radial_label, build_slayer_inputs using Roots: find_zero, Brent +using ..ResonantForcing: resonant_forcing, toroidal_flux_radius, callen_polarization_width, forcing_thresholds include("Geometry.jl") include("Terms.jl") @@ -26,6 +27,6 @@ export MRESurfaceGeometry, mre_geometry, current_density_profile, sauter2002_tra export classical_term, saturation_coefficient, saturation_term, bootstrap_term, curvature_term, lutjens_curvature_term, layer_transport_width, polarization_term, reduced_polarization_term, transport_width -export mre_roots, vacuum_island_field, ModifiedRutherfordProblem, ModifiedRutherfordResult +export mre_roots, vacuum_island_field, ModifiedRutherfordProblem, ModifiedRutherfordResult, CALLEN_PRESET end # module ModifiedRutherford diff --git a/src/Tearing/ModifiedRutherford/Problem.jl b/src/Tearing/ModifiedRutherford/Problem.jl index a43359114..7652b42b1 100644 --- a/src/Tearing/ModifiedRutherford/Problem.jl +++ b/src/Tearing/ModifiedRutherford/Problem.jl @@ -8,7 +8,7 @@ The steady modified Rutherford equation (MRE) on every rational surface of a finished force-balance solve, - F(w) = s_Δ Δ′(w) + s_sat Δ_sat(w) + s_nc Δ_nc(w) + s_GGJ Δ_GGJ(w) + s_pol Δ_pol(w), + F(w) = s_Δ Δ′(w) + s_sat Δ_sat(w) + s_nc Δ_nc(w) + s_GGJ Δ_GGJ(w) + s_pol Δ_pol(w) + s_RMP Δ_RMP(w), with `w` the full island width in normalized poloidal flux ψ_N. Each term is evaluated on the grid whether or not its switch is on; the switches select which terms `F` sums. Δ′ is the real part of the diagonal of `ffs.delta_prime` @@ -25,17 +25,29 @@ kept as well (`w_sat_all`, `w_seed_all`), so a surface with several saturated so are resistive-interchange unstable (D_R > 0) get no roots; linearly tearing unstable ones (γ > 0 in `tearing`) grow from noise, so they get no seed threshold but keep `w_sat`. +With a coil-driven perturbed equilibrium `pe`, the applied field forces the island: Δ_RMP(w) = Δ_RMP (dρ/dψ_N) (w_v/w)² +(Callen's Eq. 17, [`resonant_forcing`](@ref)), with w_v the vacuum island width and the island locked in phase with the +field. It scales with the vacuum resonant field B_vac, so the roots and branches are those of the forced F, and the +critical fields `b_crit` and `b_trigger` follow from the unforced F ([`forcing_thresholds`](@ref)). + +`CALLEN_PRESET` (deck `preset = "callen"`) reduces F to Callen's steady state Δ_c + Δ_RMP (w_v/w)² − w_pol²/w³ in his +units, so `solve` reproduces `CallenProblem`'s roots (full widths) and critical field. + ## Fields - `ffs` - Outer solution, single n, with a Δ′ matrix (Riccati with `vac_flag=true`, or Galerkin with `rpec_flag=true`) unless `delta_prime_source = :cylindrical`. - `tearing` - Linear tearing solve on the same Δ′ (the gate and the layer width δ_r), or `nothing`; set by the deck when `[Tearing]` is present. - - `classical`, `saturation`, `bootstrap`, `curvature`, `polarization` - Term switches s_Δ, s_sat, s_nc, s_GGJ, s_pol. - `saturation` is the finite-width correction to Δ′ ([`saturation_term`](@ref)), off by default. + - `pe` - Single-n, coil-driven perturbed equilibrium on the same equilibrium and n, or `nothing`; set by the deck when + `[PerturbedEquilibrium]` is present. Its rows are matched to the surfaces by ψ. + - `classical`, `saturation`, `bootstrap`, `curvature`, `polarization`, `forcing` - Term switches s_Δ, s_sat, s_nc, s_GGJ, + s_pol, s_RMP. `saturation` is the finite-width correction to Δ′ ([`saturation_term`](@ref)), off by default; + `forcing` acts only with `pe`. - `saturation_window` - Half-width in ψ_N of the quadratic fit that gives J_z′ and J_z″ in [`saturation_coefficient`](@ref). - `delta_prime_source` - `:computed` (Δ′_mn of `ffs`) or `:cylindrical` (r_s Δ′_mn = −2m, a reference case). - - `delta_prime_model` - `:mercier_power` (Benjamin Eq. 10) or `:constant` (Δ′(w) = Δ′_mn). + - `delta_prime_model` - `:mercier_power` (Benjamin Eq. 10), `:constant` (Δ′(w) = Δ′_mn), or `:callen` (constant, + Callen's Δ_c = K^(2α) Δ′_mn/ρ, or −2m/ρ with `:cylindrical`, in ψ_N; K = ρ dψ_N/dρ on the toroidal-flux radius ρ). - `delta_prime_factor`, `D_nc_factor`, `rho_theta_factor` - Multipliers on Δ′_mn, D_nc and the poloidal ion Larmor radius ρ_θi (which also sets w_ib), for scans. - `curvature_model` - `:lutjens` (default; Lütjens et al. 2001, [`lutjens_curvature_term`](@ref), whose small-w limit is @@ -45,7 +57,8 @@ from noise, so they get no seed threshold but keep `w_sat`. - `k1` - Bootstrap coefficient k₁, and curvature coefficient of `curvature_model = :benjamin`. - `c_wd` - Prefactor of the transport threshold width w_d. - `chi_perp_factor` - Multiplier on χ⊥,e (the kinetic `chi_e` profile, else its `chi_e_default`). - - `pol_model` - `:full` (Benjamin Eq. C.1) or `:reduced` (−c_pol w_pol²/w³, w_pol = C_pol w_ib). + - `pol_model` - `:full` (Benjamin Eq. C.1), `:reduced` (−c_pol w_pol²/w³, w_pol = C_pol w_ib) or `:callen` + (−c_pol w_pol²/w³ with Callen's w_pol, [`callen_polarization_width`](@ref)). - `c_pol`, `C_pol` - Polarization coefficients. - `pol_sign` - ±1, multiplies the polarization term. - `omega_model` - Island frequency ω in the E_r = 0 frame: `:electron_diamagnetic`, ω = ω_*e(1 + η_e/4) (Wilson @@ -54,14 +67,16 @@ from noise, so they get no seed threshold but keep `w_sat`. - `omega_ratio` - ω/ω_*i on every surface, replacing `omega_model`; `nothing` (default) uses the model. - `w_min`, `w_max`, `nw` - Log-spaced width grid in ψ_N. """ -@kwdef struct ModifiedRutherfordProblem{R,T} +@kwdef struct ModifiedRutherfordProblem{R,T,S} ffs::R tearing::T = nothing + pe::S = nothing classical::Bool = true bootstrap::Bool = true curvature::Bool = true polarization::Bool = false saturation::Bool = false + forcing::Bool = true saturation_window::Float64 = 0.02 curvature_model::Symbol = :lutjens delta_prime_source::Symbol = :computed @@ -86,6 +101,16 @@ end ModifiedRutherfordProblem(ffs; kwargs...) = ModifiedRutherfordProblem(; ffs=ffs, kwargs...) +""" + CALLEN_PRESET + +`ModifiedRutherfordProblem` keywords that reduce F to Callen's steady state Δ_c + Δ_RMP (w_v/w)² − w_pol²/w³: a +constant classical term in Callen's units, his polarization width, the forcing, and no other term. Keywords given with +the preset override it, e.g. `ModifiedRutherfordProblem(ffs; pe, CALLEN_PRESET..., delta_prime_source=:cylindrical)`. +""" +const CALLEN_PRESET = (; classical=true, bootstrap=false, curvature=false, saturation=false, polarization=true, forcing=true, + delta_prime_model=:callen, pol_model=:callen, c_pol=1.0, pol_sign=1.0) + """ ModifiedRutherfordResult @@ -120,6 +145,11 @@ surface × width. `w_m`, each `w_seed_all` and `w_seed_unsaturated` ([`vacuum_island_field`](@ref)), comparable with the perturbed equilibrium's resonant field and with error-field thresholds; `b_m_star` is the smallest `b_m` over the surfaces (not necessarily at the `w_m_star` surface). + - `b_vac`, `b_res`, `delta_rmp`, `w_v` - Vacuum and total resonant fields [T], Δ_RMP [1/m] and the full vacuum island + width in ψ_N from `pe` ([`resonant_forcing`](@ref)); `NaN` without it. + - `b_crit`, `b_trigger`, `w_crit` - Critical vacuum resonant fields [T] of the forcing, from the unforced F + ([`forcing_thresholds`](@ref)): a forced window opens at `b_crit` (at full width `w_crit`) and reaches the intrinsic + seed threshold at `b_trigger`; `NaN` without `pe`. `b_crit_star` is the smallest `b_crit` over the surfaces. - `fully_stabilized` - F < 0 on the whole search range. - `unstable_any_seed` - F > 0 at `w_lo`. - `unsaturated` - F > 0 at `w_max`: an island past its last rising root does not saturate in the search range. @@ -130,7 +160,8 @@ surface × width. - `large_delta_prime_w` - |Δ′(w)| w ≥ 1 at `w_m` or `w_sat`, with Δ′(w) the classical term. - `complex_delta_prime` - |Im Δ′_mn| > 10⁻³ |Re Δ′_mn|. - `saturation_unconverged` - `C_sat` not converged in the fit window (see [`saturation_coefficient`](@ref)). - - `F`, `classical`, `saturation`, `bootstrap`, `curvature`, `polarization`, `w_d` - F(w), each term, and w_d(w). + - `F`, `classical`, `saturation`, `bootstrap`, `curvature`, `polarization`, `forcing`, `w_d` - F(w), each term, and + w_d(w); `forcing` is 0 without `pe`. """ @kwdef struct ModifiedRutherfordResult{P} problem::P @@ -179,6 +210,14 @@ surface × width. b_seed_unsaturated::Vector{Float64} w_m_star::Float64 b_m_star::Float64 + b_vac::Vector{Float64} + b_res::Vector{Float64} + delta_rmp::Vector{Float64} + w_v::Vector{Float64} + b_crit::Vector{Float64} + b_trigger::Vector{Float64} + w_crit::Vector{Float64} + b_crit_star::Float64 fully_stabilized::Vector{Bool} unstable_any_seed::Vector{Bool} unsaturated::Vector{Bool} @@ -197,6 +236,7 @@ surface × width. bootstrap::Matrix{Float64} curvature::Matrix{Float64} polarization::Matrix{Float64} + forcing::Matrix{Float64} w_d::Matrix{Float64} end @@ -217,9 +257,14 @@ function validate(prob::ModifiedRutherfordProblem) throw(ArgumentError("ModifiedRutherfordProblem: the Δ′ matrix is $(size(dp.matrix, 1))×$(size(dp.matrix, 2)) for $(length(ffs.surfaces)) surfaces")) end ffs.equil.kinetic === nothing && throw(ArgumentError("ModifiedRutherfordProblem needs kinetic profiles; set kinetic_file in [Equilibrium]")) - prob.delta_prime_model in (:mercier_power, :constant) || throw(ArgumentError("delta_prime_model must be :mercier_power or :constant")) + prob.delta_prime_model in (:mercier_power, :constant, :callen) || throw(ArgumentError("delta_prime_model must be :mercier_power, :constant or :callen")) prob.curvature_model in (:lutjens, :benjamin) || throw(ArgumentError("curvature_model must be :lutjens or :benjamin")) - prob.pol_model in (:full, :reduced) || throw(ArgumentError("pol_model must be :full or :reduced")) + prob.pol_model in (:full, :reduced, :callen) || throw(ArgumentError("pol_model must be :full, :reduced or :callen")) + pe = prob.pe + if pe !== nothing + pffs = pe.response.ffs + pffs.npert == 1 && pffs.nlow == ffs.nlow || throw(ArgumentError("ModifiedRutherfordProblem: pe must be single-n with the n of ffs")) + end prob.omega_model in (:electron_diamagnetic, :exb) || throw(ArgumentError("omega_model must be :electron_diamagnetic or :exb")) abs(prob.pol_sign) == 1 || throw(ArgumentError("pol_sign must be +1 or -1")) 0 < prob.w_min < prob.w_max && prob.nw >= 2 || throw(ArgumentError("the width grid needs 0 < w_min < w_max and nw ≥ 2")) @@ -285,10 +330,11 @@ end # Per-surface scalars, flags and width rows of the result, in field order. const _MRE_SCALARS = (:rational_psi, :rational_q, :m, :n, :delta_prime, :delta_prime_imag, :D_I, :D_R, :H, :D_nc, :f_trap, :mu_ratio, :zeff, :Wc_prefac, :chi_perp, :v_te, :R0, :omega_star_i, :omega_star_e, :omega_hat, :omega_hat_pi, :beta_p, :rho_theta, :w_ib, :eps, :gamma, - :delta_r, :w_lo, :D_c, :W_d_layer, :alpha_s, :beta_s, :C_sat, :w_m, :w_sat, :w_seed_unsaturated, :n_sat, :b_m, :b_seed_unsaturated) + :delta_r, :w_lo, :D_c, :W_d_layer, :alpha_s, :beta_s, :C_sat, :w_m, :w_sat, :w_seed_unsaturated, :n_sat, :b_m, :b_seed_unsaturated, :b_vac, :b_res, :delta_rmp, :w_v, + :b_crit, :b_trigger, :w_crit) const _MRE_FLAGS = (:fully_stabilized, :unstable_any_seed, :unsaturated, :multiple_saturated, :outside_plasma, :gate_disagrees, :interchange_unstable, :linear_unstable, :below_w_ib, :large_delta_prime_w, :complex_delta_prime, :saturation_unconverged) -const _MRE_TERMS = (:F, :classical, :saturation, :bootstrap, :curvature, :polarization, :w_d) +const _MRE_TERMS = (:F, :classical, :saturation, :bootstrap, :curvature, :polarization, :forcing, :w_d) # SLAYER's toroidal critical offset D_c and the W_d it implies at one surface, with χ⊥ for every χ input. function _layer_critical_offset(equil, s, plasma, chi_perp) @@ -315,6 +361,7 @@ function _mre_context(prob::ModifiedRutherfordProblem) # Metres map to ψ_N with the toroidal-flux radial label's dr/dψ, the label SLAYER's layer widths use. r_at, dr_dpsi = radial_label(equil) return (; equil, plasma, data, psi_xs=collect(Float64, data.psi), usable, gate, r_at, dr_dpsi, J_z=current_density_profile(equil), + forcing=prob.pe === nothing ? nothing : resonant_forcing(prob.pe), psi_lim=(first(equil.profiles.xs), last(equil.profiles.xs)), chi_gate=gate === nothing || !hasproperty(gate.model, :chi_perp_Dc) ? nothing : gate.model.chi_perp_Dc) end @@ -364,33 +411,46 @@ function _surface_inputs(prob::ModifiedRutherfordProblem, ctx, k, s) x -> Float64(equil.profiles.q_deriv(x)), psi; window=prob.saturation_window, ctx.psi_lim) # SLAYER's toroidal critical offset at this surface with this χ⊥, so the curvature term meets the gate at small w. D_c, W_d_layer = prob.curvature_model === :lutjens ? _layer_critical_offset(equil, s, plasma, chi_perp) : (NaN, NaN) + # Callen's toroidal-flux radius (without the F/B₀R₀ factor of the layer label), his Δ_c and w_pol, in ψ_N. + rho, drho = toroidal_flux_radius(equil, psi, s.q) + delta_c = prob.delta_prime_source === :computed ? (rho / drho)^(2 * sqrt(max(-D_I, 0.0))) * real(dp_complex) / rho : -2 * m / rho + w_pol_callen = callen_polarization_width(equil, plasma, psi, s.q)[2] / drho + delta_prime = prob.delta_prime_factor * (prob.delta_prime_model === :callen ? delta_c * drho : real(dp_complex)) + # The coil forcing at this surface: Δ_RMP (dρ/dψ_N) w_v² is the coefficient of 1/w², proportional to B_vac. + rf = ctx.forcing + row = rf === nothing ? nothing : findfirst(p -> isapprox(p, psi; atol=1e-8), rf.rational_psi) + b_vac, b_res, delta_rmp, w_v = row === nothing ? (NaN, NaN, NaN, NaN) : (rf.b_vac[row], rf.b_res[row], rf.delta_rmp[row], 2 * rf.hw_v[row]) + forcing_amp = row === nothing ? 0.0 : delta_rmp * drho * w_v^2 return (; rational_psi=psi, rational_q=s.q, m, n, g, b_per_w2=vacuum_island_field(equil, psi, n, s.q1, 1.0), D_I, D_R, H=rg.H, D_nc=prob.D_nc_factor * D_nc, mu_ratio, zeff, chi_perp, v_te, omega_star_i, omega_star_e, omega_hat=something(prob.omega_ratio, prob.omega_factor * omega_model / omega_star_i), omega_hat_pi=1 + (dT_i / T_i) / (dn_i / n_i), beta_p=2 * MU_0 * n_i * E_CHG * T_i / g.avg_bpolsq, rho_theta, rho_theta_psi, w_ib=sqrt(g.eps) * rho_theta_psi, gradient_ratio=(dn_i / n_i) / (g.q1 / g.q), K, dp_complex, - delta_prime=prob.delta_prime_factor * real(dp_complex), sc, D_c, W_d_layer) + delta_prime, sc, D_c, W_d_layer, w_pol_callen, b_vac, b_res, delta_rmp, w_v, forcing_amp, has_forcing=row !== nothing) end # Each term and their switched sum F at the full width w, from a surface's inputs. function _mre_terms(prob::ModifiedRutherfordProblem, si, w) (; g, D_I, D_R, n, m) = si + model = prob.delta_prime_model === :callen ? :constant : prob.delta_prime_model wd = transport_width(w; prob.c_wd, si.chi_perp, si.v_te, R0=g.R_major, g.q, g.q1, n, m, g.Wc_prefac) - cl = D_I < 0 ? classical_term(si.delta_prime, D_I, w; model=prob.delta_prime_model) : NaN - st = D_I < 0 ? saturation_term(si.sc.C_sat, si.K, D_I, w; model=prob.delta_prime_model) : NaN + cl = D_I < 0 ? classical_term(si.delta_prime, D_I, w; model) : NaN + st = D_I < 0 ? saturation_term(si.sc.C_sat, si.K, D_I, w; model) : NaN bs = bootstrap_term(si.D_nc, wd, w; prob.k1) cv = D_I >= 0 ? NaN : - prob.curvature_model === :lutjens ? lutjens_curvature_term(D_R, D_I, si.K, si.W_d_layer, w; model=prob.delta_prime_model) : + prob.curvature_model === :lutjens ? lutjens_curvature_term(D_R, D_I, si.K, si.W_d_layer, w; model) : curvature_term(D_R, si.H, D_I, wd, w; prob.k1) pl = prob.pol_sign * ( prob.pol_model === :full ? polarization_term(w; prob.c_pol, g.eps, si.rho_theta_psi, si.beta_p, si.gradient_ratio, si.omega_hat, si.omega_hat_pi, si.w_ib) : - reduced_polarization_term(w; prob.c_pol, prob.C_pol, si.w_ib) + prob.pol_model === :reduced ? reduced_polarization_term(w; prob.c_pol, prob.C_pol, si.w_ib) : + reduced_polarization_term(w; prob.c_pol, C_pol=1.0, w_ib=si.w_pol_callen) ) - F = prob.classical * cl + prob.saturation * st + prob.bootstrap * bs + prob.curvature * cv + prob.polarization * pl - return (; F, classical=cl, saturation=st, bootstrap=bs, curvature=cv, polarization=pl, w_d=wd) + fc = si.forcing_amp / w^2 + F0 = prob.classical * cl + prob.saturation * st + prob.bootstrap * bs + prob.curvature * cv + prob.polarization * pl + return (; F=F0 + prob.forcing * fc, F0, classical=cl, saturation=st, bootstrap=bs, curvature=cv, polarization=pl, forcing=fc, w_d=wd) end # The linear tearing growth rate and resistive layer width in ψ_N from `tearing`, and the bottom of the root search. @@ -416,7 +476,12 @@ function _surface_roots(prob::ModifiedRutherfordProblem, ctx, si, gt, w) psi = si.rational_psi # Resonant field of the vacuum island as wide as each seed threshold; B ∝ w². b(x) = si.b_per_w2 * x^2 - return (; w_m, w_sat, w_sat_all, w_seed_all, w_seed_unsaturated, n_sat=length(w_sat_all), b_m=b(w_m), b_seed_all=b.(w_seed_all), + # Critical fields of the forcing from the unforced F; the forcing is (forcing_amp/b_vac) B_vac/w². + F0(x) = _mre_terms(prob, si, x).F0 + th = + excluded || !si.has_forcing ? (; b_crit=NaN, b_trigger=NaN, w_crit=NaN) : + forcing_thresholds(F0, si.forcing_amp / si.b_vac, gt.w_lo, w, mre_roots(F0, gt.w_lo, w).w_m) + return (; th..., w_m, w_sat, w_sat_all, w_seed_all, w_seed_unsaturated, n_sat=length(w_sat_all), b_m=b(w_m), b_seed_all=b.(w_seed_all), b_seed_unsaturated=b(w_seed_unsaturated), multiple_saturated=length(w_sat_all) > 1, fully_stabilized=!excluded && r.fully_stabilized, unstable_any_seed=!excluded && r.unstable_any_seed, unsaturated=!excluded && r.unsaturated, outside_plasma=isfinite(w_sat) && (psi - w_sat / 2 < ctx.psi_lim[1] || psi + w_sat / 2 > ctx.psi_lim[2]), @@ -445,7 +510,8 @@ function CommonSolve.solve(prob::ModifiedRutherfordProblem) nbranch = maximum((r.n_sat for r in rows); init=1) branches(key) = [k <= length(getfield(r, key)) ? getfield(r, key)[k] : NaN for r in rows, k in 1:nbranch] w_m = column(:w_m) - return ModifiedRutherfordResult(; problem=prob, w=collect(w), (key => column(key) for key in _MRE_SCALARS)..., + finite_min(v) = all(isnan, v) ? NaN : minimum(filter(!isnan, v)) + return ModifiedRutherfordResult(; problem=prob, w=collect(w), (key => column(key) for key in _MRE_SCALARS)..., b_crit_star=finite_min(column(:b_crit)), w_sat_all=branches(:w_sat_all), w_seed_all=branches(:w_seed_all), b_seed_all=branches(:b_seed_all), b_m_star=all(isnan, column(:b_m)) ? NaN : minimum(filter(!isnan, column(:b_m))), w_m_star=all(isnan, w_m) ? NaN : minimum(filter(!isnan, w_m)), (key => Vector{Bool}(column(key)) for key in _MRE_FLAGS)..., diff --git a/src/Tearing/ResonantForcing.jl b/src/Tearing/ResonantForcing.jl new file mode 100644 index 000000000..4b7e032d4 --- /dev/null +++ b/src/Tearing/ResonantForcing.jl @@ -0,0 +1,151 @@ +# The resonant forcing of a single-n coil-driven perturbed equilibrium, per rational surface, in the form a steady island +# equation takes it (Callen, UW-CPTC 16-4, 2016): the vacuum and total resonant fields, Δ_RMP and the vacuum island width, +# on the toroidal-flux radius ρ that Callen's widths use. The Callen threshold and the modified Rutherford equation share it. + +module ResonantForcing + +import FastInterpolations + +import ...ForcingTerms +using ...Utilities.PhysicalConstants: M_P, E_CHG + +export toroidal_flux_radius, callen_polarization_width, resonant_forcing, forcing_thresholds + +""" + toroidal_flux_radius(equil, psi, q) -> (rho, drho) + +Toroidal-flux radius ρ = (Φ_tor/πB₀)^(1/2) [m] at `psi`, with Φ_tor = 2πψ₀ ∫q dψ_N and B₀ the vacuum field `bt0`, and +dρ/dψ_N = qψ₀/(B₀ρ) [m] with the surface's safety factor `q`. +""" +function toroidal_flux_radius(equil, psi::Real, q::Real) + B0 = equil.params.bt0 + rho = sqrt(FastInterpolations.integrate(equil.profiles.q_spline, first(equil.profiles.xs), psi) * 2 * equil.psio / B0) + return rho, q * equil.psio / (B0 * rho) +end + +""" + callen_polarization_width(equil, plasma, psi, q) -> (rho_i, w_pol) + +Ion gyroradius ρ_i = (2T_i/m_i)^(1/2)/(Z_i e B₀/m_i) and Callen's polarization width w_pol = ½ q ρ_i/(ρ/R₀)^(1/2), both in +metres, from a `layer_plasma` view of the kinetic data; R₀ is the axis major radius and B₀ the vacuum field `bt0`. +""" +function callen_polarization_width(equil, plasma, psi::Real, q::Real) + m_i, z_i, B0 = plasma.mu_i * M_P, plasma.z_i, equil.params.bt0 + rho_i = sqrt(2 * E_CHG * Float64(plasma.profiles.T_i(psi)) / m_i) / (z_i * E_CHG * B0 / m_i) + rho, _ = toroidal_flux_radius(equil, psi, q) + return rho_i, 0.5 * q * rho_i / sqrt(rho / equil.ro) +end + +# Vacuum resonant field |B_vac| [T] of the coil forcing on the surface ψ, harmonic m. +function _vacuum_resonant_field(pe, equil, psi, n, m, area) + rmp = pe.forcing + rmp isa ForcingTerms.RMPSource && rmp.ctrl.forcing_data_format == "coil" || + throw(ArgumentError("the resonant forcing needs a single coil forcing (forcing_data_format = \"coil\") for the vacuum resonant field")) + cfg = ForcingTerms.CoilConfig(rmp.ctrl) + grid = ForcingTerms.CoilForcingGrid(equil, cfg, n; psi) + amplitude = only(ForcingTerms.coil_forcing_modes(pe.coil_sets, grid, n, m, m)).amplitude + return abs(rmp.scale * amplitude) / area +end + +""" + resonant_forcing(pe) -> NamedTuple of vectors + +The resonant forcing on every resonant row of the single-n, coil-driven perturbed equilibrium `pe`: + + - `rational_psi`, `rational_q`, `m`, `n`, `surface_idx` - Surface identity; `surface_idx` indexes `pe.response.ffs.surfaces`. + - `rho`, `drho` - Toroidal-flux radius and dρ/dψ_N [m] ([`toroidal_flux_radius`](@ref)). + - `b_res`, `b_vac` - |total| and |vacuum| resonant area-weighted field [T]. + - `delta_rmp` - Δ_RMP = |Δ|/(2πR₀q) B₀/|B_res| [1/m of ρ], Δ the singular-current jump over 2πχ₁. + - `hw_isl`, `hw_v` - Total-field and vacuum island half-widths in ψ_N, hw_v = (4 B_vac A/(2π n q′ χ₁))^(1/2). + +In ψ_N the forcing of a full width w is then Δ_RMP (dρ/dψ_N) (2 hw_v/w)², which scales with B_vac. +""" +function resonant_forcing(pe) + resp = pe.response + ffs = resp.ffs + equil = ffs.equil + ffs.npert == 1 || throw(ArgumentError("the resonant forcing is single-n; the solve spans n = $(ffs.nlow):$(ffs.nhigh)")) + isempty(pe.island_half_width) && throw(ArgumentError("the resonant forcing needs the singular coupling of the perturbed equilibrium")) + R0, B0, chi1 = equil.ro, equil.params.bt0, 2π * equil.psio + nrow = length(resp.rational_psi) + rows = map(1:nrow) do row + psi, q, m, n, area = resp.rational_psi[row], resp.rational_q[row], resp.rational_m_res[row], resp.rational_n[row], resp.rational_area[row] + idx = resp.rational_surface_idx[row] + q1 = ffs.surfaces[idx].q1 + rho, drho = toroidal_flux_radius(equil, psi, q) + b_res = abs(pe.resonant_area_weighted_field[row]) + b_vac = _vacuum_resonant_field(pe, equil, psi, n, m, area) + return (; rational_psi=psi, rational_q=q, m, n, surface_idx=idx, rho, drho, b_res, b_vac, delta_rmp=abs(pe.delta_prime[row]) / (2π * R0 * q) * B0 / b_res, + hw_isl=pe.island_half_width[row], hw_v=sqrt(abs(4 * b_vac * area / (2π * n * q1 * chi1)))) + end + keys = (:rational_psi, :rational_q, :m, :n, :surface_idx, :rho, :drho, :b_res, :b_vac, :delta_rmp, :hw_isl, :hw_v) + return NamedTuple{keys}(Tuple([getfield(r, k) for r in rows] for k in keys)) +end + +# Golden-section extremum of f on [a, b]: the minimum, or with `sign = -1` the maximum. +function _golden(f, a, b; sign=1, tol=1e-10) + phi = (sqrt(5) - 1) / 2 + c, d = b - phi * (b - a), a + phi * (b - a) + fc, fd = sign * f(c), sign * f(d) + while b - a > tol * (abs(a) + abs(b)) + if fc < fd + b, d, fd = d, c, fc + c = b - phi * (b - a) + fc = sign * f(c) + else + a, c, fc = c, d, fd + d = a + phi * (b - a) + fd = sign * f(d) + end + end + x = (a + b) / 2 + return x, f(x) +end + +""" + forcing_thresholds(F0, k, w_lo, w_grid, w_m0) -> (; b_crit, b_trigger, w_crit) + +Critical fields of a forcing k B_vac/w² added to an unforced steady island equation `F0(w)`, with the island locked in +phase with the field (Callen's assumption). The forced F is positive where k B_vac > G(w) = −F0(w) w², so: + + - `b_crit` - The vacuum field at which a forced window F > 0 first opens below the intrinsic seed threshold `w_m0` + (anywhere on the grid when `w_m0` is `NaN`), apart from the intrinsic region: the smallest local minimum of G, + `w_lo` included, at width `w_crit`. For Callen's F0 it is his B_crit. It is 0 when F0 > 0 at `w_lo`, or when G + falls all the way to `w_m0` (any field lowers the seed threshold); `NaN` when it falls all the way to the top of + the grid without a `w_m0`. Without a term that beats 1/w² at small w (Callen's −w_pol²/w³ does), G rises from + `w_lo`, the window opens there (`w_crit == w_lo`) and `b_crit` depends on `w_lo`, the layer width when the gate + sets it. + - `b_trigger` - The field at which the window merges with the region above `w_m0`, so that an island in it grows + past `w_m0` towards the intrinsic saturated width: the maximum of G on `[w_crit, w_m0]` (on `[w_lo, w_m0]` + without a window, the field at which no seed is needed). It is 0 when F0 > 0 at `w_lo`, and `NaN` without `w_m0`. + +G is sampled on `w_lo` and the grid points above it, and each interior extremum is refined by golden-section search. +""" +function forcing_thresholds(F0, k::Real, w_lo::Real, w_grid::AbstractVector, w_m0::Real) + G(w) = -F0(w) * w^2 + none = (; b_crit=NaN, b_trigger=NaN, w_crit=NaN) + (k > 0 && isfinite(k)) || return none + top = isfinite(w_m0) ? w_m0 : maximum(w_grid) + nodes = vcat(Float64(w_lo), filter(w -> w_lo < w < top, w_grid), top) + vals = G.(nodes) + any(!isfinite, vals) && return none + vals[1] < 0 && return (; b_crit=0.0, b_trigger=0.0, w_crit=NaN) + n = length(nodes) + interior = [_golden(G, nodes[j-1], nodes[j+1]) for j in 2:(n-1) if vals[j] <= vals[j-1] && vals[j] <= vals[j+1]] + minima = n > 1 && vals[1] <= vals[2] ? vcat([(nodes[1], vals[1])], interior) : interior + if isempty(minima) + isfinite(w_m0) || return none + w_crit, b_crit = NaN, 0.0 + else + w_crit, g_crit = minima[argmin(last.(minima))] + b_crit = g_crit / k + isfinite(w_m0) || return (; b_crit, b_trigger=NaN, w_crit) + end + # The highest barrier of G between the window (or w_lo) and the intrinsic seed threshold. + start = isnan(w_crit) ? nodes[1] : w_crit + j = argmax([w >= start ? v : -Inf for (w, v) in zip(nodes, vals)]) + g_max = 1 < j < n ? last(_golden(G, max(nodes[j-1], start), nodes[j+1]; sign=-1)) : vals[j] + return (; b_crit, b_trigger=max(g_max, vals[j]) / k, w_crit) +end + +end # module ResonantForcing diff --git a/src/Tearing/Tearing.jl b/src/Tearing/Tearing.jl index b9b10f383..9b74811c0 100644 --- a/src/Tearing/Tearing.jl +++ b/src/Tearing/Tearing.jl @@ -42,6 +42,7 @@ using .InnerLayer: InnerLayerParameters, InnerLayerResponse, solve_inner, layer_ include("LayerOverlap.jl") include("Dispersion/Dispersion.jl") include("CriticalResonantField/CriticalResonantField.jl") +include("ResonantForcing.jl") include("ModifiedRutherford/ModifiedRutherford.jl") include("Callen/Callen.jl") @@ -49,6 +50,7 @@ import .Dispersion as Dispersion import .CriticalResonantField as CriticalResonantField import .ModifiedRutherford as ModifiedRutherford import .Callen as Callen +import .ResonantForcing as ResonantForcing using .ModifiedRutherford: ModifiedRutherfordProblem, ModifiedRutherfordResult using .Callen: CallenProblem, CallenResult using .Dispersion: SurfaceCoupling, surface_coupling, diff --git a/test/runtests_modified_rutherford.jl b/test/runtests_modified_rutherford.jl index cf8c55fed..f6b24922b 100644 --- a/test/runtests_modified_rutherford.jl +++ b/test/runtests_modified_rutherford.jl @@ -136,6 +136,35 @@ using TOML @test w_sat ≈ 2.24 rtol = 0.1 end + @testset "forcing thresholds: Callen's B_crit, and a forced window below an intrinsic seed" begin + RF = GeneralizedPerturbedEquilibrium.Tearing.ResonantForcing + grid = exp.(range(log(1e-5), log(0.2); length=200)) + # Callen's F0 = Δ_c + c/w³: the window opens where the cubic's roots merge, b = 3|a| (c/2a)^(2/3). + a, c = -40.0, -(1.5e-2)^2 + th = RF.forcing_thresholds(w -> a + c / w^3, 2.0, 1e-5, grid, NaN) + @test th.b_crit ≈ 3 * abs(a) * (c / (2a))^(2 / 3) / 2 rtol = 1e-8 + @test th.w_crit ≈ cbrt(c / (2a)) rtol = 1e-6 + @test isnan(th.b_trigger) + # G = −F0 w² with a local minimum near 0.01 and a barrier before the intrinsic seed threshold 0.03. + G(w) = (0.03 - w) * ((w - 0.01)^2 + 1e-5) + th = RF.forcing_thresholds(w -> -G(w) / w^2, 2.0, 1e-5, grid, 0.03) + fine = range(1e-5, 0.03; length=100_001) + @test th.b_crit ≈ minimum(G, filter(<(0.02), fine)) / 2 rtol = 1e-6 + @test th.b_trigger ≈ maximum(G, filter(>=(th.w_crit), fine)) / 2 rtol = 1e-6 + @test th.w_crit < 0.03 + # G rising from w_lo opens the window there; G falling all the way to the seed threshold has none, so any field + # lowers it and b_trigger is the field that needs no seed. F0 > 0 at w_lo: no seed is needed. + th = RF.forcing_thresholds(w -> (w - 0.03) / w, 2.0, 1e-5, grid, 0.03) + @test th.w_crit == 1e-5 + @test th.b_crit ≈ 1e-5 * (0.03 - 1e-5) / 2 rtol = 1e-12 + @test th.b_trigger ≈ 0.015^2 / 2 rtol = 1e-8 + th = RF.forcing_thresholds(w -> (w - 0.03) / w^3, 2.0, 1e-5, grid, 0.03) + @test th.b_crit == 0 && isnan(th.w_crit) + @test th.b_trigger ≈ (0.03 - 1e-5) / 1e-5 / 2 rtol = 1e-12 + @test RF.forcing_thresholds(w -> 1 / w, 2.0, 1e-5, grid, NaN).b_trigger == 0 + @test isnan(RF.forcing_thresholds(w -> -1 / w, 0.0, 1e-5, grid, NaN).b_crit) + end + @testset "surface geometry matches Fortran RDCON on the DIII-D-like example" begin # Fortran values at q = 2 (ψ_N = 0.5183), interpolated from rdcon_output_n1.nc with MRE_flag = t and Z_eff = 1.52. dir = joinpath(@__DIR__, "..", "examples", "DIIID-like_gal_resistive_example") diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index a2d0aef9b..d46e11bdb 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -642,26 +642,33 @@ include("display_check.jl") end end - # A forward solve has no Δ′, so the deck stops at parse time. + # A forward solve without vac_flag has no Δ′ and allows no Riccati solve, so the deck stops at parse time. mktempdir() do dir - deck_dir = _stage_deck(dir; extra_sections=Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => Dict{String,Any}()))) + deck_dir = _stage_deck(dir; ffs_overrides=Dict{String,Any}("vac_flag" => false), + extra_sections=Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => Dict{String,Any}()))) @test_throws "needs Δ′" GPEC.parse_deck(TOML.parsefile(joinpath(deck_dir, "gpec.toml")), deck_dir) end # omega_ratio is the one optional (nothing-default) key: a deck value reaches the problem as a Float64. @test GPEC.modified_rutherford_from_deck(Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => Dict{String,Any}("omega_ratio" => 1))))[:omega_ratio] == 1.0 + # preset = "callen" expands under the section's own keys. + preset = GPEC.modified_rutherford_from_deck(Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => + Dict{String,Any}("preset" => "callen", "nw" => 50)))) + @test preset[:pol_model] === :callen && preset[:delta_prime_model] === :callen && preset[:nw] == 50 && !haskey(preset, :preset) + @test_throws "preset" GPEC.modified_rutherford_from_deck(Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => + Dict{String,Any}("preset" => "other")))) end @testset "Callen threshold under [Tearing.Callen]" begin - # The coil-driven DIII-D-like example: Callen with the computed Δ′ (main adds a Riccati solve) and the - # cylindrical MRE on the same forward solve, against the API. + # The coil-driven DIII-D-like example: Callen and the MRE's Callen preset on one shared Riccati Δ′ solve, with + # the coil forcing, against the API. + MRE = GeneralizedPerturbedEquilibrium.Tearing.ModifiedRutherford ex_dir = joinpath(@__DIR__, "..", "examples", "DIIID-like_ideal_example") mktempdir() do dir deck = TOML.parsefile(joinpath(ex_dir, "gpec.toml")) foreach(k -> delete!(deck, k), ("LocalStability", "Ballooning", "ErrorFields", "KineticForces")) deck["Run"]["output_file"] = joinpath(dir, "gpec.h5") - deck["Tearing"] = Dict{String,Any}("Callen" => Dict{String,Any}(), - "ModifiedRutherford" => Dict{String,Any}("delta_prime_source" => "cylindrical", "saturation" => true, "nw" => 40)) + deck["Tearing"] = Dict{String,Any}("Callen" => Dict{String,Any}(), "ModifiedRutherford" => Dict{String,Any}("preset" => "callen", "nw" => 400)) for key in ("eq_filename", "kinetic_file") deck["Equilibrium"][key] = abspath(joinpath(ex_dir, deck["Equilibrium"][key])) end @@ -671,6 +678,7 @@ include("display_check.jl") ric = reference.callen.problem.delta_prime @test ric isa ForceFreeStatesResult + @test reference.modified_rutherford.problem.ffs === ric api = solve(CallenProblem(reference.pe; delta_prime=ric)) @test api.delta_c ≈ reference.callen.delta_c rtol = 1e-10 @test api.hw_v_crit ≈ reference.callen.hw_v_crit rtol = 1e-10 @@ -678,16 +686,47 @@ include("display_check.jl") @test !(solve(CallenProblem(reference.pe; delta_prime_source=:cylindrical)).delta_c ≈ api.delta_c) @test_throws "needs delta_prime" solve(CallenProblem(reference.pe)) - mre = solve(ModifiedRutherfordProblem(reference.ffs; delta_prime_source=:cylindrical, saturation=true, nw=40)) + # The preset is Callen's equation: its roots are his full widths and its b_crit his B_crit. On the shared Riccati + # solve they agree to the few 1e-7 by which its surfaces' q differs from the forward solve's. + mre = solve(ModifiedRutherfordProblem(ric; pe=reference.pe, MRE.CALLEN_PRESET..., nw=400)) @test mre.F ≈ reference.modified_rutherford.F rtol = 1e-10 - @test isequal(mre.w_m, reference.modified_rutherford.w_m) - @test all(<(0), mre.delta_prime) - @test all(isfinite, mre.C_sat) + row(res, psi) = findfirst(p -> isapprox(p, psi; atol=1e-6), res.rational_psi) + @test [mre.b_crit[row(mre, psi)] for psi in api.rational_psi] ≈ api.b_crit rtol = 1e-6 + # On the forward solve's own surfaces, with Callen's cylindrical Δ_c and the coils scaled past the largest critical + # field so every surface has both roots, they agree to the root tolerance. + cyl0 = solve(CallenProblem(reference.pe; delta_prime_source=:cylindrical)) + strong = perturbed_equilibrium(reference.pe.response, 4 * maximum(cyl0.b_crit ./ cyl0.b_vac) * reference.pe.forcing; + coil_sets=reference.pe.coil_sets, reference.pe.regularization_width) + cyl = solve(CallenProblem(strong; delta_prime_source=:cylindrical)) + pre = solve(ModifiedRutherfordProblem(reference.ffs; pe=strong, MRE.CALLEN_PRESET..., delta_prime_source=:cylindrical, nw=400)) + @test cyl.b_crit ≈ cyl0.b_crit rtol = 1e-8 + @test all(==(2), cyl.n_roots) + compared = 0 + for k in eachindex(cyl.rational_psi) + s = row(pre, cyl.rational_psi[k]) + @test pre.delta_rmp[s] ≈ cyl.delta_rmp[k] rtol = 1e-12 + @test pre.w_v[s] ≈ 2 * cyl.hw_v[k] rtol = 1e-12 + @test pre.b_crit[s] ≈ cyl.b_crit[k] rtol = 1e-8 + cyl.n_roots[k] == 2 && 2 * cyl.hw_sat[k] < pre.w[end] && 2 * cyl.hw_min[k] > pre.w_lo[s] || continue + @test pre.w_m[s] ≈ 2 * cyl.hw_min[k] rtol = 1e-8 + @test pre.w_sat[s] ≈ 2 * cyl.hw_sat[k] rtol = 1e-8 + compared += 1 + end + @test compared > 0 + # The general MRE takes the same forcing: F gains exactly the forcing term, which scales as (w_v/w)². + kw = (; delta_prime_source=:cylindrical, saturation=true, nw=40) + forced = solve(ModifiedRutherfordProblem(ric; pe=reference.pe, kw...)) + free = solve(ModifiedRutherfordProblem(ric; pe=reference.pe, forcing=false, kw...)) + @test forced.F ≈ free.F .+ forced.forcing rtol = 1e-12 + @test forced.forcing .* forced.w' .^ 2 ≈ repeat(forced.forcing[:, 1] .* forced.w[1]^2, 1, 40) rtol = 1e-10 + @test all(>(0), forced.forcing) && isequal(forced.b_crit, free.b_crit) + @test all(isnan, solve(ModifiedRutherfordProblem(ric; kw...)).b_crit) h5open(joinpath(dir, "gpec.h5")) do f @test read(f["Tearing/Callen/PerSurface/delta_c"]) ≈ api.delta_c rtol = 1e-10 + @test isequal(read(f["Tearing/ModifiedRutherford/PerSurface/b_crit"]), reference.modified_rutherford.b_crit) + @test read(f["Tearing/ModifiedRutherford/Terms/forcing"]) ≈ reference.modified_rutherford.forcing recorded = TOML.parse(read(f["Input/gpec_toml_raw"]))["Tearing"] - @test haskey(recorded, "Callen") && recorded["ModifiedRutherford"]["delta_prime_source"] == "cylindrical" - @test recorded["ModifiedRutherford"]["saturation"] && read(f["Tearing/ModifiedRutherford/PerSurface/C_sat"]) ≈ mre.C_sat + @test haskey(recorded, "Callen") && recorded["ModifiedRutherford"]["pol_model"] == "callen" end end From 862b2f6b2d8506759410242219e52444993606d8 Mon Sep 17 00:00:00 2001 From: logan-nc Date: Sun, 8 Nov 2026 17:57:13 -0500 Subject: [PATCH 26/26] Tearing - REFACTOR! - Retire CallenProblem; the modified Rutherford Callen preset replaces it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MRE's preset = "callen" reproduces Callen's cubic roots and critical field on the same resonant forcing, so the closed-form CallenProblem, its [Tearing.Callen] deck section and its Tearing/Callen/PerSurface output are removed; a deck with [Tearing.Callen] now stops at parse time. The closed form stays as the test reference for the preset, evaluated from resonant_forcing and Callen's polarization width, and the Callen 2016 Fig. 8 check now runs through the MRE root finder. main runs its Riccati Δ′ solve for the MRE alone, and the diiid_n1_callen harness case tracks the preset's quantities. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BgNxCBwLNHQmVq7iXMCTae --- docs/development/hdf5-conventions.md | 4 +- docs/src/inner_layer.md | 19 +-- regression-harness/cases/diiid_n1_callen.toml | 45 +----- src/Deck.jl | 32 +--- src/GeneralizedPerturbedEquilibrium.jl | 23 +-- src/Tearing/Callen/Callen.jl | 20 --- src/Tearing/Callen/HDF5Output.jl | 47 ------ src/Tearing/Callen/Problem.jl | 152 ------------------ src/Tearing/HDF5Output.jl | 4 +- src/Tearing/ModifiedRutherford/Problem.jl | 3 +- src/Tearing/ResonantForcing.jl | 2 +- src/Tearing/Tearing.jl | 7 +- test/runtests_modified_rutherford.jl | 24 ++- test/runtests_result_struct.jl | 4 +- test/runtests_solve_api.jl | 91 ++++++----- 15 files changed, 93 insertions(+), 384 deletions(-) delete mode 100644 src/Tearing/Callen/Callen.jl delete mode 100644 src/Tearing/Callen/HDF5Output.jl delete mode 100644 src/Tearing/Callen/Problem.jl diff --git a/docs/development/hdf5-conventions.md b/docs/development/hdf5-conventions.md index dddde30ec..eb7246d4d 100644 --- a/docs/development/hdf5-conventions.md +++ b/docs/development/hdf5-conventions.md @@ -46,7 +46,7 @@ Top level (11 groups): | `PerturbedEquilibrium/` | `ForcingModes/`, `Response/`, `ResponseMatrices/`, `SingularCoupling/`, `Energies/`, `TorqueResponse/` (kinetic forward solves: ψ-resolved `T_xe`, `T_source`), control-surface spectra | | `KineticForces/` | `/` (torque/energy profiles, `dTdpsi_ell` resolved by bounce harmonic on the `ell` scale, `KineticMatrices/`); multi-ion runs add `PerSpecies///` with the same per-method layout, summing to the top-level total | | `ErrorFields/` | `CoilSensitivities/` (per-coil-set control-surface spectra and their rigid shift/tilt derivatives, `DominantMode/` full-window projection); `MonteCarlo/` (intrinsic and corrected `\|δ\|` histograms over the sampled tolerances, per batch and averaged); `Risk/` (threshold density, `P(lock\|δ)`, locking probabilities, `ToleranceScan/`); `NTV/` (correction-coil overlap and NTV torque couplings per kAt) | -| `Tearing/` | `PerSurface/` (+ `DpMatrix/`), `Roots/`, `LayerWidths/`, `Diagnostics/{ValidRoots,Poles,FilteredRoots}` (+ `*Winding` with `root_classification = winding`), `Scan/Surface_/`, `CriticalResonantField/` (+ `Scan/Surface_/`), `ModifiedRutherford/` (`w`, `w_m_star`, `b_m_star`, `b_crit_star`, `PerSurface/` with branch × surface `w_sat_all`-type arrays, `Terms/` surface × width), `Callen/PerSurface/` | +| `Tearing/` | `PerSurface/` (+ `DpMatrix/`), `Roots/`, `LayerWidths/`, `Diagnostics/{ValidRoots,Poles,FilteredRoots}` (+ `*Winding` with `root_classification = winding`), `Scan/Surface_/`, `CriticalResonantField/` (+ `Scan/Surface_/`), `ModifiedRutherford/` (`w`, `w_m_star`, `b_m_star`, `b_crit_star`, `PerSurface/` with branch × surface `w_sat_all`-type arrays, `Terms/` surface × width) | | `SurfaceGeometries/` | `{Plasma,Wall}/{x,y,z}` point clouds | Reserved (documented, not yet written): `ForceFreeStates/Solutions/RiccatiIntegration/` — the third integrator backend slot alongside `ForwardIntegration` and `GalerkinIntegration`. @@ -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/Utilities/HDF5Schema.jl` for the main writer; alongside `write_galerkin!`, the PerturbedEquilibrium writer, `KineticForces/Output.jl`, and `Tearing/HDF5Output.jl`, `Tearing/ModifiedRutherford/HDF5Output.jl` and `Tearing/Callen/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`, `Tearing/HDF5Output.jl` and `Tearing/ModifiedRutherford/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/src/inner_layer.md b/docs/src/inner_layer.md index efe4d4728..71e852ff0 100644 --- a/docs/src/inner_layer.md +++ b/docs/src/inner_layer.md @@ -109,7 +109,6 @@ The DIII-D-like kinetic file of the examples carries analytic χ profiles of the | `PerSurface/b_m`, `PerSurface/b_seed_all`, `PerSurface/b_seed_unsaturated`, `b_m_star` | the resonant area-weighted field [T] whose vacuum island is as wide as each seed threshold, ``B = \pi |n q'| \chi_1 w^2/(8A)`` (the inverse of the perturbed equilibrium's island width), to compare with `PerturbedEquilibrium` resonant fields and error-field thresholds | | `PerSurface/b_vac`, `b_res`, `delta_rmp`, `w_v`, `Terms/forcing` | with a perturbed equilibrium, the vacuum and total resonant fields, Callen's ``\Delta'_\mathrm{RMP}``, the vacuum island width and the forcing term per width | | `PerSurface/b_crit`, `b_trigger`, `w_crit`, `b_crit_star` | the vacuum resonant fields [T] at which a forced window opens below the unforced seed threshold (at width `w_crit`) and at which it reaches that threshold, and the smallest `b_crit` over the surfaces | -| `Tearing/Callen/PerSurface/` | Callen's critical vacuum island width, critical resonant field and cubic roots per resonant row | ## Modified Rutherford diagnostics @@ -132,18 +131,16 @@ The island frequency in ``\Delta_\mathrm{pol}`` is ``\omega_{*e}(1+\eta_e/4)`` ( ### Applied-field forcing and critical fields With a coil-driven `[PerturbedEquilibrium]` in the same deck (or `ModifiedRutherfordProblem(ffs; pe)` in a script), the applied field forces the island through Callen's term ``\Delta_\mathrm{RMP}(w) = \Delta'_\mathrm{RMP}(d\rho/d\psi_N)(w_v/w)^2`` (Callen 2016, Eq. 17), with ``w_v`` the vacuum island width and the island locked in phase with the field; `forcing = false` switches it off. The roots and branches are then those of the forced ``F``. -The perturbed equilibrium needs a forward solve, so `main` adds one Riccati solve for ``\Delta'`` and shares it between the MRE and `[Tearing.Callen]`; the forward solve then needs `vac_flag = true`. +The perturbed equilibrium needs a forward solve, so `main` adds one Riccati solve for the MRE's ``\Delta'``; the forward solve then needs `vac_flag = true`. Since the forcing is proportional to the vacuum resonant field ``B_\mathrm{vac}``, the forced ``F`` is positive where ``k B_\mathrm{vac} > G(w) = -F_0(w)w^2``, with ``F_0`` the unforced sum and ``k = \Delta'_\mathrm{RMP}(d\rho/d\psi_N)w_v^2/B_\mathrm{vac}``. `b_crit` is the field at which a forced window first opens below the unforced seed threshold (the smallest local minimum of ``G``), and `b_trigger` the field at which that window reaches the unforced seed threshold, so that a forced island grows on to the intrinsic saturated width (the largest ``G`` between the window and that threshold). Both are 0 on a surface that is unstable at ``w_\mathrm{lo}`` without forcing. Callen's ``-w_\mathrm{pol}^2/w^3`` beats the forcing at small widths and so gives a window away from ``w_\mathrm{lo}``; without such a term ``G`` rises from ``w_\mathrm{lo}``, the window opens there (`w_crit` equals `w_lo`), and `b_crit` depends on ``w_\mathrm{lo}``, which the `[Tearing]` gate sets to the resistive layer width. `b_trigger` does not. -`preset = "callen"` (`CALLEN_PRESET` in a script) reduces ``F`` to Callen's steady state ``\Delta_c + \Delta'_\mathrm{RMP}(w_v/w)^2 - w_\mathrm{pol}^2/w^3`` in his units (`delta_prime_model = "callen"`, `pol_model = "callen"`, the other terms off): its roots are then the full widths of `[Tearing.Callen]`'s cubic roots, and its `b_crit` is Callen's critical field. - -`[Tearing.Callen]` evaluates Callen's reduced equation for RMP-driven islands on a single-``n`` perturbed equilibrium driven by coils, in the form of Fortran GPEC: the critical vacuum island half-width, the critical vacuum resonant field and the half-widths of the two positive roots of the steady cubic ``\Delta_c w^3 + \Delta'_\mathrm{RMP}w_\mathrm{vac}^2w - w_\mathrm{pol}^2 = 0`` (Callen 2016, Eq. 17), which is posed in the full width ``w`` with ``w_\mathrm{vac} = 2h_v``. +`preset = "callen"` (`CALLEN_PRESET` in a script) reduces ``F`` to Callen's reduced equation for RMP-driven islands, the steady state ``\Delta_c + \Delta'_\mathrm{RMP}(w_v/w)^2 - w_\mathrm{pol}^2/w^3`` in his units and in the form of Fortran GPEC (`delta_prime_model = "callen"`, `pol_model = "callen"`, the other terms off). +Its roots are then the two positive roots of Callen's cubic ``\Delta_c w^3 + \Delta'_\mathrm{RMP}w_v^2w - w_\mathrm{pol}^2 = 0`` (Callen 2016, Eq. 17) in the full width ``w``, and its `b_crit` is his critical vacuum resonant field ``B_\mathrm{crit} = B_\mathrm{vac}(w_{v,\mathrm{crit}}/w_v)^2``; the critical vacuum island width is ``w_v\sqrt{b_\mathrm{crit}/B_\mathrm{vac}}``. The vacuum resonant field is the coils' resonant harmonic on the rational surface over its area. -Its intrinsic index ``\Delta_c`` is the computed ``\Delta'_{mn}`` by default (from the Riccati solve `main` shares with the MRE; in a script, pass it as `CallenProblem(pe; delta_prime)`), or Callen's cylindrical ``-2m/\rho`` of Fortran GPEC 1.6.0 with `delta_prime_source = "cylindrical"`. -The same switch on `[Tearing.ModifiedRutherford]` replaces ``\Delta'_{mn}`` by the cylindrical ``r_s\Delta' = -2m``, so the MRE then runs on any solve, forward included, as a reference case. -In a script, `solve(CallenProblem(pe))` returns the same [`CallenResult`](@ref Tearing.Callen.CallenResult). +The intrinsic index ``\Delta_c`` is the computed ``\Delta'_{mn}`` referred to ``\rho`` by default, or Callen's cylindrical ``-2m/\rho`` of Fortran GPEC 1.6.0 with `delta_prime_source = "cylindrical"`. +The same switch without the preset replaces ``\Delta'_{mn}`` by the cylindrical ``r_s\Delta' = -2m``, so the MRE then runs on any solve, forward included, as a reference case. ## Numerical notes @@ -223,12 +220,6 @@ Modules = [GeneralizedPerturbedEquilibrium.Tearing.CriticalResonantField] Modules = [GeneralizedPerturbedEquilibrium.Tearing.ModifiedRutherford] ``` -### Callen - -```@autodocs -Modules = [GeneralizedPerturbedEquilibrium.Tearing.Callen] -``` - ### ResonantForcing ```@autodocs diff --git a/regression-harness/cases/diiid_n1_callen.toml b/regression-harness/cases/diiid_n1_callen.toml index d9c32fb09..c738c14b6 100644 --- a/regression-harness/cases/diiid_n1_callen.toml +++ b/regression-harness/cases/diiid_n1_callen.toml @@ -1,54 +1,17 @@ -# Regression case: DIII-D-like n=1 Callen vacuum-island threshold on the coil-driven perturbed equilibrium, and the -# modified Rutherford equation's Callen preset on the same forcing. +# Regression case: DIII-D-like n=1 Callen vacuum-island threshold and cubic roots, from the modified Rutherford +# equation's Callen preset on the coil-driven perturbed equilibrium. # The cylindrical intrinsic Δ′ (−2m/ρ) keeps the Fortran GPEC parity case and needs no extra Δ′ solve. # Each [quantities.*] block names an HDF5 path in the run output, how to extract it, # and the noise floor below which a difference is treated as zero. [case] name = "diiid_n1_callen" -description = "DIII-D-like, n=1, Callen vacuum-island threshold and cubic roots, and the MRE Callen preset, on the coil-driven perturbed equilibrium" +description = "DIII-D-like, n=1, Callen vacuum-island threshold and cubic roots from the MRE Callen preset on the coil-driven perturbed equilibrium" example_dir = "examples/DIIID-like_ideal_example" remove_sections = ["LocalStability", "Ballooning", "ErrorFields", "KineticForces"] [overrides] -"Tearing.Callen.delta_prime_source" = "cylindrical" # Intrinsic Delta' -2m/rho, as Fortran GPEC 1.6.0; no extra Delta' solve "Tearing.ModifiedRutherford.preset" = "callen" # Reduce F(w) to Callen's steady state with the coil forcing -"Tearing.ModifiedRutherford.delta_prime_source" = "cylindrical" # Intrinsic Delta' -2m/rho, as the Callen section - -[quantities.callen_hw_v] -h5path = "Tearing/Callen/PerSurface/hw_v" -type = "real_vector" -extract = "all_real" -label = "Callen vacuum island half-width (psi_N)" -noise_threshold = 1e-10 -order = 10 -class = "physics_converged" - -[quantities.callen_hw_v_crit] -h5path = "Tearing/Callen/PerSurface/hw_v_crit" -type = "real_vector" -extract = "all_real" -label = "Callen critical vacuum half-width (psi_N)" -noise_threshold = 1e-10 -order = 12 -class = "physics_converged" - -[quantities.callen_b_crit] -h5path = "Tearing/Callen/PerSurface/b_crit" -type = "real_vector" -extract = "all_real" -label = "Callen critical vacuum resonant field (T)" -noise_threshold = 1e-12 -order = 14 -class = "physics_converged" - -[quantities.callen_n_roots] -h5path = "Tearing/Callen/PerSurface/n_roots" -type = "real_vector" -extract = "all_real" -label = "Callen positive cubic roots per surface" -noise_threshold = 0 -order = 16 -class = "physics_converged" +"Tearing.ModifiedRutherford.delta_prime_source" = "cylindrical" # Intrinsic Delta' -2m/rho, as Fortran GPEC 1.6.0; no extra Delta' solve [quantities.mre_callen_w_m] h5path = "Tearing/ModifiedRutherford/PerSurface/w_m" diff --git a/src/Deck.jl b/src/Deck.jl index a81ac5275..a69568bb3 100644 --- a/src/Deck.jl +++ b/src/Deck.jl @@ -360,9 +360,9 @@ end """ function tearing_from_deck(inputs::AbstractDict) haskey(inputs, "Tearing") || return nothing - # A [Tearing] holding only [Tearing.ModifiedRutherford] or [Tearing.Callen] runs those without a layer solve. - table = _without(inputs["Tearing"], "ModifiedRutherford", "Callen") - isempty(table) && (haskey(inputs["Tearing"], "ModifiedRutherford") || haskey(inputs["Tearing"], "Callen")) && return nothing + # A [Tearing] holding only [Tearing.ModifiedRutherford] runs it without a layer solve. + table = _without(inputs["Tearing"], "ModifiedRutherford") + isempty(table) && haskey(inputs["Tearing"], "ModifiedRutherford") && return nothing get(table, "omega_E_kHz", Dict{String,Any}()) isa AbstractDict || throw(ArgumentError("[Tearing] omega_E_kHz must be a table keyed by m/n, e.g. omega_E_kHz = { \"2/1\" = 3.0 }")) fields = _struct_fields(TearingProblem, (:ffs, :critical_resonant_field)) @@ -394,16 +394,6 @@ function modified_rutherford_from_deck(inputs::AbstractDict) return preset == "callen" ? merge(Dict{Symbol,Any}(pairs(ModifiedRutherford.CALLEN_PRESET)), kwargs) : kwargs end -""" - callen_from_deck(inputs) -> Union{Nothing,Dict{Symbol,Any}} - -The `CallenProblem` keywords of `[Tearing.Callen]`; `nothing` without that section. -""" -function callen_from_deck(inputs::AbstractDict) - haskey(inputs, "Tearing") && haskey(inputs["Tearing"], "Callen") || return nothing - return section_keywords(inputs["Tearing"]["Callen"], "Tearing.Callen", _struct_fields(CallenProblem, (:pe, :delta_prime))) -end - """ perturbed_equilibrium_from_deck(inputs) -> Union{Nothing,NamedTuple} @@ -516,21 +506,9 @@ function parse_deck(inputs::AbstractDict, path::AbstractString) end perturbed_equilibrium = perturbed_equilibrium_from_deck(inputs) forcing = forcing_terms_control(inputs, path) - callen = callen_from_deck(inputs) modified_rutherford === nothing || perturbed_equilibrium === nothing || get(modified_rutherford, :forcing, true) === false || forcing.forcing_data_format == "coil" || error("[Tearing.ModifiedRutherford] forcing needs a coil forcing: set [ForcingTerms] forcing_data_format = \"coil\", or forcing = false") - if callen !== nothing - perturbed_equilibrium === nothing && error("[Tearing.Callen] needs a [PerturbedEquilibrium] section") - first(force_balance.keywords[:nn]) == last(force_balance.keywords[:nn]) || - error("[Tearing.Callen] is single-n: set [ForceFreeStates] nn to one toroidal mode number") - get(callen, :delta_prime_source, :computed) === :cylindrical || get(force_balance.keywords, :vac_flag, false) || - error("[Tearing.Callen] computes Δ′ with an extra Riccati solve, which needs [ForceFreeStates] vac_flag = true; " * - "or set delta_prime_source = \"cylindrical\"") - forcing.forcing_data_format == "coil" || error("[Tearing.Callen] needs a coil forcing: set [ForcingTerms] forcing_data_format = \"coil\"") - isempty(get(get(inputs, "Equilibrium", Dict{String,Any}()), "kinetic_file", "")) && - error("[Tearing.Callen] needs kinetic profiles: set kinetic_file in [Equilibrium]") - end if haskey(inputs, "ErrorFields") perturbed_equilibrium === nothing && error("[ErrorFields] needs a [PerturbedEquilibrium] section") forcing.forcing_data_format == "coil" || @@ -541,7 +519,7 @@ function parse_deck(inputs::AbstractDict, path::AbstractString) error("[GGJ] is set but neither [Match] nor [Tearing] inner_model = \"GGJ\" uses it") haskey(inputs, "SLAYER") && tearing_model != "SLAYER" && !(has_ffs && get(inputs["ForceFreeStates"], "psilim_from_layer_overlap", false) === true) && error("[SLAYER] is set but neither [Tearing] inner_model = \"SLAYER\" nor [ForceFreeStates] psilim_from_layer_overlap = true uses it") - return (; run=run_settings(inputs, path), force_balance, local_stability=local_stability_from_deck(inputs), match, tearing, modified_rutherford, callen, + return (; run=run_settings(inputs, path), force_balance, local_stability=local_stability_from_deck(inputs), match, tearing, modified_rutherford, kinetic_forces=haskey(inputs, "KineticForces") ? kinetic_forces_control(inputs) : nothing, perturbed_equilibrium, forcing, error_fields=error_fields_from_deck(inputs, path)) end @@ -638,7 +616,6 @@ function deck_inputs(deck::NamedTuple, config::Equilibrium.EquilibriumConfig, an end mre = deck.modified_rutherford mre === nothing || (get!(inputs, "Tearing", Dict{String,Any}())["ModifiedRutherford"] = _deck_table(mre)) - deck.callen === nothing || (get!(inputs, "Tearing", Dict{String,Any}())["Callen"] = _deck_table(deck.callen)) deck.kinetic_forces === nothing || merge!(inputs, deck_sections(deck.kinetic_forces)) pe = deck.perturbed_equilibrium pe === nothing || (inputs["PerturbedEquilibrium"] = _deck_table(merge(pe.response, pe.drive))) @@ -688,7 +665,6 @@ deck_sections(m::ForceFreeStates.MatchResult) = deck_sections(kf::KineticForcesResult) = deck_sections(kf.control) deck_sections(t::TearingResult) = t.model === nothing ? Dict{String,Any}() : _tearing_sections(t.problem, t.problem.critical_resonant_field, t.model, t.config) deck_sections(r::ModifiedRutherfordResult) = Dict{String,Any}("Tearing" => Dict{String,Any}("ModifiedRutherford" => _deck_table(r.problem, (:ffs, :tearing, :pe)))) -deck_sections(r::CallenResult) = Dict{String,Any}("Tearing" => Dict{String,Any}("Callen" => _deck_table(r.problem, (:pe, :delta_prime)))) deck_sections(ef::ErrorFieldsResult) = _error_fields_sections(ef.settings, ef.tolerances === nothing ? "" : _RECORDED_TOLERANCES) function deck_sections(ffs::ForceFreeStatesResult) diff --git a/src/GeneralizedPerturbedEquilibrium.jl b/src/GeneralizedPerturbedEquilibrium.jl index aad456183..1eaf65168 100755 --- a/src/GeneralizedPerturbedEquilibrium.jl +++ b/src/GeneralizedPerturbedEquilibrium.jl @@ -75,8 +75,7 @@ include("Rerun.jl") using .ForceFreeStates: AbstractIntegrator, Forward, Riccati, Galerkin, TestKineticMatrices, KineticMatrices, ForceFreeStatesResult using .ForceFreeStates: MatchProblem using .InnerLayer: GGJModel, SLAYERModel -using .Tearing: TearingProblem, TearingConfig, TearingResult, CriticalResonantFieldControl, ModifiedRutherfordProblem, ModifiedRutherfordResult, CallenProblem, - CallenResult +using .Tearing: TearingProblem, TearingConfig, TearingResult, CriticalResonantFieldControl, ModifiedRutherfordProblem, ModifiedRutherfordResult using .LocalStability: LocalStabilityProblem, Ballooning, LocalStabilityResult using .Equilibrium: PlasmaEquilibrium, KineticProfileData, IonSpecies, attach_kinetic_profiles! using .ForcingTerms: RMPField @@ -142,7 +141,7 @@ equilibrium and writes nothing. The raw forcing file contents are captured in `Input/RawInputs/ForcingTerms/`, and `preloaded_coil_sets` lets the rerun path inject the coil geometry stored in `Input/RawInputs/Coils/`. -Returns `(; equil, local_stability, ffs, match, resp, pe, kinetic, error_fields, tearing, modified_rutherford, callen)`, each +Returns `(; equil, local_stability, ffs, match, resp, pe, kinetic, error_fields, tearing, modified_rutherford)`, each `nothing` when its section is absent. `equil` is the equilibrium the stages used (the solve's, on the two-pass grid), `ffs` the result the later stages read (the matched one when the deck has `[Match]`), and `match` its `MatchResult`. @@ -160,7 +159,7 @@ function main_from_inputs( runtimes = Vector{Pair{String,Float64}}() deck = parse_deck(inputs, path) (; output_file, verbose) = deck.run - local_stability = ffs = match = resp = pe = kinetic = error_fields = tearing = modified_rutherford = callen = nothing + local_stability = ffs = match = resp = pe = kinetic = error_fields = tearing = modified_rutherford = nothing equil = _timed_stage(runtimes, "equilibrium", "Equilibrium") do Equilibrium.PlasmaEquilibrium(eq_config, additional_input) @@ -226,11 +225,10 @@ function main_from_inputs( save_to_hdf5(output_file, tearing) end - # The perturbed equilibrium needs a forward solve, so a computed Δ′ for the MRE or Callen comes from one shared Riccati solve. - computed(section) = section !== nothing && get(section, :delta_prime_source, :computed) === :computed + # The perturbed equilibrium needs a forward solve, so a computed Δ′ for the MRE comes from its own Riccati solve. has_delta_prime = ffs !== nothing && ffs.delta_prime !== nothing && !isempty(ffs.delta_prime.matrix) ric = has_delta_prime ? ffs : nothing - if !has_delta_prime && (computed(deck.modified_rutherford) || computed(deck.callen)) + if !has_delta_prime && deck.modified_rutherford !== nothing && get(deck.modified_rutherford, :delta_prime_source, :computed) === :computed ric = _timed_stage(runtimes, "tearing_delta_prime", "Tearing Δ′ (Riccati)") do solve(ForceBalanceProblem(equil; deck.force_balance.keywords...), Riccati(); verbose) end @@ -244,19 +242,12 @@ function main_from_inputs( save_to_hdf5(output_file, modified_rutherford) end - if deck.callen !== nothing - callen = _timed_stage(runtimes, "callen", "Callen threshold") do - solve(CallenProblem(pe; delta_prime=computed(deck.callen) ? ric : nothing, deck.callen...)) - end - save_to_hdf5(output_file, callen) - end - total_dt = time() - total_start push!(runtimes, "total" => total_dt) ffs === nothing || _write_runtimes!(output_file, runtimes) @info "\n$_BANNER\n GPEC completed successfully in $(@sprintf("%.3f", total_dt)) s\n$_BANNER" - return (; equil, local_stability, ffs, match, resp, pe, kinetic, error_fields, tearing, modified_rutherford, callen) + return (; equil, local_stability, ffs, match, resp, pe, kinetic, error_fields, tearing, modified_rutherford) end """ @@ -332,7 +323,7 @@ export main, write_imas export solve, plasma_response, perturbed_equilibrium, kinetic_torque, error_fields export KineticProfileData, IonSpecies, attach_kinetic_profiles! export PlasmaEquilibrium, ForceBalanceProblem, Forward, Riccati, Galerkin, TestKineticMatrices, KineticMatrices, kinetic_matrices, ForceFreeStatesResult, RMPField, PlasmaResponse -export MatchProblem, TearingProblem, TearingConfig, TearingResult, GGJModel, SLAYERModel, ModifiedRutherfordProblem, ModifiedRutherfordResult, CallenProblem, CallenResult +export MatchProblem, TearingProblem, TearingConfig, TearingResult, GGJModel, SLAYERModel, ModifiedRutherfordProblem, ModifiedRutherfordResult export LocalStabilityProblem, Ballooning, LocalStabilityResult export KineticForcesControl, KineticForcesResult, ErrorFieldsResult diff --git a/src/Tearing/Callen/Callen.jl b/src/Tearing/Callen/Callen.jl deleted file mode 100644 index c32d0d0a1..000000000 --- a/src/Tearing/Callen/Callen.jl +++ /dev/null @@ -1,20 +0,0 @@ -# Callen's reduced modified Rutherford equation for coil-driven islands: the critical vacuum island width, the -# critical resonant field and the steady half-widths per resonant row of a single-n perturbed equilibrium. - -module Callen - -using HDF5 -import CommonSolve - -import ...Utilities -import ...Utilities: save_to_hdf5 -using ...Utilities.HDF5Annotations: replace_group! -using ...InnerLayer: layer_plasma -using ..ResonantForcing: resonant_forcing, callen_polarization_width - -include("Problem.jl") -include("HDF5Output.jl") - -export CallenProblem, CallenResult, callen_cubic_roots - -end # module Callen diff --git a/src/Tearing/Callen/HDF5Output.jl b/src/Tearing/Callen/HDF5Output.jl deleted file mode 100644 index c98806f64..000000000 --- a/src/Tearing/Callen/HDF5Output.jl +++ /dev/null @@ -1,47 +0,0 @@ -# HDF5Output.jl -# -# Writer for a `CallenResult`, under the caller's parent group: -# -# Tearing/ -# └── Callen/ -# └── PerSurface/ -- one dataset per field over the resonant rows - -""" - save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::CallenResult) - -Write `result` into `Tearing/Callen/PerSurface/` of `parent`, one dataset per field over the resonant rows, replacing any existing -one and keeping the rest of `Tearing/`, and record its deck section. -""" -function save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::CallenResult) - tearing = haskey(parent, "Tearing") ? parent["Tearing"] : create_group(parent, "Tearing") - g = replace_group!(tearing, "Callen") - for (key, _) in CALLEN_H5_ANNOTATIONS - g[key] = getfield(result, Symbol(basename(key))) - end - Utilities.HDF5Annotations.annotate!(g, CALLEN_H5_ANNOTATIONS) - Utilities.record_inputs!(parent, result) - return g -end - -const CALLEN_H5_ANNOTATIONS = [ - "PerSurface/rational_psi" => (; long_name="normalized poloidal flux of the rational surface", units="psi_N", dims=("surface",)), - "PerSurface/rational_q" => (; long_name="safety factor at the surface", dims=("surface",)), - "PerSurface/m" => (; long_name="resonant poloidal mode number", dims=("surface",)), - "PerSurface/n" => (; long_name="resonant toroidal mode number", dims=("surface",)), - "PerSurface/rho_tor" => (; long_name="toroidal-flux radius (Phi_tor/(pi B0))^(1/2)", units="m", dims=("surface",)), - "PerSurface/drho_dpsi" => (; long_name="d rho_tor / d psi_N", units="m", dims=("surface",)), - "PerSurface/rho_i" => (; long_name="ion gyroradius in the axis field", units="m", dims=("surface",)), - "PerSurface/w_pol" => (; long_name="polarization width 0.5 q rho_i/(rho/R0)^(1/2)", units="m", dims=("surface",)), - "PerSurface/delta_rmp" => (; long_name="Callen Delta'_RMP from the total resonant field", units="1/m", dims=("surface",)), - "PerSurface/delta_c" => - (; long_name="Callen intrinsic Delta' in 1/m of rho: computed Delta'_mn, or -2m/rho with delta_prime_source = cylindrical", units="1/m", dims=("surface",)), - "PerSurface/b_res" => (; long_name="total resonant field |flux|/area", units="T", dims=("surface",)), - "PerSurface/b_vac" => (; long_name="vacuum (coil) resonant field |flux|/area", units="T", dims=("surface",)), - "PerSurface/hw_isl" => (; long_name="total-field island half-width", units="psi_N", dims=("surface",)), - "PerSurface/hw_v" => (; long_name="vacuum island half-width", units="psi_N", dims=("surface",)), - "PerSurface/hw_v_crit" => (; long_name="critical vacuum island half-width", units="psi_N", dims=("surface",)), - "PerSurface/hw_min" => (; long_name="island half-width at the smaller positive root of the Callen cubic, NaN if none", units="psi_N", dims=("surface",)), - "PerSurface/hw_sat" => (; long_name="island half-width at the larger positive root of the Callen cubic, NaN if none", units="psi_N", dims=("surface",)), - "PerSurface/n_roots" => (; long_name="number of positive roots of the Callen cubic", dims=("surface",)), - "PerSurface/b_crit" => (; long_name="critical vacuum resonant field", units="T", dims=("surface",)) -] diff --git a/src/Tearing/Callen/Problem.jl b/src/Tearing/Callen/Problem.jl deleted file mode 100644 index f679b7f05..000000000 --- a/src/Tearing/Callen/Problem.jl +++ /dev/null @@ -1,152 +0,0 @@ -# Problem.jl -# -# `solve(CallenProblem(pe))`: Callen's reduced modified Rutherford equation for RMP-driven islands (UW-CPTC 16-4, 2016), -# in the Fortran GPEC 1.6.0 form, on every resonant row of a single-n perturbed equilibrium driven by coils. - -""" - CallenProblem(pe; delta_prime=nothing, delta_prime_source=:computed) - -Callen's reduced MRE for the vacuum-island threshold on every resonant row of the perturbed equilibrium `pe` -(a single-n state from `perturbed_equilibrium` with a coil forcing). Half-widths are in metres of the toroidal-flux -radius ρ = (Φ_tor/πB₀)^(1/2) inside, and in ψ_N on output, as in Fortran GPEC 1.6.0: - - - w_pol = ½ q ρ_i/(ρ/R₀)^(1/2) with ρ_i = (2T_i/m_i)^(1/2)/(Z_i e B₀/m_i), R₀ the axis major radius and B₀ the - vacuum toroidal field `bt0`, as Fortran GPEC 1.6.0 takes them; - - Δ_RMP = |Δ|/(2πR₀q) B₀/|B_res|, Δ the singular-current jump over 2πχ₁ and B_res the total resonant field; - - Δ_c the intrinsic tearing index in 1/m of ρ: the true Δ′_mn of `delta_prime` (a Riccati or Galerkin solve on the - same equilibrium and n), referred to ρ as K^(2α) Δ′_mn/ρ with K = ρ dψ_N/dρ and α = (−D_I)^(1/2) as for SLAYER, with - `delta_prime_source = :computed`; or Callen's cylindrical −2m/ρ (Fortran GPEC 1.6.0) with `:cylindrical`; - - critical vacuum half-width h_v,crit = ½ w_pol^(2/3) ((27/4)|Δ_c|)^(1/6)/Δ_RMP^(1/2); - - the steady state of Callen's Eq. 17, Δ_c w³ + Δ_RMP w_vac² w − w_pol² = 0, a cubic in the full width w with - w_vac = 2h_v; its positive roots halved are h_min < h_sat (with Δ_c ≥ 0 only h_min exists, and h_v,crit = 0); - - critical resonant field B_crit = |B_vac| (h_v,crit/h_v)². - -The vacuum resonant field B_vac is the coils' resonant harmonic on the rational surface over its area. - -## Fields - - - `pe` - Perturbed-equilibrium state, single n, from a coil forcing. - - `delta_prime` - Force-free-states result carrying Δ′ (Riccati or Galerkin) for `:computed`; set by the deck. - - `delta_prime_source` - `:computed` (true Δ′_mn) or `:cylindrical` (−2m/ρ, the Fortran 1.6.0 reference). -""" -@kwdef struct CallenProblem{S,D} - pe::S - delta_prime::D = nothing - delta_prime_source::Symbol = :computed -end - -CallenProblem(pe; kwargs...) = CallenProblem(; pe=pe, kwargs...) - -""" - CallenResult - -Output of `solve(::CallenProblem)`, one entry per resonant row. Half-widths are in ψ_N; `NaN` marks a root that -does not exist. - -## Fields - - - `problem` - The producing problem. - - `rational_psi`, `rational_q`, `m`, `n` - Surface identity. - - `rho_tor`, `drho_dpsi` - Toroidal-flux radius ρ [m] and dρ/dψ_N [m]. - - `rho_i`, `w_pol` - Ion gyroradius and polarization width [m]. - - `delta_rmp`, `delta_c` - Δ_RMP and Δ_c [1/m]. - - `b_res`, `b_vac` - |total| and |vacuum| resonant field [T]. - - `hw_isl`, `hw_v` - Total-field and vacuum island half-widths. - - `hw_v_crit` - Critical vacuum half-width. - - `hw_min`, `hw_sat` - Half of the smaller and larger positive (full-width) cubic roots. - - `n_roots` - Number of positive cubic roots (0 or 2; 1 when Δ_c ≥ 0). - - `b_crit` - Critical vacuum resonant field [T]. -""" -struct CallenResult{P} - problem::P - rational_psi::Vector{Float64} - rational_q::Vector{Float64} - m::Vector{Int} - n::Vector{Int} - rho_tor::Vector{Float64} - drho_dpsi::Vector{Float64} - rho_i::Vector{Float64} - w_pol::Vector{Float64} - delta_rmp::Vector{Float64} - delta_c::Vector{Float64} - b_res::Vector{Float64} - b_vac::Vector{Float64} - hw_isl::Vector{Float64} - hw_v::Vector{Float64} - hw_v_crit::Vector{Float64} - hw_min::Vector{Float64} - hw_sat::Vector{Float64} - n_roots::Vector{Int} - b_crit::Vector{Float64} -end - -""" - callen_cubic_roots(a, b, c) -> (x_min, x_sat) - -The positive roots of `a x³ + b x + c = 0` with `b ≥ 0`, `c < 0`. For `a < 0`, the trigonometric solution -x_k = 2R cos((θ + 2πk)/3), R = (−p/3)^(1/2), cos θ = −q/(2R³) of the depressed cubic x³ + px + q gives two positive -roots or none (`(NaN, NaN)`); for `a > 0` there is one positive root (Cardano), returned as `(x, NaN)`. -""" -function callen_cubic_roots(a::Real, b::Real, c::Real) - a == 0 && return (b > 0 ? -c / b : NaN, NaN) - p, q = b / a, c / a - if a > 0 - s = sqrt(q^2 / 4 + p^3 / 27) - return (cbrt(-q / 2 + s) + cbrt(-q / 2 - s), NaN) - end - p < 0 || return (NaN, NaN) - R = sqrt(-p / 3) - arg = -q / (2 * R^3) - abs(arg) <= 1 || return (NaN, NaN) - theta = acos(arg) - roots = sort(filter(>(0), [2R * cos((theta + 2π * k) / 3) for k in 0:2])) - length(roots) == 2 || return (NaN, NaN) - return (roots[1], roots[2]) -end - -# Δ′_mn of the surface at ψ from a force-free-states result, referred to the radius ρ in 1/m: K^(2α) Δ′/ρ, K = ρ dψ_N/dρ. -function _callen_delta_c(ric, psi, rho, drho) - k = findfirst(s -> isapprox(s.psifac, psi; atol=1e-8), ric.surfaces) - k === nothing && throw(ArgumentError("CallenProblem: delta_prime has no rational surface at ψ = $psi")) - rg = ric.surfaces[k].restype - alpha = sqrt(max(0.25 - (rg.E + rg.F + rg.H), 0.0)) - return (rho / drho)^(2 * alpha) * real(ric.delta_prime.matrix[k, k]) / rho -end - -function CommonSolve.solve(prob::CallenProblem) - pe = prob.pe - resp = pe.response - ffs = resp.ffs - equil = ffs.equil - ffs.npert == 1 || throw(ArgumentError("CallenProblem is single-n; the solve spans n = $(ffs.nlow):$(ffs.nhigh)")) - data = equil.kinetic - data === nothing && throw(ArgumentError("CallenProblem needs kinetic profiles; set kinetic_file in [Equilibrium]")) - isempty(pe.island_half_width) && throw(ArgumentError("CallenProblem needs the singular coupling of the perturbed equilibrium")) - prob.delta_prime_source in (:computed, :cylindrical) || - throw(ArgumentError("delta_prime_source must be :computed or :cylindrical, got :$(prob.delta_prime_source)")) - ric = prob.delta_prime - prob.delta_prime_source === :cylindrical || (ric !== nothing && !isempty(ric.delta_prime.matrix)) || - throw(ArgumentError("CallenProblem with delta_prime_source = :computed needs delta_prime, a Riccati or Galerkin solve carrying Δ′")) - - plasma = layer_plasma(equil; log=false) - rf = resonant_forcing(pe) - nrow = length(rf.rational_psi) - out = Dict(k => fill(NaN, nrow) for k in (:rho_i, :w_pol, :delta_c, :hw_v_crit, :hw_min, :hw_sat, :b_crit)) - n_roots = zeros(Int, nrow) - for row in 1:nrow - psi, m, rho, drho, delta_rmp, hw_v, b_vac = rf.rational_psi[row], rf.m[row], rf.rho[row], rf.drho[row], rf.delta_rmp[row], rf.hw_v[row], rf.b_vac[row] - rho_i, w_pol = callen_polarization_width(equil, plasma, psi, rf.rational_q[row]) - delta_c = prob.delta_prime_source === :cylindrical ? -2 * m / rho : _callen_delta_c(ric, psi, rho, drho) - # With Δ_c ≥ 0 any vacuum island lets a seed above h_min grow, so the critical vacuum width is zero. - hw_v_crit = delta_c < 0 ? 0.5 * w_pol^(2 / 3) * ((27 / 4) * abs(delta_c))^(1 / 6) / sqrt(delta_rmp) : 0.0 - # Roots are full widths in metres; the outputs are half-widths in ψ_N. - x_min, x_sat = callen_cubic_roots(delta_c, delta_rmp * (2 * hw_v * drho)^2, -w_pol^2) - for (k, v) in pairs((; rho_i, w_pol, delta_c, hw_v_crit=hw_v_crit / drho, hw_min=x_min / (2 * drho), hw_sat=x_sat / (2 * drho), - b_crit=b_vac * (hw_v_crit / drho / hw_v)^2)) - out[k][row] = v - end - n_roots[row] = count(!isnan, (x_min, x_sat)) - end - return CallenResult(prob, rf.rational_psi, rf.rational_q, rf.m, rf.n, rf.rho, rf.drho, out[:rho_i], out[:w_pol], rf.delta_rmp, out[:delta_c], rf.b_res, - rf.b_vac, rf.hw_isl, rf.hw_v, out[:hw_v_crit], out[:hw_min], out[:hw_sat], n_roots, out[:b_crit]) -end diff --git a/src/Tearing/HDF5Output.jl b/src/Tearing/HDF5Output.jl index 5fe5ebdcf..9da12ff53 100644 --- a/src/Tearing/HDF5Output.jl +++ b/src/Tearing/HDF5Output.jl @@ -20,7 +20,7 @@ using HDF5 save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::TearingResult) Write `result` into a `Tearing/` subgroup of `parent`, replacing its previous contents but keeping the -`ModifiedRutherford/` and `Callen/` groups that other results write there. +`ModifiedRutherford/` group that its result writes there. """ function save_to_hdf5(parent::Union{HDF5.File,HDF5.Group}, result::TearingResult) @@ -184,7 +184,7 @@ const TEARING_RAGGED_H5_ANNOTATIONS = [ ] # Groups under Tearing/ that other results own, so a TearingResult write keeps them. -const _TEARING_FOREIGN_GROUPS = ("ModifiedRutherford", "Callen") +const _TEARING_FOREIGN_GROUPS = ("ModifiedRutherford",) # Empty Tearing/ of everything a TearingResult writes, or create it. function _reset_tearing_group!(parent) diff --git a/src/Tearing/ModifiedRutherford/Problem.jl b/src/Tearing/ModifiedRutherford/Problem.jl index 7652b42b1..289221d52 100644 --- a/src/Tearing/ModifiedRutherford/Problem.jl +++ b/src/Tearing/ModifiedRutherford/Problem.jl @@ -31,7 +31,8 @@ field. It scales with the vacuum resonant field B_vac, so the roots and branches critical fields `b_crit` and `b_trigger` follow from the unforced F ([`forcing_thresholds`](@ref)). `CALLEN_PRESET` (deck `preset = "callen"`) reduces F to Callen's steady state Δ_c + Δ_RMP (w_v/w)² − w_pol²/w³ in his -units, so `solve` reproduces `CallenProblem`'s roots (full widths) and critical field. +units (Fortran GPEC 1.6.0's w_pol, Δ_RMP and Δ_c), so its roots are the full widths of Callen's cubic +Δ_c w³ + Δ_RMP w_v² w − w_pol² = 0 and its `b_crit` is his critical field B_crit = |B_vac| (w_v,crit/w_v)². ## Fields diff --git a/src/Tearing/ResonantForcing.jl b/src/Tearing/ResonantForcing.jl index 4b7e032d4..288a598f5 100644 --- a/src/Tearing/ResonantForcing.jl +++ b/src/Tearing/ResonantForcing.jl @@ -1,6 +1,6 @@ # The resonant forcing of a single-n coil-driven perturbed equilibrium, per rational surface, in the form a steady island # equation takes it (Callen, UW-CPTC 16-4, 2016): the vacuum and total resonant fields, Δ_RMP and the vacuum island width, -# on the toroidal-flux radius ρ that Callen's widths use. The Callen threshold and the modified Rutherford equation share it. +# on the toroidal-flux radius ρ that Callen's widths use. The modified Rutherford equation's forcing term and Callen preset use it. module ResonantForcing diff --git a/src/Tearing/Tearing.jl b/src/Tearing/Tearing.jl index 9b74811c0..02317fa08 100644 --- a/src/Tearing/Tearing.jl +++ b/src/Tearing/Tearing.jl @@ -8,8 +8,6 @@ # resonant field of error-field penetration # ModifiedRutherford -- terms of the steady modified Rutherford equation and # F(w) per surface, `solve(ModifiedRutherfordProblem(ffs; ...))` -# Callen -- Callen's reduced equation for coil-driven islands, -# `solve(CallenProblem(pe; ...))` # TearingProblem / TearingConfig / TearingResult -- the public solve, # `solve(TearingProblem(ffs; ...), model; config)`, with one # method per inner-layer model (SLAYERModel, GGJModel) @@ -44,15 +42,12 @@ include("Dispersion/Dispersion.jl") include("CriticalResonantField/CriticalResonantField.jl") include("ResonantForcing.jl") include("ModifiedRutherford/ModifiedRutherford.jl") -include("Callen/Callen.jl") import .Dispersion as Dispersion import .CriticalResonantField as CriticalResonantField import .ModifiedRutherford as ModifiedRutherford -import .Callen as Callen import .ResonantForcing as ResonantForcing using .ModifiedRutherford: ModifiedRutherfordProblem, ModifiedRutherfordResult -using .Callen: CallenProblem, CallenResult using .Dispersion: SurfaceCoupling, surface_coupling, MultiSurfaceCoupling, multi_surface_coupling, ScanResult, brute_force_scan, @@ -69,7 +64,7 @@ include("GGJTearing.jl") include("HDF5Output.jl") export InnerLayer, Dispersion, CriticalResonantField, ModifiedRutherford -export ModifiedRutherfordProblem, ModifiedRutherfordResult, CallenProblem, CallenResult +export ModifiedRutherfordProblem, ModifiedRutherfordResult export resistive_layer_overlap, layer_overlap_scan, LayerOverlapScan export TearingProblem, TearingConfig, TearingResult export run_slayer_from_inputs diff --git a/test/runtests_modified_rutherford.jl b/test/runtests_modified_rutherford.jl index f6b24922b..24eedeffa 100644 --- a/test/runtests_modified_rutherford.jl +++ b/test/runtests_modified_rutherford.jl @@ -4,7 +4,6 @@ using TOML # alone, and the surface geometry against Fortran RDCON (MRE_flag output) on the DIII-D-like example. @testset "ModifiedRutherford terms" begin MR = GeneralizedPerturbedEquilibrium.ModifiedRutherford - CL = GeneralizedPerturbedEquilibrium.Tearing.Callen @testset "classical term: Mercier power and its zero-pressure limit" begin @test MR.classical_term(-3.0, -0.25, 0.01) ≈ -3.0 @@ -120,20 +119,15 @@ using TOML @test s.w_sat_all ≈ [0.05] rtol = 1e-10 end - @testset "Callen cubic: both positive roots solve it and merge at the critical vacuum width" begin - a, c = -40.0, -(1.5e-2)^2 - x_min, x_sat = CL.callen_cubic_roots(a, 2.0, c) - @test 0 < x_min < x_sat - @test abs(a * x_min^3 + 2.0 * x_min + c) < 1e-12 && abs(a * x_sat^3 + 2.0 * x_sat + c) < 1e-12 - # The double root sits where 4p³ + 27q² = 0, i.e. b = 3|a| (c/2a)^(2/3). - b_crit = 3 * abs(a) * (c / (2a))^(2 / 3) - @test all(isnan, CL.callen_cubic_roots(a, 0.99 * b_crit, c)) - lo, hi = CL.callen_cubic_roots(a, 1.0001 * b_crit, c) - @test hi / lo < 1.05 - # Callen 2016 Fig. 8 (DIII-D 158115, ρ = 71.88 cm): full widths w_min ≈ 1.28 cm, w_sat ≈ 2.24 cm. - w_min, w_sat = CL.callen_cubic_roots(-16 / 71.88, 26.4 / 71.88 * 2.45^2, -1.5^2) - @test w_min ≈ 1.28 rtol = 0.1 - @test w_sat ≈ 2.24 rtol = 0.1 + @testset "Callen's steady state: the root finder recovers his Fig. 8 widths" begin + # Callen 2016 Fig. 8 (DIII-D 158115, ρ = 71.88 cm), the cubic Δ_c w³ + Δ_RMP w_v² w − w_pol² = 0 over w³, in cm: + # full widths w_min ≈ 1.28 cm, w_sat ≈ 2.24 cm. + a, b, c = -16 / 71.88, 26.4 / 71.88 * 2.45^2, -1.5^2 + r = MR.mre_roots(w -> a + b / w^2 + c / w^3, 0.1, exp.(range(log(0.1), log(10.0); length=200))) + @test abs(a * r.w_m^3 + b * r.w_m + c) < 1e-10 + @test abs(a * r.w_sat^3 + b * r.w_sat + c) < 1e-10 + @test r.w_m ≈ 1.28 rtol = 0.1 + @test r.w_sat ≈ 2.24 rtol = 0.1 end @testset "forcing thresholds: Callen's B_crit, and a forced window below an intrinsic seed" begin diff --git a/test/runtests_result_struct.jl b/test/runtests_result_struct.jl index 6869f36ee..5d71b69ee 100644 --- a/test/runtests_result_struct.jl +++ b/test/runtests_result_struct.jl @@ -45,9 +45,9 @@ include("display_check.jl") ret = GeneralizedPerturbedEquilibrium.main([dir]) # One field per stage, nothing for the sections the deck does not hold. - @test keys(ret) == (:equil, :local_stability, :ffs, :match, :resp, :pe, :kinetic, :error_fields, :tearing, :modified_rutherford, :callen) + @test keys(ret) == (:equil, :local_stability, :ffs, :match, :resp, :pe, :kinetic, :error_fields, :tearing, :modified_rutherford) @test ret.equil === ret.ffs.equil && - all(isnothing, (ret.match, ret.resp, ret.pe, ret.kinetic, ret.error_fields, ret.tearing, ret.modified_rutherford, ret.callen)) + all(isnothing, (ret.match, ret.resp, ret.pe, ret.kinetic, ret.error_fields, ret.tearing, ret.modified_rutherford)) ffs = ret.ffs forward_result[] = ffs @test ffs isa FFS.ForceFreeStatesResult diff --git a/test/runtests_solve_api.jl b/test/runtests_solve_api.jl index d46e11bdb..aa9a17b49 100644 --- a/test/runtests_solve_api.jl +++ b/test/runtests_solve_api.jl @@ -659,81 +659,98 @@ include("display_check.jl") Dict{String,Any}("preset" => "other")))) end - @testset "Callen threshold under [Tearing.Callen]" begin - # The coil-driven DIII-D-like example: Callen and the MRE's Callen preset on one shared Riccati Δ′ solve, with - # the coil forcing, against the API. + @testset "MRE Callen preset against Callen's closed form" begin + # The coil-driven DIII-D-like example: the MRE's Callen preset with the coil forcing and its own Riccati Δ′ solve, + # against the API and against Callen's closed-form critical field and cubic roots. MRE = GeneralizedPerturbedEquilibrium.Tearing.ModifiedRutherford + RF = GeneralizedPerturbedEquilibrium.Tearing.ResonantForcing ex_dir = joinpath(@__DIR__, "..", "examples", "DIIID-like_ideal_example") mktempdir() do dir deck = TOML.parsefile(joinpath(ex_dir, "gpec.toml")) foreach(k -> delete!(deck, k), ("LocalStability", "Ballooning", "ErrorFields", "KineticForces")) deck["Run"]["output_file"] = joinpath(dir, "gpec.h5") - deck["Tearing"] = Dict{String,Any}("Callen" => Dict{String,Any}(), "ModifiedRutherford" => Dict{String,Any}("preset" => "callen", "nw" => 400)) + deck["Tearing"] = Dict{String,Any}("ModifiedRutherford" => Dict{String,Any}("preset" => "callen", "nw" => 400)) for key in ("eq_filename", "kinetic_file") deck["Equilibrium"][key] = abspath(joinpath(ex_dir, deck["Equilibrium"][key])) end open(io -> TOML.print(io, deck), joinpath(dir, "gpec.toml"), "w") reference = GPEC.main([dir]) - @test reference.tearing === nothing && reference.callen isa CallenResult + @test reference.tearing === nothing + @test reference.modified_rutherford isa ModifiedRutherfordResult - ric = reference.callen.problem.delta_prime + ric = reference.modified_rutherford.problem.ffs @test ric isa ForceFreeStatesResult - @test reference.modified_rutherford.problem.ffs === ric - api = solve(CallenProblem(reference.pe; delta_prime=ric)) - @test api.delta_c ≈ reference.callen.delta_c rtol = 1e-10 - @test api.hw_v_crit ≈ reference.callen.hw_v_crit rtol = 1e-10 - @test isequal(api.n_roots, reference.callen.n_roots) - @test !(solve(CallenProblem(reference.pe; delta_prime_source=:cylindrical)).delta_c ≈ api.delta_c) - @test_throws "needs delta_prime" solve(CallenProblem(reference.pe)) - - # The preset is Callen's equation: its roots are his full widths and its b_crit his B_crit. On the shared Riccati - # solve they agree to the few 1e-7 by which its surfaces' q differs from the forward solve's. + @test ric !== reference.ffs + @test !isempty(ric.delta_prime.matrix) mre = solve(ModifiedRutherfordProblem(ric; pe=reference.pe, MRE.CALLEN_PRESET..., nw=400)) @test mre.F ≈ reference.modified_rutherford.F rtol = 1e-10 + + # Callen's closed form (Fortran GPEC 1.6.0) with his cylindrical Δ_c = −2m/ρ, in metres of ρ: the critical + # vacuum half-width ½ w_pol^(2/3) ((27/4)|Δ_c|)^(1/6)/Δ_RMP^(1/2), B_crit = B_vac (h_v,crit/h_v)², and the + # positive roots of Δ_c x³ + Δ_RMP (2h_v)² x − w_pol² = 0 (trigonometric form), all widths back in ψ_N. + equil = reference.pe.response.ffs.equil + plasma = GeneralizedPerturbedEquilibrium.InnerLayer.layer_plasma(equil; log=false) + function callen(pe) + rf = RF.resonant_forcing(pe) + map(eachindex(rf.rational_psi)) do k + rho, drho, d_rmp, hw_v = rf.rho[k], rf.drho[k], rf.delta_rmp[k], rf.hw_v[k] + w_pol = RF.callen_polarization_width(equil, plasma, rf.rational_psi[k], rf.rational_q[k])[2] + dc = -2 * rf.m[k] / rho + hw_v_crit = 0.5 * w_pol^(2 / 3) * ((27 / 4) * abs(dc))^(1 / 6) / sqrt(d_rmp) / drho + p, q = d_rmp * (2 * hw_v * drho)^2 / dc, -w_pol^2 / dc + R = sqrt(-p / 3) + arg = -q / (2 * R^3) + x = abs(arg) <= 1 ? sort(filter(>(0), [2R * cos((acos(arg) + 2π * j) / 3) for j in 0:2])) ./ drho : [NaN, NaN] + (; psi=rf.rational_psi[k], b_vac=rf.b_vac[k], d_rmp, w_v=2 * hw_v, b_crit=rf.b_vac[k] * (hw_v_crit / hw_v)^2, + w_min=x[1], w_sat=x[end]) + end + end row(res, psi) = findfirst(p -> isapprox(p, psi; atol=1e-6), res.rational_psi) - @test [mre.b_crit[row(mre, psi)] for psi in api.rational_psi] ≈ api.b_crit rtol = 1e-6 - # On the forward solve's own surfaces, with Callen's cylindrical Δ_c and the coils scaled past the largest critical - # field so every surface has both roots, they agree to the root tolerance. - cyl0 = solve(CallenProblem(reference.pe; delta_prime_source=:cylindrical)) - strong = perturbed_equilibrium(reference.pe.response, 4 * maximum(cyl0.b_crit ./ cyl0.b_vac) * reference.pe.forcing; + # On the forward solve's own surfaces, with the coils scaled past the largest critical field so every surface + # has both roots, the preset meets the closed form to the root tolerance. + cyl0 = callen(reference.pe) + strong = perturbed_equilibrium(reference.pe.response, 4 * maximum(c.b_crit / c.b_vac for c in cyl0) * reference.pe.forcing; coil_sets=reference.pe.coil_sets, reference.pe.regularization_width) - cyl = solve(CallenProblem(strong; delta_prime_source=:cylindrical)) + cyl = callen(strong) pre = solve(ModifiedRutherfordProblem(reference.ffs; pe=strong, MRE.CALLEN_PRESET..., delta_prime_source=:cylindrical, nw=400)) - @test cyl.b_crit ≈ cyl0.b_crit rtol = 1e-8 - @test all(==(2), cyl.n_roots) + @test [c.b_crit for c in cyl] ≈ [c.b_crit for c in cyl0] rtol = 1e-8 + @test all(c -> c.w_sat > c.w_min, cyl) compared = 0 - for k in eachindex(cyl.rational_psi) - s = row(pre, cyl.rational_psi[k]) - @test pre.delta_rmp[s] ≈ cyl.delta_rmp[k] rtol = 1e-12 - @test pre.w_v[s] ≈ 2 * cyl.hw_v[k] rtol = 1e-12 - @test pre.b_crit[s] ≈ cyl.b_crit[k] rtol = 1e-8 - cyl.n_roots[k] == 2 && 2 * cyl.hw_sat[k] < pre.w[end] && 2 * cyl.hw_min[k] > pre.w_lo[s] || continue - @test pre.w_m[s] ≈ 2 * cyl.hw_min[k] rtol = 1e-8 - @test pre.w_sat[s] ≈ 2 * cyl.hw_sat[k] rtol = 1e-8 + for c in cyl + s = row(pre, c.psi) + @test pre.delta_rmp[s] ≈ c.d_rmp rtol = 1e-12 + @test pre.w_v[s] ≈ c.w_v rtol = 1e-12 + @test pre.b_crit[s] ≈ c.b_crit rtol = 1e-8 + c.w_sat < pre.w[end] && c.w_min > pre.w_lo[s] || continue + @test pre.w_m[s] ≈ c.w_min rtol = 1e-8 + @test pre.w_sat[s] ≈ c.w_sat rtol = 1e-8 compared += 1 end @test compared > 0 + # The computed Δ′ moves Δ_c, and so the critical field, off the cylindrical one. + @test !(mre.b_crit ≈ solve(ModifiedRutherfordProblem(ric; pe=reference.pe, MRE.CALLEN_PRESET..., delta_prime_source=:cylindrical, nw=400)).b_crit) # The general MRE takes the same forcing: F gains exactly the forcing term, which scales as (w_v/w)². kw = (; delta_prime_source=:cylindrical, saturation=true, nw=40) forced = solve(ModifiedRutherfordProblem(ric; pe=reference.pe, kw...)) free = solve(ModifiedRutherfordProblem(ric; pe=reference.pe, forcing=false, kw...)) @test forced.F ≈ free.F .+ forced.forcing rtol = 1e-12 @test forced.forcing .* forced.w' .^ 2 ≈ repeat(forced.forcing[:, 1] .* forced.w[1]^2, 1, 40) rtol = 1e-10 - @test all(>(0), forced.forcing) && isequal(forced.b_crit, free.b_crit) + @test all(>(0), forced.forcing) + @test isequal(forced.b_crit, free.b_crit) @test all(isnan, solve(ModifiedRutherfordProblem(ric; kw...)).b_crit) h5open(joinpath(dir, "gpec.h5")) do f - @test read(f["Tearing/Callen/PerSurface/delta_c"]) ≈ api.delta_c rtol = 1e-10 + @test !haskey(f["Tearing"], "Callen") @test isequal(read(f["Tearing/ModifiedRutherford/PerSurface/b_crit"]), reference.modified_rutherford.b_crit) @test read(f["Tearing/ModifiedRutherford/Terms/forcing"]) ≈ reference.modified_rutherford.forcing recorded = TOML.parse(read(f["Input/gpec_toml_raw"]))["Tearing"] - @test haskey(recorded, "Callen") && recorded["ModifiedRutherford"]["pol_model"] == "callen" + @test recorded["ModifiedRutherford"]["pol_model"] == "callen" end end - # Without a perturbed equilibrium the deck stops at parse time. + # The retired [Tearing.Callen] section stops the parse as an unknown [Tearing] key. mktempdir() do dir deck_dir = _stage_deck(dir; extra_sections=Dict{String,Any}("Tearing" => Dict{String,Any}("Callen" => Dict{String,Any}()))) - @test_throws "needs a [PerturbedEquilibrium]" GPEC.parse_deck(TOML.parsefile(joinpath(deck_dir, "gpec.toml")), deck_dir) + @test_throws "Callen" GPEC.parse_deck(TOML.parsefile(joinpath(deck_dir, "gpec.toml")), deck_dir) end end