Skip to content

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

Open
logan-nc wants to merge 17 commits into
developfrom
feature/multi-ion-ntv
Open

KineticForces - NEW FEATURE - Multi-main-ion NTV (summed ions + impurity + electrons, both paths)#339
logan-nc wants to merge 17 commits into
developfrom
feature/multi-ion-ntv

Conversation

@logan-nc

Copy link
Copy Markdown
Collaborator

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.

API

[KineticForces]
kinetic_file = "kinetic.dat"   # n_i column = TOTAL main-ion density
electron = true
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 ⇒ single main ion from zi/mibit-for-bit unchanged. Explicit measured profiles via named HDF5 datasets (density = "n_D").

Validation

  • 25 unit tests (test/runtests_multiion.jl): composition (exact single-ion reduction; 50/50 D-T Zeff; quasineutrality), TOML Vector{Any}IonSpecies conversion, resolver (single-species reduction, D-T-e set, ν∝1/√m, one-of-{fraction,density}), explicit HDF5 profiles.
  • Regression case solovev_kinetic_multiion: tracks the summed total + each per-species NTV end-to-end.
  • 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.
  • fortran-physics-reviewer sign-off: collision-frequency z_s²/n_main form CORRECT (n_main is the faithful Fortran-matching choice, not n_e·Zeff); the self-consistent δW shift is additive-channel physics (ideal F/K/G added once downstream — no per-species double-count), not a summation bug.

Stacking note

This branch includes two bugfix commits that are their own PRs and should merge first; they deduplicate on rebase:

  • #337 — Coulomb log natural-log fix (prerequisite for correct collisionality).
  • #336 — IntegrationChunk arity fix (prerequisite for any Mode A / self-consistent kinetic run).

Not included (follow-ups)

  • Scaling factors (density_factor, …) with a multi-ion list error out (documented).
  • zpitch is a main-ion closure applied approximately to impurity/electron test species (annotated).

🤖 Generated with Claude Code

https://claude.ai/code/session_01SsLUydP2gJbE1tGoaHDiS1

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant