Skip to content

Clarify force-balance documentation and PDF build#76

Open
krystophny wants to merge 39 commits into
mainfrom
omte-force-balance-level0
Open

Clarify force-balance documentation and PDF build#76
krystophny wants to merge 39 commits into
mainfrom
omte-force-balance-level0

Conversation

@krystophny

@krystophny krystophny commented Apr 13, 2026

Copy link
Copy Markdown
Member

Summary

  • implement and document the direct pair-force-balance benchmark as the canonical experimental-style check for the AUG 30835 work
  • clarify in compute_omte.md that full NEO-2, reduced single-ion NEO-2, the direct pair evaluator, and GA vgen(2) are four different objects and must not be mixed
  • document that species_tag_Vphi is a real physics selector for the rotation species, not a bookkeeping label
  • make the standalone documentation build from DOC/ work again by wiring in the vendored cmake/UseLATEX.cmake
  • fix the LaTeX breakages touched during this pass in DOC/UserDoc/Output.tex and DOC/ExtraDocuments/neo-2-ql-demo_runs.tex

Documentation changes

  • python/src/neo2_ql/compute_omte.md
    • add a short current verdict near the top
    • spell out when to compare against full NEO-2, the reduced single-ion formula, the direct pair-force-balance evaluator, and GA vgen
    • explain explicitly why vgen er_method=1 is not part of the benchmark right now
    • explain what changing species_tag_Vphi actually changes in compute_Er()
  • DOC/neo2.in.ql-full
    • document species_tag_vphi as the selected rotation species used in the force-balance solve
  • DOC/ExtraDocuments/neo-2-ql-demo_runs.tex
    • document the meaning of isw_Vphi_loc = 0 and species_tag_Vphi = 2 in the demo profile generator example
  • DOC/CMakeLists.txt
    • add the repo cmake/ directory to CMAKE_MODULE_PATH so the standalone doc build can resolve UseLATEX.cmake

PDF outputs

After this change the documented CMake path from DOC/ produces these PDFs in DOC/Build/:

  • main.pdf
  • neo-2-ql-demo_runs.pdf
  • neo-2-par-eccd_runs.pdf
  • Doxygen_Reference_Manual.pdf

Verification

Documentation build fails before the fix

$ cmake -S DOC -B DOC/Build -G Ninja && cmake --build DOC/Build -j2
-- Found LATEX: /usr/bin/latex
-- Found Doxygen: /usr/bin/doxygen (found version "1.16.1") found components: doxygen dot missing components: mscgen dia
CMake Error at CMakeLists.txt:32 (include):
  include could not find requested file:

    UseLATEX

CMake Error at CMakeLists.txt:35 (add_latex_document):
  Unknown CMake command "add_latex_document".

Documentation build passes after the fix

$ cmake -S DOC -B DOC/Build -G Ninja && cmake --build DOC/Build -j2
-- Build files have been written to: /home/ert/code/NEO-2/DOC/Build
[10/10] Generating Doxygen_Reference_Manual.pdf
Output written on refman.pdf (768 pages, 4600660 bytes).

Python tests pass after the doc update

$ pytest python/test
============================= test session starts ==============================
platform linux -- Python 3.14.4, pytest-9.0.3, pluggy-1.6.0
collected 80 items
...
============================== 80 passed in 1.69s ==============================

@qodo-code-review

Copy link
Copy Markdown

Review Summary by Qodo

Add diamagnetic Om_tE computation from force balance (Level 0)

✨ Enhancement 🧪 Tests

Grey Divider

Walkthroughs

Description
• Add compute_omte_diamagnetic() function computing E x B rotation frequency from pressure
  gradient term of radial force balance (Level 0 model)
• Implement diamagnetic Om_tE calculation using CGS units matching NEO-2 conventions
• Include 6 analytic unit tests covering zero gradient, sign, linear profiles, temperature gradient,
  charge scaling, and array inputs
