Skip to content

KineticForces - FEATURE! - Multi-main-ion NTV (summed ions + impurity + electrons, both paths) - #339

Merged
logan-nc merged 37 commits into
developfrom
feature/multi-ion-ntv
Aug 20, 2026
Merged

KineticForces - FEATURE! - Multi-main-ion NTV (summed ions + impurity + electrons, both paths)#339
logan-nc merged 37 commits into
developfrom
feature/multi-ion-ntv

Conversation

@logan-nc

@logan-nc logan-nc commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Release note

  • Audience: users
  • Numerical impact: solovev_kinetic_calculated and its collisionless nuzero variant are re-baselined because their shared deck now declares a 50/50 D-T mix (Re(et[1]) +1.51%, Im(et[1]) +0.93%); diiid_n1, solovev_n1 and solovev_kinetic_ntv are unchanged (harness @ 0e21828)
  • Migration: electron = true now adds electrons in addition to the ion species rather than computing them instead of the ions, and per-species NTV output moved from flat top-level kinetic_forces_ groups into the KineticForces/PerSpecies/ subtree.

A plasma can now declare an arbitrary list of main-ion species, each with its own charge, mass and density, and the NTV is computed per species on one shared full-composition Zeff and summed over the main ions, the quasineutrality-closing impurity and the electrons. This applies to both the post-PE ψ-quadrature diagnostic and the self-consistent kinetic matrices, and replaces the previous workaround of running each ion separately, which gave every run the wrong Zeff.

Regression report

regress --cases diiid_n1,solovev_n1,solovev_kinetic_ntv,solovev_kinetic_nuzero,solovev_kinetic_calculated,solovev_kinetic_multiion --refs develop,local (develop @ 14a3c9d6)

diiid_n1                     47 unchanged
solovev_n1                   21 unchanged
solovev_kinetic_ntv           6 unchanged
solovev_kinetic_nuzero        5 changed, 9 unchanged
solovev_kinetic_calculated    6 changed, 8 unchanged
solovev_kinetic_multiion      new case; develop ref fails as expected
                              ("Example directory not found"), local values recorded

solovev_kinetic_calculated      develop         local           Diff
total energy Re(et[1])          1.845905e+00    1.873742e+00    2.784e-02 (1.51%)
total energy Im(et[1])         -1.421059e+00   -1.434284e+00    1.322e-02 (0.93%)
plasma energy Re(ep[1])        -8.593965e+00   -8.563835e+00    3.013e-02 (0.35%)
vacuum energy Re(ev[1])         1.043987e+01    1.043758e+01    2.293e-03 (0.02%)

solovev_kinetic_nuzero (collisionless override of the same deck)
total energy Re(et[1])          2.245440e+00    2.243336e+00    2.103e-03 (0.09%)
total energy Im(et[1])         -2.034869e+00   -2.026028e+00    8.841e-03 (0.43%)

Every changed row is confined to the two cases sharing the calculated deck, which this PR deliberately switched to a 50/50 D-T mix so the multi-ion kinetic-matrix species loop is pinned end-to-end. diiid_n1's NTV torque and dW rows are exactly 0.0e+00, so the single-ion paths are untouched and the movement is the fixture change alone.

Notes for reviewers

Since the last review round this branch also merged develop and restored develop's #367 immutability freeze on KineticForcesControl, which an earlier develop merge had silently reverted here because the species loop mutated a deepcopy of the run control. Each species' control is now built with the @kwdef keyword constructor — the idiom that freeze commit documents. The multi-ion example produces bit-identical per-species torques before and after that rewrite.


Summary

Adds correct multi-main-ion NTV to KineticForces. A plasma can now declare an arbitrary list of main-ion species (each with its own z, m, and density), and the NTV is computed per species on one shared full-composition Zeff and summedτ = Σ_s τ_s — over the main ions, the neutrality-closing impurity, and the electrons. This applies identically to both NTV paths:

  • the KineticForces ψ-quadrature (post-PE diagnostic), and
  • the self-consistent DCON kinetic matrices (Mode A) — the kinetic W/torque matrices are additive over species, accumulated in a species loop.

Previously only a single main ion was supported (D-T had to be run per-species externally and summed, which double-used the n_i column as both the species density and the Zeff source — giving each ion run the wrong Zeff ≈ 3.7 instead of the composition Zeff ≈ 1.4). This is an extension beyond Fortran PENTRC (also single main ion), consistent with Logan & Park PoP 2013.

