From 02c70b87161283ec531a63e70cefa07becb698de Mon Sep 17 00:00:00 2001 From: logan-nc Date: Mon, 17 Aug 2026 10:33:11 -0400 Subject: [PATCH 1/3] SLAYER - BUGFIX - Use the physical toroidal field, not the b0exp normalization run_slayer substituted `equil.config.b0exp` for the toroidal field whenever `[SLAYER] bt` was unset (the default). b0exp is a NORMALIZATION -- commonly exactly 1.0 -- so the layer physics ran at B_T = 1 T while the equilibrium's own F-spline gives ~1.95 T on the DIII-D-like deck. build_slayer_inputs already documents this exact trap: `bt` -- toroidal field [T]. Scalar, callable of `psi`, or `nothing` (default). When `nothing`, the physical `B_T = F(psi)/(2*pi*R_0)` is computed per surface from the equilibrium's F-spline. Note: `equil.config.b0exp` is a *normalization* (often just `1.0`), not the physical field, so passing it as a scalar is almost always wrong. Passing `control.bt` straight through restores that documented default: unset means the physical per-surface field, and an explicitly configured bt still wins. B_T enters c_beta (via the local beta), hence d_beta, hence the layer thickness delta_s, and separately tau_h and so the Lundquist number and D_norm. Layer widths on the DIII-D-like deck move by 3-15%, and the growth rates with them, so this changes SLAYER results -- it is a correction, not a refactor. Found by cross-checking two independent computations of the same layer width that disagreed; with this fix they agree to ~1e-6 (the residual being that one runs on the pass-1 equilibrium and the other on the re-formed one). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PSrf6JCViFfVzqzkQ66o6b --- src/Tearing/Runner/run_slayer.jl | 10 +++++++++- test/runtests_slayer_runner.jl | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Tearing/Runner/run_slayer.jl b/src/Tearing/Runner/run_slayer.jl index 0e254e625..82c49fdfa 100644 --- a/src/Tearing/Runner/run_slayer.jl +++ b/src/Tearing/Runner/run_slayer.jl @@ -361,6 +361,9 @@ profiles they set χ⊥(ψ)/χ_φ(ψ), otherwise the scalar `control.chi_perp`/ `ffs_intr.delta_prime_matrix` (or, if empty, from the diagonal `sing.delta_prime` entries). +The toroidal field comes from `control.bt`; leaving it unset (the default) makes +`build_slayer_inputs` evaluate the physical `B_T = F(ψ)/(2π·R₀)` per surface. + Returns an `enabled=false` `SLAYERResult` when `control.enabled` is false. """ @@ -382,7 +385,12 @@ function run_slayer(equil, ffs_intr, control::SLAYERControl; resistivity_model=_build_resistivity_model(control.resistivity_model), lnLambda_form=control.lnLambda_form) else - bt = control.bt === nothing ? equil.config.b0exp : control.bt + # `equil.config.b0exp` is a NORMALIZATION (commonly exactly 1.0), not the toroidal + # field, so substituting it here silently ran the layer physics at B_T = 1 T. Pass the + # control value through instead: `nothing` makes build_slayer_inputs compute the + # physical B_T = F(psi)/(2*pi*R_0) per surface from the equilibrium's F-spline, which is + # what its docstring already prescribes. + bt = control.bt # χ⊥/χ_φ from the kinetic file when present, else the scalar fallbacks. chi_perp = loaded.chi_perp === nothing ? control.chi_perp : loaded.chi_perp chi_tor = loaded.chi_tor === nothing ? control.chi_tor : loaded.chi_tor diff --git a/test/runtests_slayer_runner.jl b/test/runtests_slayer_runner.jl index 25a28f4fc..8a2333393 100644 --- a/test/runtests_slayer_runner.jl +++ b/test/runtests_slayer_runner.jl @@ -259,4 +259,19 @@ end end end + @testset "toroidal field is the physical B_T, not the b0exp normalization" begin + # run_slayer used to substitute equil.config.b0exp for the toroidal field. That is a + # NORMALIZATION -- commonly exactly 1.0 -- so the layer physics ran at B_T = 1 T while + # the equilibrium's own F-spline gives ~1.95 T on the DIII-D-like deck. c_beta, d_beta + # and hence delta_s all scale with it, so layer widths were off by several percent. + src = read(joinpath(dirname(@__DIR__), "src", "Tearing", "Runner", "run_slayer.jl"), String) + # the substitution itself, not any prose mentioning it + @test !occursin("control.bt === nothing ? equil.config.b0exp", src) + @test occursin(r"^\s*bt = control\.bt\s*$"m, src) + # build_slayer_inputs must still be the thing that resolves `nothing` to the physical + # field, so the default path is documented where it is implemented. + li = read(joinpath(dirname(@__DIR__), "src", "InnerLayer", "SLAYER", "LayerInputs.jl"), String) + @test occursin("F_spline(ψ)) / (2π * R0_use)", li) || occursin("F_spline", li) + end + end From 1fd7974cdac43570966692cc9bd480e74fc4fcf4 Mon Sep 17 00:00:00 2001 From: d-burg Date: Tue, 18 Aug 2026 11:52:56 -0400 Subject: [PATCH 2/3] SLAYER - TEST - Pin the physical-B_T default behaviourally, not by grepping source Addresses the two review points on this branch. Control.jl still documented `bt` as resolving to equil.config.b0exp, which is exactly the behaviour the fix removes and the docstring a user configuring [SLAYER] bt reads. The regression test asserted on the literal text of run_slayer.jl, which couples it to the formatting of a file under active refactoring, cannot catch the bug returning by another route (defaulting control.bt to b0exp in the constructor would leave it passing), and its third assertion was tautological -- the occursin("F_spline", li) fallback holds for any file mentioning F_spline. Replaced with a behavioural check where the equilibrium fixture already lives. Since tau_h = R0*sqrt(mu0*rho)/(n*sval_r*bt), lu is linear in the field actually used, so the resolved bt is recoverable from the returned parameters: the test pins that the default equals F(psi)/(2*pi*R0), that an explicit bt overrides it, and that resolving to b0exp gives a different answer. Co-Authored-By: Claude Opus 5 --- src/Tearing/Runner/Control.jl | 4 +++- test/runtests_slayer_inputs.jl | 28 ++++++++++++++++++++++++++++ test/runtests_slayer_runner.jl | 14 -------------- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/Tearing/Runner/Control.jl b/src/Tearing/Runner/Control.jl index 17128a3d6..1a12ff7e6 100644 --- a/src/Tearing/Runner/Control.jl +++ b/src/Tearing/Runner/Control.jl @@ -29,7 +29,9 @@ constructor. # Physics knobs - - `bt` -- toroidal field [T]. `nothing` → use `equil.config.b0exp` + - `bt` -- toroidal field `[T]`. `nothing` (default) resolves the physical + `B_T = F(ψ)/(2π·R₀)` per surface from the equilibrium's F-spline; a scalar or a + callable of `psi` overrides it - `mu_i` -- ion mass in proton-mass units (default 2.0 for D) - `zeff` -- effective charge - `chi_perp`, `chi_tor` -- fallback perpendicular / toroidal heat diff --git a/test/runtests_slayer_inputs.jl b/test/runtests_slayer_inputs.jl index c0b7d2d7d..c0fb636c3 100644 --- a/test/runtests_slayer_inputs.jl +++ b/test/runtests_slayer_inputs.jl @@ -106,6 +106,34 @@ @test sl[1].Q_i == -sl[1].tauk * profiles.omega_i(0.3) end + @testset "build_slayer_inputs: bt defaults to the physical B_T, not a normalization" begin + # `b0exp` is a normalization (commonly exactly 1.0), not a field. Passing it as `bt` ran + # the layer physics at the wrong toroidal field. Asserted behaviourally rather than by + # grepping the source, so a refactor cannot silently break the check and a reintroduction + # anywhere in the chain still fails: tau_h = R0*sqrt(mu0*rho)/(n*sval_r*bt), so lu ∝ bt + # and the resolved field is recoverable from the returned parameters. + sings = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=2, n=1)] + + sl_default = build_slayer_inputs(equil, sings, profiles; dr_val=0.0, compute_omega_star=false) + sl_explicit = build_slayer_inputs(equil, sings, profiles; bt=2.0, dr_val=0.0, compute_omega_star=false) + + # The default must be the equilibrium's own F-spline field at that surface. + bt_phys = Float64(equil.profiles.F_spline(0.3)) / (2π * equil.ro) + @test bt_phys > 0 + + # lu scales linearly with the field actually used, so the ratio recovers it exactly. + @test sl_default[1].lu / sl_explicit[1].lu ≈ bt_phys / 2.0 rtol = 1e-10 + + # An explicitly configured bt still wins over the physical default. + @test sl_explicit[1].lu != sl_default[1].lu + + # The regression itself: resolving bt to b0exp is not the same as the physical field. + b0exp = equil.config.b0exp + sl_b0exp = build_slayer_inputs(equil, sings, profiles; bt=b0exp, dr_val=0.0, compute_omega_star=false) + @test !isapprox(sl_default[1].lu, sl_b0exp[1].lu; rtol=1e-6) + @test sl_default[1].lu / sl_b0exp[1].lu ≈ bt_phys / b0exp rtol = 1e-10 + end + @testset "build_slayer_inputs: chi_perp/chi_tor as scalars and callables" begin sings = [_mk_sing(psi=0.5, q=2.4, q1=1.2, m=2, n=1)] diff --git a/test/runtests_slayer_runner.jl b/test/runtests_slayer_runner.jl index 7aee33b07..0eacc18ed 100644 --- a/test/runtests_slayer_runner.jl +++ b/test/runtests_slayer_runner.jl @@ -306,19 +306,5 @@ end end end - @testset "toroidal field is the physical B_T, not the b0exp normalization" begin - # run_slayer used to substitute equil.config.b0exp for the toroidal field. That is a - # NORMALIZATION -- commonly exactly 1.0 -- so the layer physics ran at B_T = 1 T while - # the equilibrium's own F-spline gives ~1.95 T on the DIII-D-like deck. c_beta, d_beta - # and hence delta_s all scale with it, so layer widths were off by several percent. - src = read(joinpath(dirname(@__DIR__), "src", "Tearing", "Runner", "run_slayer.jl"), String) - # the substitution itself, not any prose mentioning it - @test !occursin("control.bt === nothing ? equil.config.b0exp", src) - @test occursin(r"^\s*bt = control\.bt\s*$"m, src) - # build_slayer_inputs must still be the thing that resolves `nothing` to the physical - # field, so the default path is documented where it is implemented. - li = read(joinpath(dirname(@__DIR__), "src", "InnerLayer", "SLAYER", "LayerInputs.jl"), String) - @test occursin("F_spline(ψ)) / (2π * R0_use)", li) || occursin("F_spline", li) - end end From a00ecad8962a8f66f8c7f1183894334ca5029c31 Mon Sep 17 00:00:00 2001 From: d-burg Date: Tue, 18 Aug 2026 12:26:00 -0400 Subject: [PATCH 3/3] SLAYER - TEST - Make the B_T assertions deck-independent The b0exp comparison assumed the normalization differs from the physical field. On the Solovev fixture it does not: b0exp = 1.0 and F(psi)/(2*pi*R0) = 1.0 to roundoff, which is precisely why the original defect survived there. CI caught the bad assertion. Pins the resolution rule instead, which holds on any deck: leaving bt unset is identical to passing F(psi)/(2*pi*R0) explicitly, and lu tracks an explicit bt linearly. Where the bug is actually visible -- the DIII-D-like EFIT deck, b0exp = 1.0 against a physical ~1.95 T -- is recorded in a comment. Co-Authored-By: Claude Opus 5 --- test/runtests_slayer_inputs.jl | 40 +++++++++++++++++----------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/test/runtests_slayer_inputs.jl b/test/runtests_slayer_inputs.jl index c0fb636c3..55d6124e9 100644 --- a/test/runtests_slayer_inputs.jl +++ b/test/runtests_slayer_inputs.jl @@ -107,31 +107,31 @@ end @testset "build_slayer_inputs: bt defaults to the physical B_T, not a normalization" begin - # `b0exp` is a normalization (commonly exactly 1.0), not a field. Passing it as `bt` ran - # the layer physics at the wrong toroidal field. Asserted behaviourally rather than by - # grepping the source, so a refactor cannot silently break the check and a reintroduction - # anywhere in the chain still fails: tau_h = R0*sqrt(mu0*rho)/(n*sval_r*bt), so lu ∝ bt - # and the resolved field is recoverable from the returned parameters. + # `b0exp` is a normalization, not a field; passing it as `bt` ran the layer physics at the + # wrong toroidal field. Asserted behaviourally rather than by grepping the source, so a + # refactor cannot silently break the check and a reintroduction anywhere in the chain still + # fails: tau_h = R0*sqrt(mu0*rho)/(n*sval_r*bt), so lu is linear in the field actually used + # and the resolved bt is recoverable from the returned parameters. + # + # Note this fixture cannot exhibit the original bug: Solovev is normalized so that + # b0exp == 1.0 and F(psi)/(2*pi*R0) == 1.0 to roundoff, which is exactly why the defect + # survived. It shows up on a deck whose normalization differs from its field -- the + # DIII-D-like EFIT deck has b0exp = 1.0 against a physical ~1.95 T. What is pinned here is + # therefore the resolution rule itself, which is deck-independent. sings = [_mk_sing(psi=0.3, q=2.0, q1=1.5, m=2, n=1)] - - sl_default = build_slayer_inputs(equil, sings, profiles; dr_val=0.0, compute_omega_star=false) - sl_explicit = build_slayer_inputs(equil, sings, profiles; bt=2.0, dr_val=0.0, compute_omega_star=false) - - # The default must be the equilibrium's own F-spline field at that surface. bt_phys = Float64(equil.profiles.F_spline(0.3)) / (2π * equil.ro) - @test bt_phys > 0 - # lu scales linearly with the field actually used, so the ratio recovers it exactly. - @test sl_default[1].lu / sl_explicit[1].lu ≈ bt_phys / 2.0 rtol = 1e-10 + sl_default = build_slayer_inputs(equil, sings, profiles; dr_val=0.0, compute_omega_star=false) + sl_at_phys = build_slayer_inputs(equil, sings, profiles; bt=bt_phys, dr_val=0.0, compute_omega_star=false) + sl_double = build_slayer_inputs(equil, sings, profiles; bt=2 * bt_phys, dr_val=0.0, compute_omega_star=false) - # An explicitly configured bt still wins over the physical default. - @test sl_explicit[1].lu != sl_default[1].lu + # Leaving bt unset must be identical to passing the physical field explicitly. + @test sl_default[1].lu ≈ sl_at_phys[1].lu rtol = 1e-12 - # The regression itself: resolving bt to b0exp is not the same as the physical field. - b0exp = equil.config.b0exp - sl_b0exp = build_slayer_inputs(equil, sings, profiles; bt=b0exp, dr_val=0.0, compute_omega_star=false) - @test !isapprox(sl_default[1].lu, sl_b0exp[1].lu; rtol=1e-6) - @test sl_default[1].lu / sl_b0exp[1].lu ≈ bt_phys / b0exp rtol = 1e-10 + # An explicit bt overrides, and lu tracks it linearly -- so the field that was actually + # used is what the parameters encode, not merely some field. + @test sl_double[1].lu / sl_default[1].lu ≈ 2.0 rtol = 1e-10 + @test sl_double[1].lu != sl_default[1].lu end @testset "build_slayer_inputs: chi_perp/chi_tor as scalars and callables" begin