• Add end-to-end validation against NEO-2 reference data (AUG #30835) with regression assertions
• Capture 15-35% of full neoclassical Om_tE, with correct sign and order of magnitude
Diagram
flowchart LR
  A["Pressure gradient<br/>dn/ds, dT/ds"] --> B["compute_omte_diamagnetic()"]
  C["Geometry parameters<br/>aiota, sqrtg_phi, av_nabla"] --> B
  D["Profile data<br/>n, T, z"] --> B
  B --> E["Radial electric field<br/>Er"]
  B --> F["E x B rotation frequency<br/>Om_tE"]
  E --> G["Validation vs NEO-2<br/>Sign & magnitude match"]
  F --> G
Loading

Grey Divider

File Changes

1. python/src/neo2_ql/__init__.py ✨ Enhancement +1/-0

Export diamagnetic Om_tE computation function

• Export new compute_omte_diamagnetic function from package
• Add import statement to make function available at package level

python/src/neo2_ql/init.py


2. python/src/neo2_ql/compute_omte.py ✨ Enhancement +63/-0

Implement diamagnetic Om_tE force balance computation

• Implement compute_omte_diamagnetic() function computing E x B rotation frequency from pressure
 gradient
• Use radial force balance equation with zero flow assumption (Level 0 model)
• Define CGS unit constants (C_CGS, E_CGS) matching NEO-2 conventions
• Support both scalar and array inputs for multiple flux surfaces
• Return both Om_tE and radial electric field Er

python/src/neo2_ql/compute_omte.py


3. python/test/test_compute_omte.py 🧪 Tests +210/-0

Add comprehensive unit and end-to-end tests

• Add 6 analytic unit tests: uniform profiles, negative density gradient, linear profiles,
 temperature gradient contribution, charge number scaling, array input handling
• Implement end-to-end test against NEO-2 reference fixture (AUG #30835) with regression value
 assertions
• Verify sign consistency and ratio bounds (0.1-1.0) between diamagnetic and full neoclassical
 results
• Include detailed error messages with surface locations and computed values

python/test/test_compute_omte.py


View more (1)
4. python/test/data/omte_reference_aug30835.npz 🧪 Tests +0/-0

Add NEO-2 reference data fixture for validation

• Add NEO-2 reference fixture containing profiles and computed quantities from AUG discharge #30835
• Include density, temperature, and gradient profiles for 2 flux surfaces
• Store geometric parameters (aiota, sqrtg_bctrvr_phi, av_nabla_stor) and NEO-2 computed Er values
• Enable end-to-end validation of diamagnetic Om_tE against full neoclassical calculation

python/test/data/omte_reference_aug30835.npz


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented Apr 13, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. compute_omte_diamagnetic needs dn_ds📎
Description
Level 0 is required to compute dp_i/dr from the provided radial coordinate using finite
differences/spline utilities, but the new function instead requires precomputed dn_ds/dT_ds
inputs. This breaks the specified Level 0 interface and shifts core derivative logic outside
compute_omte.py.
Code

python/src/neo2_ql/compute_omte.py[R24-62]

+def compute_omte_diamagnetic(n, T, dn_ds, dT_ds, z,
+                             aiota, sqrtg_bctrvr_phi, av_nabla_stor):
+    """Compute Om_tE from diamagnetic pressure gradient (Level 0).
+
+    Uses the radial force balance with zero flow velocity:
+        E_r = (1 / (Z e n)) dp/dr
+    and converts to the E x B rotation frequency:
+        Om_tE = c E_r / (iota * sqrt(g) B^phi)
+
+    Parameters
+    ----------
+    n : float or array
+        Ion density [1/cm^3].
+    T : float or array
+        Ion temperature [erg].
+    dn_ds : float or array
+        Derivative of ion density w.r.t. s_tor [1/cm^3].
+    dT_ds : float or array
+        Derivative of ion temperature w.r.t. s_tor [erg].
+    z : float
+        Ion charge number (dimensionless, e.g. 1 for D+).
+    aiota : float or array
+        Rotational transform iota = 1/q.
+    sqrtg_bctrvr_phi : float or array
+        sqrt(g) * B^phi contravariant component [G cm^2].
+    av_nabla_stor : float or array
+        Flux surface average of |nabla s_tor| [1/cm].
+
+    Returns
+    -------
+    Om_tE : float or array
+        E x B rotation frequency [rad/s].
+    Er : float or array
+        Radial electric field (w.r.t. effective radius) [statV/cm].
+    """
+    dp_ds = T * dn_ds + n * dT_ds
+    dp_dr = dp_ds * av_nabla_stor
+    Er = dp_dr / (n * z * E_CGS)
+    Om_tE = C_CGS * Er / (aiota * sqrtg_bctrvr_phi)
Evidence
PR Compliance ID 1 requires deriving dp_i/dr from the provided radial coordinate (finite
differences/splines) inside python/src/neo2_ql/compute_omte.py. The added implementation takes
dn_ds/dT_ds as required parameters and computes dp_ds directly, so it does not perform the
required derivative-from-coordinate step within the function.

Level 0 Om_tE computation implemented as a pure, side-effect-free Python function
python/src/neo2_ql/compute_omte.py[24-62]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`compute_omte_diamagnetic()` currently requires `dn_ds` and `dT_ds` as inputs, but Level 0 compliance requires computing `dp_i/dr` from the provided radial coordinate using finite differences or spline differentiation inside `python/src/neo2_ql/compute_omte.py`.
## Issue Context
There is already derivative/interpolation utility logic in the Python package (e.g., the finite-difference `derivative()` helper used when building multispec profiles). Level 0 should accept the profile arrays and their radial coordinate (e.g., `s_tor`/`r`) and derive gradients internally.
## Fix Focus Areas
- python/src/neo2_ql/compute_omte.py[24-62]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. No unified compute_omte API📎
Description
The checklist requires a single progressive compute_omte interface (Levels 0–2) driven by optional
arguments, but this PR adds a standalone compute_omte_diamagnetic() function instead. This
prevents the intended backward-compatible extension path and risks downstream incompatibility with
expected Om_tE profile generation workflows.
Code

python/src/neo2_ql/compute_omte.py[R24-63]

+def compute_omte_diamagnetic(n, T, dn_ds, dT_ds, z,
+                             aiota, sqrtg_bctrvr_phi, av_nabla_stor):
+    """Compute Om_tE from diamagnetic pressure gradient (Level 0).
+
+    Uses the radial force balance with zero flow velocity:
+        E_r = (1 / (Z e n)) dp/dr
+    and converts to the E x B rotation frequency:
+        Om_tE = c E_r / (iota * sqrt(g) B^phi)
+
+    Parameters
+    ----------
+    n : float or array
+        Ion density [1/cm^3].
+    T : float or array
+        Ion temperature [erg].
+    dn_ds : float or array
+        Derivative of ion density w.r.t. s_tor [1/cm^3].
+    dT_ds : float or array
+        Derivative of ion temperature w.r.t. s_tor [erg].
+    z : float
+        Ion charge number (dimensionless, e.g. 1 for D+).
+    aiota : float or array
+        Rotational transform iota = 1/q.
+    sqrtg_bctrvr_phi : float or array
+        sqrt(g) * B^phi contravariant component [G cm^2].
+    av_nabla_stor : float or array
+        Flux surface average of |nabla s_tor| [1/cm].
+
+    Returns
+    -------
+    Om_tE : float or array
+        E x B rotation frequency [rad/s].
+    Er : float or array
+        Radial electric field (w.r.t. effective radius) [statV/cm].
+    """
+    dp_ds = T * dn_ds + n * dT_ds
+    dp_dr = dp_ds * av_nabla_stor
+    Er = dp_dr / (n * z * E_CGS)
+    Om_tE = C_CGS * Er / (aiota * sqrtg_bctrvr_phi)
+    return Om_tE, Er
Evidence
PR Compliance ID 9 requires a single unified API in python/src/neo2_ql/compute_omte.py with
optional arguments enabling Level selection. The new module defines only
compute_omte_diamagnetic() and provides no unified compute_omte(...) wrapper/interface with
progressive optional inputs (e.g., v_phi=None).

Unified compute_omte interface supports progressive optional arguments for Levels 0–2 and outputs Om_tE profile compatible with multispec input generator
python/src/neo2_ql/compute_omte.py[24-63]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A standalone Level 0 function was added, but compliance requires a single unified `compute_omte` interface that supports Levels 0–2 via optional arguments while remaining backward compatible.
## Issue Context
The Level hierarchy is intended to be enabled by optional inputs (e.g., `v_phi` for Level 1; enabling `v_theta`/neoclassical inputs for Level 2) so that omitting those inputs reproduces Level 0 behavior.
## Fix Focus Areas
- python/src/neo2_ql/compute_omte.py[24-63]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Divide-by-zero in Om_tE🐞
Description
compute_omte_diamagnetic performs unguarded division by (n*z) and (aiota*sqrtg_bctrvr_phi), so
zero/near-zero inputs yield inf/NaN that can silently propagate into downstream analysis. This can
happen for realistic profiles (e.g., n→0 at boundaries) or geometry terms approaching zero,
producing invalid Om_tE/Er without an explicit failure.
Code

python/src/neo2_ql/compute_omte.py[R59-62]

+    dp_ds = T * dn_ds + n * dT_ds
+    dp_dr = dp_ds * av_nabla_stor
+    Er = dp_dr / (n * z * E_CGS)
+    Om_tE = C_CGS * Er / (aiota * sqrtg_bctrvr_phi)
Evidence
The function directly divides by user-provided quantities with no validation or masking, so NumPy
will produce inf/NaN on zero denominators (typically without raising an exception).

python/src/neo2_ql/compute_omte.py[59-62]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`compute_omte_diamagnetic()` divides by `(n * z * E_CGS)` and `(aiota * sqrtg_bctrvr_phi)` without checking for zero/near-zero values. This can produce `inf`/`nan` results that silently propagate.
### Issue Context
This is a public API (exported from `neo2_ql.__init__`) and may be called with profiles/geometry that include zeros at boundaries or poorly-conditioned values.
### Fix Focus Areas
- Add explicit validation and clear error messages for invalid denominators (e.g., any `n == 0`, `z == 0`, `aiota == 0`, `sqrtg_bctrvr_phi == 0`).
- Alternatively (or additionally), implement safe division with `np.divide(..., where=...)` and return `np.nan` for invalid points (but be consistent and document it).
- python/src/neo2_ql/compute_omte.py[59-62]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment thread python/src/neo2_ql/compute_omte.py Outdated
Comment thread python/src/neo2_ql/compute_omte.py Outdated
Comment thread python/src/neo2_ql/compute_omte.py
@krystophny krystophny changed the title Add diamagnetic Om_tE computation from force balance (Level 0) Om_tE force-balance hierarchy: Levels 0-2 and full-output replay Apr 13, 2026
@krystophny krystophny changed the title Om_tE force-balance hierarchy: Levels 0-2 and full-output replay Clarify force-balance documentation and PDF build Apr 14, 2026
@krystophny krystophny force-pushed the omte-force-balance-level0 branch from a2067ef to 4c10b72 Compare April 14, 2026 22:19
Compute the E x B rotation frequency Om_tE from the pressure gradient
term of the radial force balance (no flows assumed). This is the
simplest model in the planned hierarchy (Level 0-2) for estimating
Om_tE from experimental profiles without running the full neoclassical
NEO-2 calculation.

Includes e2e validation against NEO-2 output from AUG #30835 showing
correct sign and order of magnitude (35% at s=0.25, 15% at s=0.50
of the full neoclassical result).

Closes #72
Remove dead code in e2e test, add exact regression values for Er and
Om_tE at rtol=1e-5, tighten ratio bounds from (0.01, 100) to (0.1, 1).
Document the physics (radial force balance, coordinate conventions,
CGS units), the model hierarchy (Level 0-3), algebraic consistency
with the NEO-2 Fortran implementation, and validation results against
AUG #30835. Includes references to Helander & Sigmar, Kim-Diamond-
Groebner, Viezzer et al., and NEO-2 papers.
The HDF5 multispec input was regenerated after the NEO-2 runs with
different profiles. Extract actual n, T, dn/ds, dT/ds from the
Fortran namelists which match the output n_spec/T_spec exactly.

With corrected profiles, Level 0 OVERESTIMATES |Om_tE| by 3-5x
(not underestimates as previously reported). This is physically
correct: the toroidal rotation and neoclassical flow terms partially
cancel the diamagnetic pressure gradient contribution.

Also fix sqrtg_bctrvr_phi unit annotation from [G cm^2] to [G cm],
verified by dimensional analysis through the full formula chain and
cross-checked against the AUG toroidal flux.
Explain why Level 1 uses the simple V^phi * B_theta product instead
of the full Fortran numerator V^phi * (iota*B_theta + B_phi): the
V^theta * B_phi term is O(B_phi) but cancels against V^phi * B_phi
through the neoclassical parallel momentum constraint. Without the
D31/D32/D33 transport closure, the cancellation is absent and Er
overshoots by two orders of magnitude.

Add the Boozer pair-product identity showing that the contravariant/
covariant product V^phi * B_theta_cov equals the physical product
v_phi * B_theta because the R factors cancel.

Mark the strict NEO-2 Vphi convention function as a building block
only, not a standalone reduced model.
The KDG formula in CGS is v_theta = K_i * c / (Z*e*B_phi) * dT/dr,
where c is the speed of light. The factor of c cancels against 1/c
in the force balance term -v_theta*B_phi/c, giving the net correction
Er_pol = -K_i * dT/dr / (Z*e). Without c the poloidal contribution
was 3e10 times too small (~4e-12 instead of ~0.13 statV/cm).

Show three Level 2 curves for the banana (K=-1.17), plateau (K=-0.5),
and Pfirsch-Schluter (K=+0.5) collisionality regimes. Cross-checked
against Kasilov (ntv_torque.tex eq. vpoltor) and MARS (knc=1.17 in
MarsQ_2022/common_ntv.f).
The geometry factor bcovar_phi^2 / (sqrtg_bctrvr_tht * <B^2>) was
squashing the KDG poloidal rotation estimate by ~15x. This is wrong
because the pair-product identity guarantees that v_theta * B_phi is
coordinate-independent: the metric factors in the Boozer contravariant
velocity and covariant field cancel exactly.

Without the geometry correction, the Level 2 k-scan from -2.5 to 1.5
now encloses the NEO-2 reference result at both AUG surfaces. The
best-fit k shifts from 1.5 (boundary, not enclosing) to ~1.08
(interior, enclosing).
The Level 1/2 force-balance functions paired V^phi with the covariant
B_theta_cov, but the correct Boozer-algebra rotation product is
sqrt(g)*B^theta * V^phi.  These quantities differ in sign and by a
factor of 3-5 because the inverse metric mixes the dominant B_phi_cov
into the contravariant B^theta.

Add compute_omte_toroidal_rotation_boozer (Level 1) and
compute_omte_neoclassical_poloidal_boozer (Level 2) using the correct
sqrtg_bctrvr_tht * Vphi product.  With the transport-derived k_ii
coefficient, the corrected Level 2 agrees with full NEO-2 within 8%
on the AUG 30835 benchmark (previously off by 3x).

Also add Boozer metric helpers (compute_boozer_metric,
compute_boozer_metric_from_bc, compute_boozer_metric_from_rz_profile)
for converting Boozer components to physical cylindrical quantities,
and update the fixture with R_Vphi_prof/Z_Vphi_prof geometry data.
Document the complete model hierarchy (Level 0 through full NEO-2
transport closure) using the notation from Kasilov et al., Phys.
Plasmas 21, 092506 (2014).  Includes the correct contravariant
Boozer pairing sqrt(g)*B^theta * V^phi, the k coefficient from
D32/D31, thermodynamic forces, and AUG 30835 benchmark results.

Wire into the CMake doc build via add_latex_document.
…ctions

Remove compute_boozer_metric, compute_boozer_metric_from_bc, and
compute_boozer_metric_from_rz_profile which attempted to fix the
force-balance pairing via an R/|e_theta| diagonal-metric correction.
This approach was wrong because the off-diagonal inverse metric
g^{theta phi} mixes the dominant B_phi_cov into B^theta, flipping
the sign.

The correct fix (already in place) uses the contravariant product
sqrt(g)*B^theta * V^phi directly from the NEO-2 output. Clean up
all docstrings and documentation to remove references to the
discarded pair-product identity and metric-correction approach.
Reads input profiles from the multispec HDF5 input and NEO-2 output
from all 100 surfaces of the 2016 controlled fusion benchmark.
Plots Er and Om_tE for Level 0/1/2, reduced single-ion, and full
NEO-2 across the full radial range.
Show V^phi (NEO-2 vs input), V^theta (NEO-2 vs KDG estimate),
Er term decomposition (diamagnetic, rotation, KDG poloidal), and
the full model hierarchy comparison across 100 radial surfaces.
Show Level 1 (rotation only), Level 2 with filled band between
banana (k=-1.17) and Pfirsch-Schluter (k=+0.5) limits, Level 2
with NEO-2 transport-derived k_ii, and full NEO-2 transport closure
on a single Om_tE panel.
row_ind_spec stores species tags (1=electron, 2=ion), not Python
array indices. The code was matching against ion_idx (array index 1)
instead of species_tag_Vphi (tag value 2), extracting the
electron-electron transport coefficients instead of ion-ion.

This gave k_ii ~ 0.27 (electron value) instead of the correct
k_ii ~ 0.60 (ion value), making the Level 2 curve wrong.

Replace all hardcoded D31_AX[:, 3] with explicit tag matching.
The code used KDG K_i values (banana = -1.17, PS = +0.5) in the
Kasilov formula Er = ... - k * dT/dr / (Ze), giving wrong signs.

In Kasilov notation k = 5/2 - D32/D31 (eq 8), the correct values
are banana = +1.17, plateau ~ +0.5, PS ~ -0.5.  The KDG K_i has
opposite sign convention and is a different physical quantity.

Fix select_poloidal_rotation_coefficient, all k-regime tables in
documentation, and the Level 2 band edges in the benchmark plot.
Level 3 retains the exact Boozer geometry factor
B_phi / (iota B_theta + B_phi) on the KDG term instead of
approximating it as 1. On AUG 30835 this is a ~2% correction,
so Levels 2 and 3 nearly overlap. Add to the Om_tE benchmark
plot and the LaTeX force-balance levels document.

Also gitignore DOC/Build/.
Implement compute_ftrap (trapped particle fraction from B(theta))
and compute_k_sauter (Sauter, Angioni & Lin-Liu, Phys. Plasmas 6,
2834, 1999) for computing k = 5/2 - D32/D31 without a neoclassical
code. On AUG 30835 the Sauter k agrees with the NEO-2 drift-kinetic
result within 16% across all 100 surfaces.
The Sauter formula requires the Hinton-Hazeltine nu*_i normalized
with eps^{3/2}, which differs from NEO-2's nu_star_spec by a
factor of ~4x. Add compute_nui_star_sauter that computes nu*
from plasma parameters (n, T, m, Z, R, q, eps) with the GACODE
Coulomb logarithm. This reduces the Sauter k error from 16% to
~9% compared to the NEO-2 drift-kinetic result.
Compute k from the Sauter analytical formula using ftrap from the
Boozer file and nu* from plasma parameters, and plot the resulting
Level 2 Om_tE alongside the NEO-2 k_ii curve for comparison.
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