Design (physics-reviewed)

  • One canonical path: each species resolves to a per-species density profile n_s(ψ); fraction (share of the total n_i) and explicit HDF5 density arrays both feed it (normalize-at-the-boundary).
  • Two distinct densities kept separate: resonant n_s (per-species torque prefactor) vs the collision field density (total n_main + shared composition Zeff + species z_s², m_s).
  • Composition multi_ion_composition: Zeff = (Σ z_s² n_s + z_imp² n_imp)/n_e, impurity closes quasineutrality; reduces exactly to the single-ion formula for one z=1 ion.
  • Unified electron flag: electron = true always adds the electron NTV in addition to the ion species, on both the single-ion and multi-ion paths (a single-ion + electron run resolves as {main ion, impurity, electron} summed). The Fortran PENTRC electron-instead-of-ions behavior was an IO limitation and is deliberately not reproduced. electron = false with an empty ion_species list is bit-for-bit unchanged.
  • Input validation: fraction lists can never sum above 1 (shares of n_i); an all-fraction list must sum to 1 (in mixed fraction/density lists the impurity content is set by the file's n_i/n_e deficit); every main-ion z < zimp (zpitch-closure pole at Zeff = zimp, with a near-pole @warn); hard error on any negative density.

API

[KineticForces]
kinetic_file = "kinetic.dat"   # n_i column = TOTAL main-ion density
electron = true                # adds electrons to the species sum
zimp = 6; mimp = 12            # impurity (closes quasineutrality; summed as its own species)

[[KineticForces.ion_species]]
z = 1; m = 2; fraction = 0.5   # Deuterium
[[KineticForces.ion_species]]
z = 1; m = 3; fraction = 0.5   # Tritium

Empty ion_species + electron = false ⇒ single main ion from zi/mibit-for-bit unchanged. Explicit measured profiles via named HDF5 datasets (density = "n_D"; datasets must be named n_* and round-trip through write_kinetic_h5). Docs: new "Multi-ion runs" section in docs/src/kinetic_forces.md.

Validation

  • Unit tests (test/runtests_multiion.jl, 46): composition (exact single-ion reduction; 50/50 D-T Zeff; quasineutrality), TOML conversion, resolver (single-species reduction, D-T-e set, ν∝1/√m, one-of-{fraction,density}, unified electron semantics), explicit HDF5 profiles + write/read roundtrip, all validation guards, union-grid summing incl. duplicate-node guard.
  • Regression cases: solovev_kinetic_multiion (ψ-quadrature path: summed total + per-species NTV) and solovev_kinetic_calculated now runs 50/50 D-T (self-consistent kinetic-matrix species loop; deliberate baseline change et[1]: 1.846−1.421i → 1.874−1.434i, with the collisionless nuzero override variant shifting accordingly).
  • End-to-end (ITER n=3 TC-24): KF path summed 4 species (D+T+C+e), total = exact per-species sum; Mode A self-consistent kinetic-matrix path completed with the summed matrices.
  • Two independent review rounds applied (claude-bot + krystophny + a 3-agent physics/clean-code/performance audit): test-particle z² Krook form confirmed (deflection-off-background; exact z=1 reduction), resonant-n_s vs collision-n_main separation confirmed, additive matrix accumulation confirmed (no ideal F/K/G double-count), :static threading on the kinetic-matrix ψ-loop.
  • Regression report vs develop: see PR comment.

Behavior changes (deliberate)

  1. electron = true with empty ion_species now computes ion + impurity + electron NTV summed (was: electron only). No shipped example or regression case used the old mode.
  2. solovev_kinetic_calculated baseline re-blessed for the D-T fixture change.

🤖 Generated with Claude Code

logan-nc and others added 15 commits July 29, 2026 10:54
The lnLambda formula 17.3 - 0.5*log(ne/1e20) + 1.5*log(Te/1keV) is the NRL Plasma
Formulary electron-ion Coulomb logarithm (lambda_ei = 23 - ln(ne^1/2 * Te^-3/2))
re-normalized to (ne/1e20 m^-3, Te/1keV); its 17.3/-0.5/+1.5 coefficients are
calibrated for natural log. The Julia port used log10, so lnLambda -- and the
collision frequencies nue/nui that scale linearly with it -- ran ~13% low at a
20 keV core (19.2 vs the correct 21.7). Restore natural log, matching both Fortran
PENTRC inputs.f90:238 and the formulary. Regression (diiid_n1): NTV FGAR and dW FGAR
shift within tolerance (~0.06-0.2%; small only because that case sits near the
ne=1e20/Te=1keV reference point where log10 ~= ln).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
…ar path)

First increment toward correct multi-main-ion (e.g. D-T) NTV. Adds an ion_fraction
control (default 1.0 = single main ion, bit-for-bit backward compatible) that scales
only this species' resonant density out of the total main-ion n_i. The Coulomb
collisionality and Zeff use the full n_i, so a multi-ion run supplies the TOTAL
main-ion density in the kinetic file and runs each species (D, T) with its fraction;
wdian/wdiat are unaffected (the fraction cancels in T*(dn/dpsi)/n), and nu_s stays the
full-composition collisionality. Vetted against Logan-Park PoP 2013 (fortran-physics-reviewer).

Wired through the fgar NTV torque path (Compute.integrate_psi_quadgk -> tpsi!) and
auto-exposed via the [KineticForces] TOML splat.

Not yet threaded (tracked in the feature plan): the matrix-method (_setup_surface_state)
and self-consistent kinetic-DCON paths use the implicit 1.0; and an internal
multi-species loop (D/T summation is still external for now, but with the correct Zeff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
…arbitrary ion mix)

Physics core for correct multi-main-ion NTV: full-composition Zeff and momentum-restoring
pitch-angle enhancement from an arbitrary list of main-ion (z_s, n_s) plus one impurity that
closes quasineutrality (n_imp = (ne - Sum z_s n_s)/zimp; Zeff = (Sum z_s^2 n_s + zimp^2 n_imp)/ne).

Verified: reduces EXACTLY to the current single-ion formula Zeff = zimp - (ni/ne) zi (zimp-zi)
(Zeff 1.44355 both ways); gives the correct 50/50 D-T Zeff=1.44 vs the wrong 3.72 that each
split single-ion run currently sees (the ni double-duty bug). Per-species collisionality built
on this shares zpitch/n_main/Zeff/lnLambda and varies only z_s^2, m_s, T_s (Logan-Park PoP 2013,
fortran-physics-reviewer). Pure function, not yet wired into the loader/driver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
…uilder

Replaces the scalar ion_fraction stopgap with the species-list design. Adds:
- IonSpecies(z, m, fraction|density) + ion_species::Vector on KineticForcesControl,
  auto-converted from [[KineticForces.ion_species]] TOML tables (Vector{Dict}->Vector{IonSpecies}).
  Empty default => single ion from zi/mi (unchanged).
- Equilibrium.build_species_profiles(file, ion_species; zimp, mimp) -> Vector{KineticProfileSplines},
  one per species: ni_spline = that species' resonant density (fraction * total n_i), nui_spline =
  its full-composition collisionality (shared Zeff/zpitch/lnLambda via multi_ion_composition, per-species
  z_s^2, m_s, T_i); ne/Te/omegaE/zeff/nue shared. tpsi! consumes each view unchanged.

Verified: single-species [z=1,m=2,fraction=1] reproduces load_kinetic_profiles exactly (ni/nui/zeff);
50/50 D-T gives n_D=n_T=0.5 n_i, shared Zeff=1.46 (not the buggy 3.7), nu_D/nu_T=sqrt(3/2).
Reverts the ion_fraction wiring in tpsi!/Compute. Explicit per-species profiles (density=) validated
but not yet wired (fraction path only) -- next increment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
…d output

Wires the ion_species list end to end for the fgar NTV (post-PE diagnostic): when ctrl.ion_species
is non-empty, build per-species profiles (build_species_profiles), run compute_torque_all_methods!
once per species (+ electron if enabled) sharing the perturbed field and the full-composition Zeff,
and sum via combine_species_states (exact scalar totals; dT/dpsi summed on the union grid via the
per-species torque_profile interpolants; T(psi) re-integrated). Output: per-species groups
kinetic_forces_ion<i>_z<z>_m<m> / kinetic_forces_electron, plus the summed total in the standard
kinetic_forces group (so existing analysis reads the total). Empty ion_species => unchanged single
path. write_to_hdf5! gains a group_name kwarg. The self-consistent kinetic-DCON path stays single-ion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
…version

TOML.jl parses [[KineticForces.ion_species]] as Vector{Any} (not Vector{Dict}), so the
AbstractVector{<:AbstractDict} convert did not match. Broaden to AbstractVector with per-element
construction (pass IonSpecies through).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
…e + kinetic matrices) incl impurity

Both NTV paths now loop over and sum the SAME full species set. resolve_ntv_species (was
build_species_profiles) returns descriptors (z, m, profiles, electron, label) for: the main ions,
the neutrality-closing impurity (zimp/mimp, density = the quasineutrality n_imp) as its own resonant
species, and (optionally) electrons -- all on one shared full-composition Zeff/zpitch/lnLambda.
Resolved once in main() and passed to:
- the KineticForces quadrature path (per-species compute + combine_species_states), and
- compute_calculated_kinetic_matrices (self-consistent DCON Mode A): the kinetic W/torque matrices
  are additive over species, so the threaded psi-loop is wrapped in a species loop that accumulates
  (+=) each species' block-diagonal contribution (species=nothing => single-species, unchanged).
Empty ion_species => single-species from zi/mi (bit-for-bit unchanged). Verified: D-T-e config
resolves to 4 summed species (D, T, C impurity, e).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
… kinetic EL crossing

cross_kinetic_singular_surf! built its placeholder IntegrationChunk with a stale
4-arg signature (psi_start, psi_end, needs_crossing, ising), but the struct's
@kwdef positional constructor requires the direction field added later for
bidirectional parallel FM. The 4-arg call throws MethodError. This path is only
reached on the serial Euler-Lagrange shooting solver (use_parallel=false) with a
kinetic singular surface (kinetic_factor>0), which previously errored earlier in
the Riccati branch, so the defect stayed latent. Now passes direction=1, matching
the sibling placeholder call sites in cross_ideal_singular_surf! and the Riccati
crossing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
…HDF5)

Completes the multi-ion IO: KineticProfileData gains a species_densities dict populated by
_read_kinetic_h5 from any non-standard HDF5 datasets (e.g. "n_D", "n_T"). resolve_ntv_species'
`density=` branch resolves a species from its named profile (resampled), the same downstream path
as the `fraction` shorthand -- so measured, differently-shaped per-species profiles are supported
via the HDF5 container while ASCII+fraction stays the simple case. One-of-{fraction,density} per
species is validated; a missing named profile errors with the available names. Verified: n_D=0.6*Ni,
n_T=0.4*Ni explicit datasets resolve to the correct per-species densities, impurity closes
quasineutrality from them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
Covers: multi_ion_composition (exact single-ion reduction for z=1,2; 50/50 D-T Zeff; quasineutrality
closes the impurity), IonSpecies + TOML Vector{Any}->Vector{IonSpecies} conversion, resolve_ntv_species
(single-species reduces to load_kinetic_profiles; D-T-e set = {D,T,impurity,electron}; nu ~ 1/sqrt(m);
one-of-{fraction,density} validation), and explicit per-species HDF5 density profiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
…_multiion)

