Skip to content

KineticForces - BUG FIX - Collisionless (nutype=zero) energy integral NaN + nuzero sentinel (#281) - #282

Merged
logan-nc merged 8 commits into
test/kinetic-pv-regression-validationfrom
bugfix/kinetic-collisionless-nan
Jun 21, 2026
Merged

KineticForces - BUG FIX - Collisionless (nutype=zero) energy integral NaN + nuzero sentinel (#281)#282
logan-nc merged 8 commits into
test/kinetic-pv-regression-validationfrom
bugfix/kinetic-collisionless-nan

Conversation

@logan-nc

Copy link
Copy Markdown
Collaborator

Summary

Fixes the collisionless (nutype="zero") energy-integral NaN tracked in #281 (deferred out of #280), and adds the end-to-end nuzero regression sentinel that was blocked on it.

Stacked on #280 (test/kinetic-pv-regression-validation) — it depends on that PR's nutype threading. Base will be retargeted to develop once #280 merges.

The bug

The collisionless energy integral (src/KineticForces/EnergyIntegration.jl) produced NaN / a QuadGK DomainError whenever a resonance pole fell in the Maxwellian tail (x_res ≳ 35). Two mechanisms, both rooted in the u = 1 − exp(−x) substitution compressing the real-axis pole toward u = 1:

  • u_res = 1 − exp(−x_res) rounds to 1.0, so R ∝ (1−u_res) = 0 while log1p(−u_res) = −Inf0·(−Inf) = NaN;
  • and, in real x-space, the resonance denominator Ω(x) = leff·wb·√x + n·(we+wd·x) — a difference of O(10⁴)-magnitude terms — rounds to exactly 0.0 over a ~ULP-wide window (granularity ulp(1e4) ≈ 1.8e-12), where the physical term N·exp(−x)/(iΩ) is 0/0. Neither a pole-location Newton refinement (Ω already rounds to 0) nor a one-ULP nudge (the whole window is flat-zero) escapes it.

The fix

Split the energy integral by collisionality (integrate_energy dispatches on _energy_collision_frequency(1.0,p) == 0.0):

  • Collisional (ν > 0): unchanged — keeps the u-space [0,1) integral with the off-axis complex-pole add-back. The harness confirms byte-identical output (see below).
  • Collisionless (ν ≡ 0): new real-x-space branch (_integrate_energy_collisionless) over [0, xmax = 72], matching Fortran PENTRC energy.f90. Each real pole is removed by analytic principal-value + residue (causal ν→0⁺ branch ∓iπ·sign(Ω′)), an improvement over the deprecated ximag contour offset. Inside the flat-Ω window the integrand is replaced by the analytic Laurent regular-part limit[h′ − h·Ω″/(2Ω′)]/(iΩ′) (h = N·e^{−x}), via new _collisionless_regular_part + _energy_numerator_deriv.

Implementation note: the x-space route was chosen to keep the real-axis pole well-conditioned (and faithful to Fortran), but it still required the analytic regular-part limit because Ω's catastrophic cancellation makes a multi-float flat-zero window — there is no way around handling the singularity analytically there.

Validation

  • Physics:fortran-physics-reviewer independently re-derived the residue, the causal ∓iπ·sign(Ω′) branch, the regular-part Laurent limit (the crux), the numerator derivative, and the xmax=72 truncation — all match the code (PASS), against pentrc/energy.f90 and Logan & Park 2013 Eq. (8).
  • Regression harness (regression-guardian):diiid_n1, solovev_n1, solovev_multi_n, solovev_kinetic_calculated are 0.0-diff vs develop (81 quantities, zero regressions — the collisional/ideal paths are byte-identical).
  • nuzero sentinel: Solovev calculated collisionless et[1] = 15.885 − 0.482i. Re sits on the harmonic 15.888 to 0.02% (collisions are a small perturbation to the real energy); Im is the resonant ∓iπ damping, smaller than the harmonic −0.711 as expected. Runs in ~68 s (≤ 2 min budget).
  • 124 runtests_kinetic.jl unit tests pass (incl. a new tail-pole case in the ν→0⁺-limit test); x_res sweep 0.5…800 is NaN-free; runtests_fullruns.jlex6 added.

Changes

  • src/KineticForces/EnergyIntegration.jl — collisionless x-space branch + regular-part limit + dispatch.
  • test/test_data/regression_solovev_kinetic_nuzero/ — new fixture ([KineticForces] nutype="zero").
  • regression-harness/cases/solovev_kinetic_nuzero.toml — new harness case.
  • test/runtests_fullruns.jl (ex6), test/runtests_kinetic.jl (tail-pole case).

Closes#281.

🤖 Generated with Claude Code

logan-ncand others added 2 commits June 13, 2026 23:38
The collisionless (nutype="zero", or any nu==0 config) energy integral produced
NaN/DomainError for resonance poles in the Maxwellian tail (x_res >~ 35): under
the u = 1-exp(-x) substitution u_res rounds to 1.0, so the residue weight
R proportional to (1-u_res) collapses to 0 while log1p(-u_res) -> -Inf, tripping
0*(-Inf) = NaN; in the [35,36] band a Gauss-Kronrod node coincident with the
breakpoint gives 0/0 = NaN (QuadGK DomainError).
Root cause is the u-substitution compressing the real-axis pole toward u=1. Fix:
dispatch the structurally-collisionless case to a dedicated real-x-space integral
over [0, X_ENERGY_MAX=72] (matching Fortran PENTRC energy.f90), where the pole
x_res in (0,72) stays a well-conditioned O(1) number. Each pole is removed by
analytic principal-value + residue (causal nu->0+ branch -/+ i*pi). The
collisional (nu>0) path keeps the u-space formulation unchanged (off-axis complex
pole, no truncation) - byte-identical output.
Verified: x_res sweep 0.5..800 all finite; matches the nu->0+ harmonic limit to
~1e-6; 120 kinetic unit tests pass. Addresses #281.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nce poles
Follow-up to the x-space collisionless energy integral: the real-axis resonance
denominator Ω(x) = leff·wb·√x + n·(we+wd·x) is a difference of O(1e4)-magnitude
terms that cancel to ~0 at the pole, so it rounds to exactly 0.0 over a ~ULP-wide
window (granularity ulp(1e4) ≈ 1.8e-12). Across that window the physical term
N·exp(-x)/(iΩ) is 0/0 → NaN (QuadGK DomainError), which neither a Newton pole
refinement (Ω already rounds to 0) nor a one-ULP nudge (whole window is flat-zero)
can escape.
Fix: in the flat-Ω window QuadGK is handed the analytic Laurent regular-part
limit of the pole-subtracted integrand, [h′ - h·Ω″/(2Ω′)]/(iΩ′) with h = N·exp(-x)
(new _energy_numerator_deriv + _collisionless_regular_part). Other poles' finite
subtractions are retained. Collisional (ν>0) path is untouched.
Also adds the nutype="zero" regression sentinel:
- test fixture regression_solovev_kinetic_nuzero (calculated path, [KineticForces]
nutype="zero"),
- regression-harness case solovev_kinetic_nuzero,
- fullruns ex6 and a tail-pole case in the runtests_kinetic ν→0⁺ limit test.
Validation: Solovev calculated et[1] = 15.885 - 0.482i (finite). Re sits on the
harmonic 15.888 to 0.02% (collisions are a small perturbation to the real energy);
Im is the resonant ∓iπ damping, smaller than the harmonic -0.711 as expected.
Runtime 23s. 124 kinetic unit tests pass; x_res sweep 0.5..800 NaN-free. Closes#281.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@logan-nc

Copy link
Copy Markdown
CollaboratorAuthor

@claude review this pr

@claude

claudeBot commented Jun 15, 2026

Copy link
Copy Markdown

Code Review

Overview

This PR fixes a real NaN bug (nutype=\"zero\" energy integral blowing up for resonances deep in the Maxwellian tail) by splitting the collisionless path into a dedicated real-x-space integral. The mathematical approach (PV + residue + Laurent regular-part limit) is sound and matches the Fortran PENTRC convention. Good test coverage: new unit test for the tail-pole case, a new end-to-end fixture, and a regression harness case.


Issues

1. Dispatch on function call instead of field value (EnergyIntegration.jl, new dispatch block in integrate_energy)

if_energy_collision_frequency(1.0, p) ==0.0return_integrate_energy_collisionless(p, leff, wb, n, we, wd, atol, rtol)
end

Since p.nutype is a String field directly accessible on EnergyParams, and _energy_collision_frequency is just a switch on that string, this is an indirect way to write p.nutype == "zero". The comment tries to justify the indirection ("ν vanishes identically iff it vanishes at any x"), but it actually introduces a subtle assumption: that any collision frequency returning 0.0 at x=1.0 is identically zero everywhere. That is currently true only because nutype=\"zero\" returns a literal 0.0, not by general contract. Use p.nutype == \"zero\" directly — it is explicit, no floating-point comparison, and reads as an intent check rather than a numerical probe.


2. Reactive NaN guard in the integrand closure (_integrate_energy_collisionless)

if!isfinite(val)
# find nearest pole k, replace val with _collisionless_regular_part...end

This works in practice (the only non-finite path is the flat-Ω window around the pole), but it is reactive: it intercepts NaN after the fact and silently replaces val with the regular part of the nearest pole. If a different, unexpected source of NaN were introduced later (e.g., in _energy_integrand_real under some edge-case parameter), the guard would silently return a wrong-but-finite value with no indication anything went wrong.

A proactive threshold check is more explicit:

k =argmin(abs(x - xp) for xp in x_poles) # or the existing loopifabs(x - x_poles[k]) < threshold
val =_collisionless_regular_part(x_poles[k], p, leff, wb, n, wd)
...else
val =_energy_integrand_real(x, p)
...end

The threshold would need to cover the flat-Ω ULP window — roughly eps(Float64) * abs(x_poles[k]). This approach also avoids evaluating _energy_integrand_real at all for the degenerate points, removing the NaN generation entirely.


3. Nested @test inside @test begin ... true end (runtests_fullruns.jl, ex6 block)

@testbegin...@testisfinite(real(et[1]))
@testreal(et[1]) 15.885 rtol =0.01...trueend

If any inner @test fails, the outer block still returns true and records an outer PASS alongside the inner failures. This is a pre-existing pattern in the file (ex1–ex5 do the same), so not a regression introduced here, but it means the outer @test is vacuous. The inner assertions do register independently in the test log, so failures are visible — but the signal is muddied. Worth fixing consistently across the file when convenient.


Minor

  • unique(sort(x_poles))sort already returns a copy in Julia so x_poles is safe, but the comment explaining why ("Sort a copy — x_poles must stay aligned with residues") correctly documents the intent. No issue, just confirming it is safe.
  • The new gpec.toml fixture omits [PerturbedEquilibrium] and [ForcingTerms] sections — consistent with the existing regression_solovev_kinetic_calculated/gpec.toml, which does the same. Fine.
  • X_ENERGY_MAX = 72.0 as a module-level const with a comment explaining the Fortran provenance is the right approach.

Summary

The math and the fix are correct. Items 1 and 2 are the issues worth addressing before merge: item 1 is a one-line change, item 2 requires a bit more refactoring but eliminates a potential silent-wrong-answer path.

…residue path
Fold the two energy-integral paths (u-space for ν>0, real x-space for ν=0)
into a single real-x-space principal-value + residue integrator,
`_integrate_energy_resonant`. The pole is `x_pole = x_res − i·ν/Ω′` — off the
real axis for ν>0, on it for ν=0 — so the collisionless case is the exact ν→0
limit of one formula rather than a separate branch. The causal ∓iπ·sign(Ω′)
retarded branch now falls out of the signed zero of `pole_offset = ν/Ω′`
(verified to 1e-14 for both signs of Ω′), and the flat-Ω regular-part limit
(`_real_pole_regular_part`, renamed) only ever fires for ν=0.
Deletes the ~40-line u-space collisional block (`u_poles`/`u_breaks`, the
`u=1−exp(−x)` substitution, the `xr>700` guard). Net −47 lines.
The collisional harmonic path now routes through x-space instead of u-space,
moving `solovev_kinetic_calculated` et[1] by ~1e-6 relative (per-integral
reassociation ~5e-12 amplified through the ill-conditioned eigenproblem; harness
0.00%, inside the fullruns rtol brackets). The 3 ideal harness cases are
0.0-diff. Collisionless ν=0 output is unchanged from #282.
Verified: fortran-physics-reviewer PASS (residue, add-back, causal branch,
regular-part guard, truncation); 126 runtests_kinetic + 24 runtests_fullruns
green; regression harness 3 ideal cases 0.0-diff. Adds an Ω′<0 collisionless
causal-branch unit test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@logan-nc

Copy link
Copy Markdown
CollaboratorAuthor

Follow-on: unified onto a single x-space integrator (0d7c02c)

Per reviewer feedback that the two parallel integration paths were a maintainability cost, the collisional (ν>0) and collisionless (ν=0) cases are now one real-x-space PV+residue integrator, _integrate_energy_resonant. The pole is x_pole = x_res − i·ν/Ω′ (off-axis for ν>0, on-axis for ν=0), so the collisionless case is the exact ν→0 limit of one formula. The causal ∓iπ·sign(Ω′) retarded branch now falls out of the signed zero of pole_offset = ν/Ω′ (verified to 1e-14 for both signs of Ω′); the flat-Ω regular-part limit only ever fires for ν=0. The ~40-line u-space collisional block is deleted (net −47 lines).

This supersedes two claims in the PR description above:

  • The collisional ν>0 path is no longer "unchanged / byte-identical" — it now routes through x-space.
  • Consequently solovev_kinetic_calculated (harmonic) moves by ~1e-6 relative on et[1] (per-integral reassociation ~5e-12 amplified through the ill-conditioned eigenproblem; harness reports 0.00%, well inside the fullruns rtol brackets). The 3 ideal harness cases remain 0.0-diff. Collisionless ν=0 output is unchanged from the prior commit.

Validation:fortran-physics-reviewer PASS on residue, add-back, the signed-zero causal branch, the ν=0-only regular-part guard, and truncation — confirmed faithful to Fortran energy.f90 and Logan & Park 2013 Eq. (8). 126 runtests_kinetic (+ a new Ω′<0 causal-branch test) and 24 runtests_fullruns green; regression harness shows the 3 ideal cases 0.0-diff.

Post-merge note: re-cache the solovev_kinetic_calculated harness baseline at the new x-space values (regress --cases solovev_kinetic_calculated --refs local --force) so future comparisons start from the unified path.

…uns to smoke (closes#273)
Split the two conflated jobs the nerfed Solovev-kinetic cases were doing:
- REGRESSION (harness): repoint the two kinetic cases at new full-band example
decks examples/Solovev_kinetic_{calculated,nuzero}_example (mpsi=16,
delta_mlow=delta_mhigh=8 → mpert=32), so the pinned eigenvalue is the
mode-converged value (calc et[1]≈1.84−1.41i, nuzero≈1.72−1.08i) rather than the
mpert=8-truncated 15.9. Mirrors how the ideal cases already point the harness at
examples/ decks while fullruns use the nerfed test/test_data fixtures. Runtime
~105-120s/case (within the ~2-min budget). NB: the mpert=32 value is a Julia
self-consistent anchor — the Fortran cross-check in
benchmark_solovev_kinetic_stability.jl is locked to mpert=8 (15.888 vs 15.619).
- TEST (fullruns ex3-ex6): relax to run-through + physical-sanity smoke tests on
the nerfed fixtures (isfinite, total energy > 0, damping imag < 0). Drop the
numeric value pins — including the platform-fragile imag(et[1])≈-0.711 rtol=0.08
pin that failed on macOS aarch64 (-0.856). Closes#273.
The mode band (not mpsi) is the dominant cost+physics lever here; the auto-mpsi
(log_asymptotic) path is a no-op for analytic Solovev (no separatrix → flat slope
estimate → near-uniform ~16-surface grid).
Verified: 126 runtests_kinetic + 17 runtests_fullruns green; harness runs both new
decks within budget at the expected mpert=32 values.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@logan-nc

Copy link
Copy Markdown
CollaboratorAuthor

Follow-on: kinetic regression on a mode-converged deck; fullruns → smoke tests (closes #273, 8bf5d01)

Separated the two jobs the nerfed Solovev-kinetic cases were conflating:

Regression harness — the two kinetic cases now point at new full-band example decks examples/Solovev_kinetic_{calculated,nuzero}_example (mpsi=16, delta_mlow=delta_mhigh=8mpert=32). This pins the mode-converged eigenvalue (calc et[1]≈1.84−1.41i, nuzero ≈1.72−1.08i) instead of the mpert=8-truncated 15.9. Mirrors the existing pattern (ideal harness cases point at examples/ decks; fullruns use the nerfed test/test_data fixtures). Runtime ~105–120s/case (within the ~2-min budget).

The mode band — not mpsi — is the dominant lever: restoring delta_m=8 moves et[1] ~8× (mpert=8→32). The pinned mpert=32 value is a Julia self-consistent anchor; the Fortran cross-check in benchmark_solovev_kinetic_stability.jl is locked to mpert=8 (15.888 vs 15.619, 1.7%). (The auto-mpsi log_asymptotic path is a no-op for analytic Solovev — no separatrix → flat slope estimate → near-uniform ~16-surface grid.)

Tests (fullruns ex3–ex6) — relaxed to run-through + physical-sanity smoke tests on the nerfed fixtures (isfinite, total energy > 0, damping imag < 0). Dropped the numeric value pins, including the platform-fragile imag(et[1]) ≈ −0.711 rtol=0.08 that failed on macOS aarch64 (−0.856). Closes #273. The precise collisionless-integral correctness (#281) stays guarded by the deterministic runtests_kinetic.jl unit tests.

Validation: 126 runtests_kinetic + 17 runtests_fullruns green; harness runs both new decks within budget at the expected mpert=32 values.

So this PR now: fixes the collisionless NaN (#281), unifies the energy integral onto one x-space path, and re-architects the kinetic coverage into fast smoke tests + mode-converged harness pins (closing #273).

@logan-nclogan-nc self-assigned this Jun 20, 2026
@logan-nclogan-nc added the bug Something isn't working label Jun 20, 2026
logan-ncand others added 4 commits June 20, 2026 13:26
…files; collapse nu=0 deck via harness override
Make the Solovev kinetic example physically rigorous and remove the duplicate
collisionless deck.
Profiles: replace the invented parabolic kinetic.dat with profiles tied to the
Solovev equilibrium pressure P(psi)=P0*(1-psi), P0~4.27e4 Pa (beta_axis~11%).
T(psi)=100+2600*(1-psi) eV (core 2700, edge 100); n=P0*(1-psi)/(2*e*T) (core
~4.9e19) with a small edge density floor so T stays finite. 2*n*T matches P_eq
to ~1e-4. Written to the example deck and both kept test fixtures with a
provenance header.
nu=0 collapse: delete examples/Solovev_kinetic_nuzero_example. Add a generic
[overrides] mechanism to the regression harness (CaseSpec.overrides ->
_materialize_rundir copies the deck to a temp dir, patches dotted gpec.toml
keys, cleans up). solovev_kinetic_nuzero now reuses the calculated deck with
KineticForces.nutype="zero". Re-baselined: calculated et[1]~1.894-1.525i,
nuzero et[1]~2.333-2.174i.
Reader robustness: KineticProfiles readdlm(...; comments=true) so a '#'
provenance header cannot widen the parsed matrix and pad the data rows.
Deck migration: port the kinetic decks off the removed bal_flag/mer_flag onto
local_stability_flag and drop the deprecated delta_mband (the develop merge
removed those keys, which was breaking every kinetic run on this branch).
Verified: fullruns 17/17 green (ex5/ex6 with new profiles), harness override
runs end-to-end with the same equilibrium and distinct collisionless damping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…100 and harden boundary-pole handling
Manual PR review asked whether the real-x-space energy integral's upper limit
X_ENERGY_MAX could be chosen more robustly, and whether the approach is robust to
a resonance pole sitting at or within ~1e-15 of that limit (the direct analog of
the original u→1 NaN this branch fixed).
Findings: the integrand AND every resonance pole decay as x^p·exp(-x) (p ≤ 3.5 with
the qt heat-flux factor) — at a simple pole Ω′ is O(the drift frequencies), so a
dropped pole's residue scales as xr^2.5·exp(-xr), the same law as the smooth tail.
exp(-x) does not underflow until x≈746, so the Fortran default 72 was a tolerance
edge, not a precision edge. Benchmarking confirmed a tighter, tolerance-derived
limit buys no speed (QuadGK accepts the near-zero tail in a single panel; 72→100 is
byte-identical eval count). So extend the fixed limit 72 → 100 (100^3.5·exp(-100) ≈
4e-37, ~30 orders below any tolerance) and document the real justification.
A kept pole (xr < X_ENERGY_MAX) is subtracted analytically/exactly and is robust at
any proximity to the limit; the xr >= X_ENERGY_MAX guard prevents the only NaN risk,
log(X_ENERGY_MAX - x_pole)=log(0), at the exact endpoint. Near-degenerate poles
(Ω′→0) are handled by the SINGULAR_EPS guard, not by the limit. Added a regression
testset forcing a resonance to exactly X_ENERGY_MAX and ±1e-13 around it, asserting
finiteness, continuity across the boundary, and agreement with the ν→0⁺ limit.
All 200 kinetic unit tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@logan-nc
logan-nc merged commit 8f6f68d into test/kinetic-pv-regression-validationJun 21, 2026
2 checks passed
@logan-nc
logan-nc deleted the bugfix/kinetic-collisionless-nan branch June 21, 2026 01:06
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

KineticForces — collisionless (nutype="zero") energy integral NaNs on real resonance poles; add nuzero regression sentinel once fixed

1 participant

@logan-nc