diff --git a/.gitignore b/.gitignore index fcb3cc96c..1d1bf86a2 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ scratch/ # Local profiling scratch (one-off study scripts, not part of the package) profiling/ + +# Override-case rundirs materialized beside their example by the regression harness +.regress-override-* diff --git a/regression-harness/cases/diiid_slayer_n2.toml b/regression-harness/cases/diiid_slayer_n2.toml new file mode 100644 index 000000000..3444a51db --- /dev/null +++ b/regression-harness/cases/diiid_slayer_n2.toml @@ -0,0 +1,173 @@ +# Regression case: DIII-D-like H-mode n=2 SLAYER tearing-mode growth rates. +# Reuses the n=1 SLAYER deck via [overrides] at toroidal mode number 2, where the deck's +# qmin = 1.204 puts the 3/2 surface in the plasma. Exists to guard the n-dependence of the +# layer build — in particular the diamagnetic frequencies ω_* ∝ n feeding Q_e/Q_i, which +# no n=1 case can constrain — with the inner rationals 3/2, 4/2, 5/2 as the tracked roots. +# 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_n2" +description = "DIII-D-like H-mode equilibrium, n=2, SLAYER tearing-mode analysis guarding the 3/2 surface and the n-scaling of Q_e/Q_i (reuses the n=1 deck via overrides)" +example_dir = "examples/DIIID-like_SLAYER_example" + +# Run the shared SLAYER deck at n = 2 (no separate n=2 example directory). +[overrides] +"ForceFreeStates.nn_low" = 2 +"ForceFreeStates.nn_high" = 2 + +# Per-surface SLAYER layer parameters (geometry + dimensionless) +[quantities.slayer_ising] +h5path = "Tearing/PerSurface/rational_index" +type = "real_vector" +extract = "all_real" +label = "SLAYER surface indices" +noise_threshold = 0 +order = 10 + +[quantities.slayer_m] +h5path = "Tearing/PerSurface/m" +type = "real_vector" +extract = "all_real" +label = "SLAYER poloidal m" +noise_threshold = 0 +order = 11 + +[quantities.slayer_n] +h5path = "Tearing/PerSurface/n" +type = "real_vector" +extract = "all_real" +label = "SLAYER toroidal n" +noise_threshold = 0 +order = 12 + +[quantities.slayer_rs] +h5path = "Tearing/PerSurface/rs" +type = "real_vector" +extract = "all_real" +label = "SLAYER minor radius rs" +noise_threshold = 1e-10 +order = 13 + +[quantities.slayer_sval_r] +h5path = "Tearing/PerSurface/sval_r" +type = "real_vector" +extract = "all_real" +label = "SLAYER r-based shear" +noise_threshold = 1e-10 +order = 14 + +[quantities.slayer_lu] +h5path = "Tearing/PerSurface/lu" +type = "real_vector" +extract = "all_real" +label = "SLAYER Lundquist S" +noise_threshold = 1e-8 +order = 15 + +[quantities.slayer_D_norm] +h5path = "Tearing/PerSurface/D_norm" +type = "real_vector" +extract = "all_real" +label = "SLAYER D_norm" +noise_threshold = 1e-10 +order = 16 + +[quantities.slayer_P_perp] +h5path = "Tearing/PerSurface/P_perp" +type = "real_vector" +extract = "all_real" +label = "SLAYER P_perp" +noise_threshold = 1e-8 +order = 17 + +[quantities.slayer_tauk] +h5path = "Tearing/PerSurface/tau_k" +type = "real_vector" +extract = "all_real" +label = "SLAYER tauk" +noise_threshold = 1e-12 +order = 18 + +[quantities.slayer_Q_e] +h5path = "Tearing/PerSurface/Q_e" +type = "real_vector" +extract = "all_real" +label = "SLAYER Q_e" +noise_threshold = 1e-12 +order = 20 + +[quantities.slayer_Q_i] +h5path = "Tearing/PerSurface/Q_i" +type = "real_vector" +extract = "all_real" +label = "SLAYER Q_i" +noise_threshold = 1e-12 +order = 21 + +[quantities.slayer_iota_e] +h5path = "Tearing/PerSurface/iota_e" +type = "real_vector" +extract = "all_real" +label = "SLAYER iota_e" +noise_threshold = 1e-12 +order = 19 + +# Tearing eigenvalue (coupled mode → length 1). The headline deliverable: +# a real, nonzero growth rate on a realistic equilibrium. Root-extraction +# is sensitive to the AMR cell topology and ODE solver, so the Q/ω/γ +# thresholds are absolute and modestly loose; re-pin intentionally if a +# solver/AMR change shifts the root inventory. +# Growth-rate / frequency / root outputs. Pinned for the inner three rational +# surfaces only (3/2, 4/2, 5/2) via `first_3`: the Δ'/γ contour search is +# numerically unreliable on the outermost surfaces near the edge, so those are +# deliberately not golden-tracked. +[quantities.slayer_Q] +h5path = "Tearing/Roots/Q_root" +type = "complex_vector" +extract = "first_3_complex" +label = "SLAYER Q_root [3/2,4/2,5/2]" +noise_threshold = 1e-4 +order = 30 + +[quantities.slayer_omega_Hz] +h5path = "Tearing/Roots/omega" +type = "real_vector" +extract = "first_3" +label = "SLAYER ω_Hz [3/2,4/2,5/2]" +noise_threshold = 1.0 +order = 32 + +[quantities.slayer_gamma_Hz] +h5path = "Tearing/Roots/gamma" +type = "real_vector" +extract = "first_3" +label = "SLAYER γ_Hz [3/2,4/2,5/2]" +noise_threshold = 1e-1 +order = 33 + +# no_root flag (1 = extraction failed). Pinned for the inner three surfaces +# so the 3/2, 4/2, 5/2 root inventory cannot drift silently. +[quantities.slayer_no_root] +h5path = "Tearing/Roots/no_root" +type = "real_vector" +extract = "first_3" +label = "SLAYER no_root flags [3/2,4/2,5/2]" +noise_threshold = 0 +order = 34 + +# Settings (catches accidental config drift) +[quantities.slayer_enabled] +h5path = "Tearing/enabled" +type = "int_scalar" +extract = "value" +label = "SLAYER enabled flag" +noise_threshold = 0 +order = 90 + +[quantities.runtime] +h5path = "" +type = "runtime" +extract = "value" +label = "Runtime (s)" +noise_threshold = 0.0 +order = 999 diff --git a/regression-harness/src/runner.jl b/regression-harness/src/runner.jl index ed7e5f4d5..048276b2a 100644 --- a/regression-harness/src/runner.jl +++ b/regression-harness/src/runner.jl @@ -27,15 +27,17 @@ end Materialize the directory GPEC will actually run in. With no `overrides`, the example deck runs in place (returns it untouched). With overrides, -the deck is copied to a throwaway temp dir and the named gpec.toml keys are patched there, -so one shared example can serve several cases (e.g. a collisionless variant via -`"KineticForces.nutype" => "zero"`). Override keys are dotted paths into the TOML; missing -intermediate tables are created. Returns `(rundir, is_temp)`; the caller removes the temp -tree when `is_temp`. +the deck is copied to a throwaway sibling of the example dir and the named gpec.toml keys are +patched there, so one shared example can serve several cases (e.g. a collisionless variant via +`"KineticForces.nutype" => "zero"`). The copy sits beside the original so relative file +references in the deck (e.g. `SLAYER.profile_file = "..//..."`) still resolve. +Override keys are dotted paths into the TOML; missing intermediate tables are created. +Returns `(rundir, is_temp)`; the caller removes `rundir` when `is_temp`. """ function _materialize_rundir(example_path::String, overrides::Dict{String,Any}) isempty(overrides) && return (example_path, false) - rundir = joinpath(mktempdir(), "deck") + rundir = joinpath(dirname(example_path), ".regress-override-" * basename(example_path)) + rm(rundir; recursive=true, force=true) # stale leftover from a crashed run cp(example_path, rundir) rm(joinpath(rundir, "gpec.h5"); force=true) # drop any stale output copied along cfg = TOML.parsefile(joinpath(rundir, "gpec.toml")) @@ -523,7 +525,8 @@ function run_local(db::SQLite.DB, case_spec::CaseSpec, repo_root::String; rm(runinfo_file; force=true) end if rundir_is_temp && rundir !== nothing - rm(dirname(rundir); recursive=true, force=true) + # rundir sits beside the example; removing its parent would delete examples/ itself + rm(rundir; recursive=true, force=true) end end end @@ -647,7 +650,8 @@ function run_at_commit(db::SQLite.DB, commit_hash::String, ref_name::String, rm(runinfo_file; force=true) end if rundir_is_temp && rundir !== nothing - rm(dirname(rundir); recursive=true, force=true) + # rundir sits beside the example; removing its parent would delete examples/ itself + rm(rundir; recursive=true, force=true) end if own_worktree && worktree_path !== nothing remove_worktree(worktree_path, repo_root) diff --git a/src/InnerLayer/SLAYER/LayerInputs.jl b/src/InnerLayer/SLAYER/LayerInputs.jl index 96177903d..294090295 100644 --- a/src/InnerLayer/SLAYER/LayerInputs.jl +++ b/src/InnerLayer/SLAYER/LayerInputs.jl @@ -217,20 +217,23 @@ function build_slayer_inputs(equil, sings, profiles::KineticProfiles; da_dpsi = _da_dpsi_at(psi) sval_r = r_based_shear(rs, q, q1, da_dpsi) - prof = profiles(psi) - # Override ω_*e, ω_*i with spline-derivative values when requested. - ω_e_use, ω_i_use = if compute_omega_star - _omega_star_at(psi) - else - (prof.omega_e, prof.omega_i) - end - # Resonant (m, n): take the first element of the mode-number vectors. # Parallel-FM `sing.m`/`sing.n` hold exactly one entry each; ideal # DCON may hold multiple — we pick the first and document the choice. m_res = sing.m[1] n_res = sing.n[1] + prof = profiles(psi) + # Override ω_*e, ω_*i with spline-derivative values when requested. In flux coordinates + # ω_* = n·(dp/dψ)/(e·n_e); `_omega_star_at` returns the n = 1 value, so restore the + # factor n here. Values supplied through `profiles` are taken to be physical already. + ω_e_use, ω_i_use = if compute_omega_star + ωe1, ωi1 = _omega_star_at(psi) + (n_res * ωe1, n_res * ωi1) + else + (prof.omega_e, prof.omega_i) + end + # Pull geometric trapped-fraction inputs from ResistGeometry when # available (populated by ForceFreeStates.resist_eval_all!); else # fall back to nothing and let slayer_parameters compute them from diff --git a/test/runtests_slayer_inputs.jl b/test/runtests_slayer_inputs.jl index 55d6124e9..d4d198e20 100644 --- a/test/runtests_slayer_inputs.jl +++ b/test/runtests_slayer_inputs.jl @@ -183,4 +183,20 @@ @test sl isa Vector{SLAYERParameters} @test isempty(sl) end + + @testset "build_slayer_inputs: omega_star carries the toroidal mode number" begin + # In flux coordinates ω_* = n·(dp/dψ)/(e·n_e): resolving the same surface at n = 1 + # and n = 2 must double Q/tauk = -ω_*, while the ratio iota_e must not move. + # Asserted through the returned parameters so the check survives a refactor of + # where the factor is applied. + s1 = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=2, n=1)] + s2 = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=4, n=2)] + sl1 = build_slayer_inputs(equil, s1, profiles; bt=2.0, dr_val=0.0) + sl2 = build_slayer_inputs(equil, s2, profiles; bt=2.0, dr_val=0.0) + + @test sl2[1].Q_e / sl2[1].tauk ≈ 2 * (sl1[1].Q_e / sl1[1].tauk) rtol = 1e-12 + @test sl2[1].Q_i / sl2[1].tauk ≈ 2 * (sl1[1].Q_i / sl1[1].tauk) rtol = 1e-12 + @test sl2[1].iota_e ≈ sl1[1].iota_e rtol = 1e-12 + end + end