New example examples/Solovev_kinetic_multiion_example (Solovev main ion modelled as 50/50 D-T) and
regression case solovev_kinetic_multiion tracking the summed total plus each per-species NTV
contribution (D, T, electron; pure fixture so no impurity), pinning the multi-species
resolve_ntv_species + combine_species_states loop end to end. Verified: total 1.318e-4 = D+T+e sum.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
…ity (per physics review)

Per fortran-physics-reviewer sign-off of the multi-species NTV: (1) flag that zpitch is a main-ion
momentum-restoring closure applied approximately to the impurity/electron test species; (2) the
electron descriptor now carries ne (not ns[1]) in the unused ni_spline slot for clarity. No numerical
change. Reviewer verdict: collision-freq z^2/n_main form CORRECT (n_main is the faithful Fortran
choice, not ne*Zeff); the self-consistent dW +0.066->-0.10 shift is additive-channel physics, not a
summation bug (ideal F/K/G added once downstream, kw/kt purely kinetic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
…ll zeros

The multi-species combined dT/dψ profile (written to the "kinetic_forces" total group) was identically
zero: combine summed via each MethodResult.torque_profile interpolant, but that field is not populated
by the per-species compute (only the dtdpsi array is). total_torque was correct (summed scalar), so the
bug was invisible until the combined mid-radius profile was integrated. Fix: linear-interpolate each
species' (psi_grid, dtdpsi) arrays onto the union grid and sum (zero outside a species' range). Unit
test added (overlap sum + nonzero + total). Per-species output groups were always correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
…omment rule)

Collapse the 3-line lnLambda comment to one self-contained line: drop the 'not log10'
bug-history note and the stale Fortran file:line, per the keep-comments-concise rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
…nChunk (PR #336 review)

Match the sibling call sites (cross_ideal_singular_surf!, Riccati crossing): pass the in-scope
ising instead of 0. Functionally inert (sing_der! discards the chunk); keeps the cherry-picked
IntegrationChunk fix in sync with the amended bugfix branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
@logan-nc

Copy link
Copy Markdown
Collaborator Author

@claude review this pr

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review: Multi-main-ion NTV

Reviewed the diff (18 commits: KineticProfiles.jl, Compute.jl, CalculatedKineticMatrices.jl, KineticForcesStructs.jl, the new example/regression case, and runtests_multiion.jl).

Potential physics inconsistency

resolve_ntv_species's per-species collision frequency includes an explicit z_s² factor:

_nu(zsp, msp) = [... (zpitch[i]/3.5e17) * zsp^2 * n_main[i] * loglam[i] / (sqrt(Float64(msp)) * ...) ...]

but the pre-existing single-ion path in load_kinetic_profiles does not carry a zi² term:

nui[i] = T_i > 0 ? (zpitch/3.5e17) * n_i * ll / (sqrt(1.0*mi) * (T_i/1.602e-16)^1.5) : 0.0

For the flagship D/T case (z=1 for both species) this cancels (1²=1), so the headline result is unaffected, and the unit tests only exercise z=1 species. But for any non-hydrogenic main ion (e.g. He, z=2) the two paths would diverge by a factor of . The resolve_ntv_species docstring even hedges this ("Reduces to load_kinetic_profiles for one z=1 main ion with fraction=1") rather than claiming an unconditional reduction, which suggests the discrepancy is known but not explained. Worth either (a) confirming with the physics reviewer which form is correct and fixing the other, or (b) adding an explicit docstring/comment note that the two formulas intentionally disagree for z≠1 and why.

Test coverage gap

The new regression case (solovev_kinetic_multiion) and its gpec.toml set kinetic_source = "fixed" / kinetic_factor = 0.0, so they only exercise the KineticForces ψ-quadrature species-summing path (resolve_ntv_species + combine_species_states). The other half of this PR — the self-consistent DCON kinetic-matrix accumulation in compute_calculated_kinetic_matrices (species loop wrapping the threaded ψ-loop, += accumulation into kw_flat/kt_flat) — is only verified per the PR description via a manual, non-committed ITER TC-24 run. Given this is a non-trivial change (nested Threads.@threads per species, block-diagonal accumulation), it would be worth adding a small regression/unit case with kinetic_source = "calculated" and a non-empty ion_species list so this path doesn't silently regress later.

Minor style

  • The new ion_species field comment in KineticForcesControl is a 5-line block that largely duplicates the IonSpecies docstring. Consider trimming to 1-2 lines and pointing at IonSpecies, per the project's "concise comments" convention.
  • combine_species_states's combined MethodResult drops torque_profile, records, panel_psis, and resonance_psis (left at their zero-value/nothing defaults). That's a reasonable choice, but it isn't called out in the docstring — worth a one-line note so a future reader of e.g. kinetic_forces/<method>/panel_psis in the summed group doesn't wonder why it's empty.

What looks solid

  • multi_ion_composition reduces exactly to the existing single-ion Zeff formula (verified algebraically in the diff and by the new unit test) — good regression discipline for a formula change.
  • The species === nothing fallback in compute_calculated_kinetic_matrices and the isempty(kf_ctrl.ion_species) fallback in main_from_inputs keep the single-ion path bit-for-bit unchanged, which is the right way to land a feature like this without disturbing existing users.
  • The IonSpecies / TOML Vector{Any}Vector{IonSpecies} convert overload, and the one-of-{fraction,density} validation, are handled correctly and are unit-tested.
  • The separate IntegrationChunk arity fix addresses a real latent MethodError on a rarely-exercised serial+kinetic-singular-surface path — useful fix, currently inert since the chunk is discarded there.

Process note

Per CLAUDE.md, the regression harness should be run and its report included before merging any substantive change. The PR description shows unit test results and a manual ITER end-to-end check, but no regress --cases diiid_n1,solovev_n1,solovev_multi_n,solovev_kinetic_multiion --refs develop,local table. Given this PR also carries the Coulomb-log natural-log fix, a combined run would confirm nothing besides the already-noted diiid_n1 shift moved unexpectedly.

logan-nc and others added 2 commits July 31, 2026 12:05
…icle z², density hard-stop, typed species

- ν_s carries the test-particle z² (Krook deflection, Logan & Park 2013 Eq. 6);
  the single-ion path now equals the multi-ion limit (identical at z=1).
- Hard error on any negative density (bad input or cubic-resample overshoot)
  instead of silently clamping the neutrality-closing impurity.
- resolve_ntv_species returns concrete-eltype ResolvedNTVSpecies structs, so the
  self-consistent kinetic-matrix species loop is type-stable.
- Share physical constants and the Coulomb-log helper across load_kinetic_profiles
  and resolve_ntv_species; drop duplicate local literals.
- Fix combine_species_states docstring (linear interp of dtdpsi, dropped fields);
  move the multi-ion contract into the KineticForcesControl / KineticProfileData
  docstrings; trim inline field comments.
- Make runtests_multiion self-contained (using HDF5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
… work

- kinetic_ntv_map.md: multi-ion NTV audit — test-particle z² justification,
  field-density (zpitch·n_main vs n_e·Zeff) reconciliation, and the electron-
  driven self-consistent δW sign-flip analysis (physics, not a double-count).
- reg_spot_regularization.md: verdict that the reg_spot field-reconstruction
  smoothing port is correct and distinct from the singfac_min ODE crossing gate.
- MEMORY.md: index pointer to the new reg_spot audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1
@logan-nc

Copy link
Copy Markdown
Collaborator Author

Review changes applied (5f9f40ee) + reviewer memory (b864d38d)

Physics

  • ν_s now carries the test-particle z² (Krook deflection, Logan & Park 2013 Eq. 6). Confirmed as the correct form (deflection-off-background, not self-collision Z⁴) in a literature review. Arithmetically identical for z=1, so it only generalizes impurity/multi-species collisionality and makes the single-ion path the exact 1-species limit of the multi-ion path.
  • Hard error on any negative density (bad input or cubic-resample overshoot) on nₑ, total nᵢ, each ion species, and the neutrality-closing impurity — replaces silent clamping.

Maintainability

  • resolve_ntv_species returns concrete-eltype ResolvedNTVSpecies structs → type-stable species loop in the self-consistent kinetic-matrix build.
  • Fixed combine_species_states docstring (linear-interp of dtdpsi; documents the dropped per-species fields).
  • Shared physical constants + Coulomb-log helper across load_kinetic_profiles/resolve_ntv_species; multi-ion contract moved into struct docstrings; doc links normalized.
  • runtests_multiion.jl made self-contained (using HDF5).

Verification

  • Multi-ion suite: 28/28 passing. Formatted with pinned JuliaFormatter v1.0.62.
  • Regression: numerically inert. diiid_n1 NTV torque FGAR + dW FGAR both OK (z=1 ⇒ z²=1). The multi-ion collision/composition formulas (_nu, n_imp, zeff, zpitch, nue) are byte-identical before/after this commit, so solovev_kinetic_multiion is unchanged. The ideal-MHD deltas seen in a whole-branch-vs-develop diff come from the stacked 66cc9dee ising fix (ForceFreeStates - BUG FIX - Pass direction arg to IntegrationChunk in kinetic EL crossing #336), not from these commits.

@logan-nc
logan-nc requested a review from matt-pharr August 1, 2026 19:25
@logan-nc logan-nc self-assigned this Aug 1, 2026
@logan-nc logan-nc added the feature New capability label Aug 1, 2026
@logan-nc

logan-nc commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

@krystophny could you review this (for TC24)?

Two conflicts, both from develop moving under this branch.

The Coulomb logarithm was corrected to a natural log on both sides
independently: develop in #337, this branch in its own refactor. They are
numerically identical, so the helper is kept and develop's annotation already
sits on its definition.

The kinetic-matrix block conflicted because develop hoisted it and this branch
still carried the older copy, which git left duplicated. One copy is kept, in
develop's position, with this branch's `species=kf_species` argument carried
into it. The branch's guard on `ctrl.ode_flag` is dropped because develop
deprecated and removed that flag; the surviving guard is `singfac_min > 0`.

The module loads against the merged tree.

@krystophny krystophny left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@logan-nc Three things before this goes in.

The fraction path in resolve_ntv_species never checks that the fractions sum to one. If they don't, the shortfall ends up in the quasineutrality impurity, which pushes zeff toward zimp, and zpitch has a pole there. A typo like 0.5/0.4 gives no error, just a collisionality that runs away. Either require sum == 1 within a tolerance, or say what a partial sum means.

write_kinetic_h5 doesn't write species_densities, but read_kinetic_h5 reads it, and docs/src/kinetic_forces.md tells people to author files with write_kinetic_h5. So the documented way of making a kinetic file can't produce one the new explicit-profile input accepts. Needs the put() loop.

The resolve_ntv_species docstring still says explicit per-species profiles are "not yet wired". They are, a few lines below.

The hard error on the scaling factors is fine.

@matt-pharr

Copy link
Copy Markdown
Collaborator

@logan-nc I am going to refrain from reviewing this until we have our group sit-downs next week. Unfortunately I have a lot of these PR review requests building up and cannot get them done fast enough with my thesis writing right now.

logan-nc and others added 4 commits August 14, 2026 16:03
…d species + shared constants

Review response (PR #339): the kinetic-matrix psi loop indexed per-thread
buffers by threadid() under default dynamic scheduling — a task-migration
hazard multiplied by the new species loop (the sibling quadrature loop already
uses :static). Pin with :static and hoist the per-species scalars so the
closure captures concrete values instead of a union-typed sp. Type the species
kwarg as AbstractVector{<:ResolvedNTVSpecies}. Distinct _E_CHARGE constant
where the physics wants the elementary charge (same PENTRC-parity value as
_EV_J), single _NKIN resample-grid constant shared by both loaders, and
combine_species_states unions method names over all states instead of taking
the first state's list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… Mode A matrices

Review response (PR #339): the self-consistent kinetic-matrix species loop was
only verified by a manual ITER TC-24 run. The existing calculated-path fixture
now declares the main ion as a 50/50 D-T mix, pinning the multi-ion matrix
accumulation end-to-end in the regression harness. Deliberate baseline change:
et[1] moves from ~1.894-1.525i (single D) to ~1.874-1.434i (D-T summed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…physics-reviewer memory

The recorded example arithmetic was wrong (Zeff=1.5 with C6 gives ~18% gap, not
1.55 vs 1.5); the conclusion (zpitch·n_main is PENTRC's deliberate design)
stands and is now stated without the false near-equality.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ero case annotations

The nuzero case reuses the calculated example deck via [overrides], so it
inherits the 50/50 D-T multi-ion fixture change (same documented baseline
shift, collisionless variant); drop the stale et[1] quote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@logan-nc

Copy link
Copy Markdown
Collaborator Author

Review round 2 applied (six commits) + independent 3-agent audit

All three of @krystophny's items, the remaining claude-bot items, and the findings of a fresh independent review (physics / clean-code / performance agents over the full diff) are addressed. Point-by-point:

@krystophny's items

1. Fraction-sum validation — implemented as two hard errors (resolve_ntv_species):

  • any list (mixed included) with Σ fraction > 1 + 1e-6 errors — fractions are shares of the total n_i, so over-allocation silently inflated every ν_s before;
  • an all-fraction list additionally requires |Σ − 1| ≤ 1e-6.

A blanket Σ==1 check would have broken supported mixed fraction+density lists, where the legitimate Fortran semantics apply: the impurity content is set by the file's n_i/n_e deficit, not by a fraction shortfall (docstring now states this). Two new guards land in the same commit, both motivated by your zpitch-pole observation: every main-ion z < zimp is enforced (the closure has a pole at Zeff = zimp and goes negative beyond), and a @warn fires when max(Zeff) comes within 10% of zimp. multi_ion_composition also no longer parks the ne ≤ 0 fallback exactly on the pole. Unit-tested (0.9/1.2/mixed/z≥zimp/near-pole warning).

2. write_kinetic_h5 species densities — fixed with a roundtrip test (write → read → resolve_ntv_species consumes the file). Per-species datasets must be named n_*; the reader now correspondingly harvests only n_*-prefixed non-standard datasets, so future schema additions can't be silently swept in as densities.

3. Stale "not yet wired" docstring — fixed; the docstring now describes the implemented named-dataset lookup + resample behavior.

claude-bot items

  • Mode A coverage: the existing solovev_kinetic_calculated fixture now declares a 50/50 D-T main-ion mix, pinning the self-consistent kinetic-matrix species accumulation end-to-end in the harness. Deliberate baseline change: et[1] moves 1.894−1.525i → 1.874−1.434i (T's higher mass reduces the kinetic damping contribution).
  • ion_species comment duplication: already resolved at HEAD (single-line field comment + docstring); no change.

Independent audit findings applied

  • Unified electron semantics (decision by @logan-nc): electron = true now always adds the electron NTV in addition to the ion species — a single-ion + electron run resolves through the species machinery as {main ion, quasineutrality impurity, electron}, summed. The old single-ion behavior (electron instead of ions) was an artifact of Fortran PENTRC's one-species-per-run IO, not physics. electron = false + empty ion_species is bit-for-bit unchanged, and no shipped example/regression case used the old electron-only mode.
  • Threading: the kinetic-matrix ψ-loop indexed per-thread buffers by threadid() under default dynamic scheduling — a task-migration hazard multiplied by the new species loop. Now :static (matching the sibling quadrature loop), with per-species scalars hoisted so the closure captures concrete types.
  • Docs: new "Multi-ion runs" section in docs/src/kinetic_forces.md (species table, fraction vs density, n_* schema, per-species output groups, electron flag); ν_s citation now points at the PENTRC numerical form rather than implying Logan & Park Eq. (6) carries the prefactor; the zpitch·n_main field term's z=1 validity domain is stated; t_cumulative documented as a diagnostic (endpoint ≠ exact summed Gauss-Kronrod total); example TOMLs fully annotated.
  • Physics audit verdict on the rest: composition/ν forms, resonant-vs-collision density separation, and the additive kinetic-matrix accumulation (no F/K/G double-count) all confirmed sound; the develop merge conflict resolution verified clean.

Regression

Full run: regress --cases diiid_n1,solovev_n1,solovev_kinetic_ntv,solovev_kinetic_nuzero,solovev_kinetic_calculated,solovev_kinetic_multiion --refs develop,local (develop @ 1308b85e, single-thread, pinned manifest):

Case Result
diiid_n1 48 unchanged (NTV torque FGAR + dW FGAR exactly 0.0 diff)
solovev_n1 21 unchanged
solovev_kinetic_ntv 6 unchanged (ψ-quadrature NTV bit-identical)
solovev_kinetic_calculated 6 changed — deliberate D-T fixture change (below)
solovev_kinetic_nuzero 7 changed — same fixture (reuses the calculated deck via [overrides])
solovev_kinetic_multiion new case: no develop baseline (example dir doesn't exist on develop); local values recorded

The two changed cases are the one intended baseline change (the calculated deck now declares a 50/50 D-T main-ion mix to pin the Mode A species loop):

solovev_kinetic_calculated        develop         local           Diff
total energy Re(et[1])            1.845905e+00    1.873742e+00    2.784e-02 (1.51%)
total energy Im(et[1])           -1.421059e+00   -1.434284e+00    1.323e-02 (0.93%)
plasma energy Re(ep[1])          -8.593964e+00   -8.563835e+00    3.013e-02 (0.35%)
vacuum energy Re(ev[1])           1.043987e+01    1.043758e+01    2.293e-03 (0.02%)

solovev_kinetic_nuzero (collisionless override of the same deck)
total energy Re(et[1])            2.245439e+00    2.243336e+00    2.103e-03 (0.09%)
total energy Im(et[1])           -2.034869e+00   -2.026028e+00    8.841e-03 (0.43%)

Attribution was verified by isolation: re-running solovev_kinetic_nuzero against 61912a0b (this branch immediately before this review round) reproduces develop's values bit-for-bit — the branch's feature code is numerically inert on single-ion paths, and the only mover in this round is the fixture change itself.

@logan-nc
logan-nc requested a review from krystophny August 14, 2026 21:56
logan-nc and others added 3 commits August 15, 2026 13:13
…orms to the h5 schema

Merge origin/develop (b3abe07), which brought the repo-wide gpec.h5 naming and
metadata standard of docs/development/hdf5-conventions.md.

The per-species groups this branch added predated that standard: flat lowercase
top-level kinetic_forces_<label> siblings violated both the CamelCase group rule
and the physics-first organization principle. Per-species results now nest under
KineticForces/PerSpecies/<label>/<method>/, parallel to Tearing/PerSurface/, with
the summed total staying at KineticForces/<method>/.

write_to_hdf5! takes a species_label instead of a free-form group_name, so callers
can no longer name arbitrary top-level groups; the top group is opened-or-created
because a multi-species run writes it once per species plus once for the total.
The metadata pass now annotates the method groups actually written rather than
walking keys(g), which would otherwise annotate the PerSpecies container and leave
every per-species dataset without long_name/units.

Species labels drop the ordinal (ion_z1_m2, not ion1_z1_m2) since z and m identify
the species; a repeated (z, m) pair gets a numeric discriminator so group creation
cannot collide. The schema-test group whitelist and the conventions doc cover the
species token, and the multi-ion regression case moves to the new paths (including
SingularSurfaces/rational_count and the ForceFreeStates-prefixed eigenmode path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pins KineticForces/PerSpecies/<label>/<method>/ with the summed total at
KineticForces/<method>/, and asserts long_name/units reach the per-species
method level — the failure mode if the annotation pass stops at the
PerSpecies container instead of walking to the method groups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tly two token levels

The whitelist admitted anything at any depth below KineticForces/PerSpecies/.
Only the species label and the method token are data-driven; EnergyIntegrals/
and KineticMatrices/ below them are ordinary groups and must still be CamelCase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@logan-nc

Copy link
Copy Markdown
Collaborator Author

@matt-pharr has been assigned to many reviews, so I'll hand this off to @ebursch since he is already developing things related to torques.

@ebursch this needs a human sanity check of the diff - sorry it's a bit lengthy but luckily the physics isn't hard. Ultimately, we are just trying to loop over multiple species in the NTV calculation.

@logan-nc

Copy link
Copy Markdown
Collaborator Author

Regression re-baselined against current develop (b3abe074)

The table I posted earlier is superseded: develop has since moved 49 commits (notably #364 refactor/hdf5-metadata), and this branch now merges it. Re-run with --refs develop,local, single thread, pinned manifest.

Case Result
diiid_n1 48 unchanged
solovev_n1 21 unchanged
solovev_kinetic_ntv 6 unchanged (ψ-quadrature NTV bit-identical)
solovev_kinetic_calculated 6 changed — deliberate D-T fixture change
solovev_kinetic_nuzero 7 changed — same fixture (reuses the calculated deck via [overrides])
solovev_kinetic_multiion new case; develop ref fails as expected (Example directory not found), local values recorded

Every changed row is confined to the two cases that share the calculated deck, which this PR deliberately switched to a 50/50 D-T main-ion mix so the multi-ion kinetic-matrix species loop is pinned end-to-end:

solovev_kinetic_calculated        develop         local           Diff
total energy Re(et[1])            1.845905e+00    1.873742e+00    2.784e-02 (1.51%)
total energy Im(et[1])           -1.421059e+00   -1.434284e+00    1.323e-02 (0.93%)
plasma energy Re(ep[1])          -8.593964e+00   -8.563835e+00    3.013e-02 (0.35%)
vacuum energy Re(ev[1])           1.043987e+01    1.043758e+01    2.293e-03 (0.02%)
ODE steps (total)                 1800            1801            1 (0.06%)

solovev_kinetic_nuzero (collisionless override of the same deck)
total energy Re(et[1])            2.245439e+00    2.243336e+00    2.103e-03 (0.09%)
total energy Im(et[1])           -2.034869e+00   -2.026028e+00    8.841e-03 (0.43%)
plasma energy Re(ep[1])          -8.485145e+00   -8.479030e+00    6.115e-03 (0.07%)

Attribution is pinned: diiid_n1's NTV torque FGAR and dW FGAR rows are exactly 0.0e+00, and solovev_kinetic_ntv is entirely unchanged, so the single-ion paths are untouched and the movement is the fixture change alone.

HDF5 schema conformance

Per-species NTV output now nests under KineticForces/PerSpecies/<label>/<method>/ per docs/development/hdf5-conventions.md (the flat kinetic_forces_<label> top-level groups violated the CamelCase group rule). Verified on a real 3-species run:

KineticForces/
  fgar/                    total_torque = 1.317868e-04
  PerSpecies/
    ion_z1_m2/fgar/        +7.794827e-05
    ion_z1_m3/fgar/        +8.206067e-05
    electron/fgar/         -2.822214e-05

Species sum to the total bitwise; every per-species dataset carries long_name + units; dTdpsi is native ComplexF64. runtests_h5_schema.jl 14/14, runtests_kinetic.jl 277/277, runtests_multiion.jl 52/52.

@krystophny krystophny left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, Nik. I rechecked the requested points and the multi-ion regression. We accidentally included an unnecessary CI hash refresh, reverted it, and everything is now fine to merge from my side.

logan-nc and others added 2 commits August 18, 2026 10:14
…d drop dead deck keys

A develop merge into this branch silently reverted PR #367's immutability
freeze on KineticForcesControl, because the multi-species loop mutated a
deepcopy of the run control. That made the branch carry an undocumented revert
of an upstream refactor and depend on the mutability upstream removed
("KineticForcesControl had no writers in src/ at all" — this loop was exactly
such a writer, and the only one left).

Build each species' control with the @kwdef keyword constructor instead, the
idiom the freeze commit documents and that check_psi_quadrature_convergence
already uses, and restore @kwdef struct. Threading species identity through
explicit arguments was rejected: ctrl is passed whole into integrate_psi_quadgk
and onward, so that would touch several nested signatures.

Also drop six keys from the multi-ion deck that develop removed when it
replaced the integrator-selection axis with integrator= (mat_flag, ode_flag,
force_wv_symmetry, use_parallel, parallel_threads, populate_dense_xi). They
only emitted deprecation warnings, but populate_dense_xi in particular implied
a guarantee it no longer provides — had integrator= been dropped on that
belief, the run would have fallen back to the riccati default, which does not
populate dense xi, and PE/NTV would have gone quietly wrong. Document the
PerSpecies layout in the h5 schema table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@logan-nc logan-nc changed the title KineticForces - NEW FEATURE - Multi-main-ion NTV (summed ions + impurity + electrons, both paths) KineticForces - FEATURE! - Multi-main-ion NTV (summed ions + impurity + electrons, both paths) Aug 19, 2026
@github-actions github-actions Bot added the changed-results Results move or an interface breaks - read before upgrading label Aug 19, 2026
@logan-nc
logan-nc merged commit c42d558 into develop Aug 20, 2026
7 of 8 checks passed
@logan-nc
logan-nc deleted the feature/multi-ion-ntv branch August 20, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changed-results Results move or an interface breaks - read before upgrading feature New capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants