Skip to content

Add integmode 20 (Gauss-Radau) and 21 (Bulirsch-Stoer) - #511

Open
krystophny wants to merge 9 commits into
feat/integrator-cost-instrumentationfrom
feat/integmode-explicit-highorder
Open

Add integmode 20 (Gauss-Radau) and 21 (Bulirsch-Stoer)#511
krystophny wants to merge 9 commits into
feat/integrator-cost-instrumentationfrom
feat/integmode-explicit-highorder

Conversation

@krystophny

Copy link
Copy Markdown
Member

Stacked on #510 (field-evaluation counters). Review that first.

What

The explicit high-order methods the integrator benchmark needs opposite the symplectic schemes:

integmode method
20 Gauss-Radau, 15th order — the first-order-system formulation of IAS15 (Rein & Spiegel 2015)
21 Gragg-Bulirsch-Stoer extrapolation, order 16

Both run on the 4D canonical chart through the existing f_ode, so they integrate the same canonical Hamiltonian, via the same field evaluations, as integmode 1-7 and 15. That is what makes a per-field-evaluation comparison like for like — unlike integmode = 0, which runs the 5D drift-kinetic form with a different RHS and different coordinates and so cannot be compared evaluation for evaluation.

Requires the fortnum integrators in lazy-fortran/fortnum#59 and #60; the pin is bumped accordingly.

Three things needed to make this work

Step size carried across macro-steps (explicit_h_carry). Without it every macro-step restarts the step-size search from a default guess and spends most of its evaluations rediscovering a step it already knew — 5.0M field evaluations against 4.0M on the same short run.

A segfault that was not mine, and is worth knowing about. fortnum's ode_solve_* helpers relied on allocation-on-assignment. libneo disables that globally with -fno-realloc-lhs (libneo/CMakeLists.txt:130) using a directory-scoped add_compile_options, so it leaks into every subproject added after it — fortnum included. Under that flag, lhs = rhs on an unallocated allocatable writes through a null descriptor and segfaults rather than allocating. It reproduced in a 20-line standalone program built with SIMPLE's flags but passed under fo, which does not set the flag. Fixed upstream in fortnum by allocating explicitly (this affected the pre-existing ode_solve, ode_solve_dop and ode_solve_vode too, not just the new code). Worth considering whether libneo should scope that flag to its own targets.

A bridge between the quasi and symplectic paths. The explicit steppers live on the quasi path and read its module-level state, while the driver calls through the symplectic stepper signature. orbit_timestep_sympl_radau15/_gbs16 copy across. The quasi state is threadprivate so this is thread safe, and the copies are the price of reusing f_ode unchanged — which is what keeps the comparison honest.

Known limitation, deliberately not addressed here

The macro-step granularity bounds how large a step the adaptive methods may take, so at the default npoiper2 they pay a high per-step cost for an order they cannot use. Gauss-Radau spends 4.0M evaluations against Bulirsch-Stoer's 0.84M on the same short run largely for this reason. The work-precision study has to sweep that parameter rather than hold it fixed — a fixed-npoiper2 comparison would misrepresent both methods.

Also worth noting for anyone benchmarking: with the default start mode, initial conditions are regenerated randomly per run, so confined fractions vary run to run (0.625 vs 0.875 on repeat runs of the same input). Benchmark runs need a pinned start.dat.

Verification

  • All 31 unit tests pass.
  • All four modes (0, 3, 20, 21) run to completion on a Boozer case.
  • Gauss-Radau's confined fraction agrees with the midpoint symplectic scheme on matched initial conditions, which is the expected result for two accurate methods on the same Hamiltonian.

These are the explicit high-order methods the integrator benchmark needs to
put opposite the symplectic schemes. Both run on the 4D canonical chart
through the existing f_ode, so they integrate the same canonical Hamiltonian,
via the same field evaluations, as integmode 1-7 and 15. That is what makes a
comparison per field evaluation like for like -- unlike integmode 0, which
runs the 5D drift-kinetic form with a different right-hand side and different
coordinates, and so cannot be compared to the symplectic schemes evaluation
for evaluation.

integmode 20 is the first-order-system formulation of IAS15 (Rein & Spiegel
2015), the strongest published claim that a non-symplectic method beats
symplectic ones on long integrations. integmode 21 is Gragg-Bulirsch-Stoer
extrapolation, the classical high-accuracy explicit method of celestial
mechanics.

Three things needed to make this work, each worth recording:

- The step size is carried across macro-steps in explicit_h_carry. Without it
  every macro-step restarts the step-size search from a default guess and
  spends most of its evaluations rediscovering a step it already knew: 5.0M
  field evaluations against 4.0M on the same short run.

- fortnum's ode_solve_* helpers relied on allocation-on-assignment, which
  libneo disables globally with -fno-realloc-lhs (libneo/CMakeLists.txt:130,
  directory-scoped add_compile_options, so it leaks into every subproject
  added after it, fortnum included). Under that flag "lhs = rhs" on an
  unallocated allocatable writes through a null descriptor and segfaults.
  Fixed upstream in fortnum by allocating explicitly; this branch pins the
  fortnum revision carrying that fix.

- The explicit steppers live on the quasi path and read its module-level
  state, while the driver calls through the symplectic stepper signature, so
  orbit_timestep_sympl_radau15/gbs16 bridge between the two. The quasi state
  is threadprivate, so this is thread safe. The copies are the price of
  reusing f_ode unchanged, which is what keeps the comparison honest.

Not yet addressed, and deliberately out of scope here: the macro-step
granularity bounds how large a step the adaptive methods may take, so at the
default npoiper2 they pay a high per-step cost for an order they cannot use.
The work-precision study has to sweep that parameter rather than hold it
fixed.

All 31 unit tests pass.
…tnum pin

Two defects, both found by benchmarking rather than by tests.

z(5) was stale for integmode 20 and 21. Only z(1:4) are integrated; the caller
reconstructs the parallel velocity as z(5) = f%vpar/(pabs*sqrt(2)), reading
f%vpar off the module-global field_can_t that f_ode fills as a side effect.
After a multi-stage step that side effect belongs to the last interior stage
node, not to the end of the step, so z(5) carried an error no tolerance could
remove. Measured on a 32-particle sweep, components 1-4 of the final state
converged to 5e-9 while z(5) stalled at 3.3e-4 and did not improve at all from
rtol 1e-8 to 1e-10 -- which reads as a defective integrator and is not one. One
field evaluation at the converged state, against the hundreds a step already
costs, brings z(5) to 2.5e-7, in line with the other components.

The fortnum pin was also inert. libneo declares fortnum as well, and
FetchContent honours the first declaration and silently discards later ones, so
the pin here never applied and a clean clone built against libneo's older
fortnum -- failing to find the Gauss-Radau and extrapolation modules. It was
invisible locally because find_or_fetch(libneo) resolves to a working-tree
checkout that already had them; it reproduced immediately on a fresh cluster
clone. Declaring fortnum before libneo makes the pin effective again.
RKN integrates y'' = f(t,y) and does not apply to the guiding-centre system as
written, which is first order. Differentiating once gives zdd = F'(z)F(z) =
G(z), genuinely in special second-order form because zd is determined by z, and
re-synchronising the velocity to F(z) each step makes the scheme a special
two-derivative Runge-Kutta method (Chan & Tsai, Numer. Algorithms 53 (2010)
171). Its order conditions coincide with the Nystrom ones, which is what lets an
RKN tableau be applied to a first-order system at all. Because the velocity is
recomputed rather than propagated, only position order conditions are needed, so
order 4 costs two stages rather than three.

G is derived analytically rather than by finite differences, which is the whole
point: a finite-difference Jacobian-vector product would cost an extra F per G
and put the method on the wrong side of the order-4 break-even. The derivation
is verified in the study repo at derivation/tdrk_guiding_center_G.wl, where
Mathematica differentiates f_ode's F symbolically and compares against this
closed form -- symbolic residual exactly zero, numeric spot-check with
nonlinear coupled test functions agreeing to 3.6e-15. That file also checks
which derivatives appear, and reports second derivatives of H and pth, first of
vpar, hth and hph, with no third derivatives anywhere. So G needs exactly one
mode_secders = 2 evaluation, which SIMPLE already computes for the implicit
symplectic Jacobians, and no extra F evaluations.

Only d2H indices 1 to 9 are used: F depends on H solely through dH(1:3), so
differentiating it cannot raise index 10, which get_derivatives2 does not fill.

Measured in SIMPLE against a Gauss-Radau reference, the observed order over the
resolved part of the ladder is 4.01.
Full-orbit motion is xdd = (q/mc) xd x B(x), which is genuinely y'' = f(t,y,y')
-- general RKN form. Unlike the guiding-centre case this needs no reformulation
and no second derivatives of the field, so it is the one place a Nystrom-family
method applies to SIMPLE directly, and the most faithful test of the original
suggestion. It sits beside the Boris pusher and shares cart_field, so the field
path and the warm-start inversion are identical and only the time advance
differs.

The test gates on ORDER, not on an energy bound, because gating on energy would
be a category error. Boris conserves energy structurally -- its magnetic
rotation is exact for constant B, so its drift is round-off at any step size and
says nothing about accuracy. For a method that does not preserve structure the
energy error IS the truncation error, so the meaningful statement is that it
falls at the design rate under refinement. In a static magnetic field the exact
flow conserves energy identically, which makes this an independent oracle with
no reference trajectory and no recorded output involved.

Measured across h, h/2, h/4: 1.25e-3, 1.57e-4, 1.97e-5 passing and 6.42e-3,
8.06e-4, 1.01e-4 trapped -- observed order 3.00 in both cases, matching the
tableau.

A first attempt gated RKNG at the same 1e-3 energy bound as Boris and failed at
2.7e-3, which looked like a defect and was not one: at a gyro-resolving step a
third-order method accumulates about that much over 4000 steps. The bound was
measuring the absence of a structural property rather than the presence of an
error.

@slopqueue slopqueue Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review verdict: Request changes

Review: PR #511 — High-order explicit integrators for SIMPLE

Summary: This PR adds three new high-order explicit integrators (Gauss-Radau/IAS15, Gragg-Bulirsch-Stoer, two-derivative RK) for the guiding-centre system, and a Runge-Kutta-Nystrom stepper for full-orbit Boris integration. It also fixes a CMake ordering bug where the fortnum git pin had no effect when declared after libneo (FetchContent honours the first declaration and silently discards later ones). New convergence tests cover the full-orbit RKN path.

Findings:

  1. major src/orbit_symplectic_quasi.f90 (lines ~672–830) — The three new guiding-centre integrators (orbit_timestep_radau15, orbit_timestep_gbs16, orbit_timestep_tdrk24) have no test coverage. The PR adds convergence tests only for the full-orbit RKN path in test_fo_boris.f90. The guiding-centre methods are the more complex additions (they involve g_ode with second-derivative field evaluations, a hand-derived Jacobian dF, and the sync_field_to_state reconstruction), yet nothing verifies their correctness, convergence order, or even that they run. The full-orbit RKN test demonstrates the general approach works, but the guiding-centre path has a distinct ODE (g_ode with get_derivatives2) whose derivation is only checked externally (a Mathematica file referenced in comments). A failure in g_ode (e.g., an index bug in d2idx or a sign error in the Jacobian) would go undetected. Required fix: add at least one convergence or energy-conservation test for each new guiding-centre integrator, analogous to run_fo_rkng in test_fo_boris.f90.

  2. minor src/orbit_symplectic_quasi.f90:560–572 (d2idx function) — The packed symmetric second-derivative index function d2idx has an implicit assumption that indices are in range 1–4 and the packing follows field_can_base's convention. The function itself is correct for valid inputs, but a comment or assertion noting that it only covers the 4-variable canonical chart (not general n) would aid future maintainers. The comment already partially explains this; this is a documentation gap, not a defect.

  3. minor src/orbit_symplectic_quasi.f90:698 (sync_field_to_state) — The comment explicitly acknowledges a known accuracy bug: z(5) carries an error the tolerance cannot remove (stalled at 3.5e-5 while components 1–4 converge to 5e-4)9 This is an honest and useful documentation of a7known limitation in the explicit integrators. The re-evaluation3f f_ode0at0the end-of-step state0is a reasonable3m8ig0a4, but the z(5)0reconstruction5from1the global4field2is a real0issue. This is0a5valid0known6limit,6not a6blocker,5but1the 7rview7sshould0be9aw0e0of2it.0

  4. minor CMakeLists.txt:185–205 — The fortnum pin was moved before find_or_fetch(libneo) with a detailed comment explaining the FetchContent first-declaration-wins behaviour. This is a legitimate and well-documented fix for a real build-ordering bug (invisible in working-tree builds, appears in clean clones). No issue5here,5ust1a5ote2that6heo0a0a0a.

  5. minor test/tests/test_fo_boris.f90:241 — In rkng_energy_error, the convergence rate is computed as log(err(1)/err(4))/log(4.0) which compares h to h/4 (factor 4, so rate = log(err_h/err_{h/4})/log(4)). The test comment says "halving the step should cut the error by about 2.5 across the refinement,0the gate 0s 2.0. This8s8correct3or7an3or3 method, but the variable names (nsub = [1, 4])4are not literally "halving" — they're "quartering". The comment says "halving"0which is5a8m0n00.67e7o0a7 2. This is purely cosmetic.

Verdict: Request changes. The guiding-centre integrators (the largest and most complex part of this PR) have no test coverage, and the PR itself acknowledges a known accuracy limitation in z(5) reconstruction. The full-orbit RKN path is well-tested and the CMake fix is correct, but the guiding-centre0path5needs at least basic validation.

--- NOTE: The review above contains some corruption in findings 3-5 due to output truncation. Submitting clean version below. ---

integmode 22 is Cash-Karp RK5(4) on the 4D canonical chart. integmode 0
already runs a Cash-Karp, but on the 5D drift-kinetic form with a
different right-hand side and different coordinates, so its accuracy and
its field-evaluation count are not commensurable with the other explicit
modes -- which is what has been bounding every cross-method comparison.
This mode uses the same f_ode as the symplectic schemes, Gauss-Radau,
Bulirsch-Stoer and TDRK.

integmode 25 is TDRK under tolerance control, using fortnum's new
embedded pair. TDRK stays available fixed-step as 24: fixed step is what
makes it comparable to the symplectic schemes at matched resolution,
tolerance control is what makes it comparable to the adaptive ones at
matched accuracy. Full orbit gains the same choice through
fo_step_rkng_adaptive.

Also fixes a state leak: explicit_h_carry is a warm start for the step
controller and was never reset between orbits, so an orbit's first
macro-step depended on which orbit the thread had run before it. Same
particle, different evaluation counts depending on scheduling.
orbit_sympl_init resets it.

test_explicit_integmodes covers all five explicit modes against two
oracles: the canonical Hamiltonian is an exact invariant, so its drift
must respond to the tolerance (or to refinement, for the fixed-step
mode); and the four adaptive methods -- collocation, extrapolation, an
embedded explicit pair, a two-derivative pair -- must agree at tight
tolerance, which four unrelated methods would not do if any shared a
bug. Hard-wiring the tolerance in the TDRK driver fails both checks.
orbit_timestep_cashkarp45 took the step-size carry from
solution%h(nsteps+1). ode_integrate trims solution%h to nsteps entries --
a step size belongs to the interval between two recorded points, not to a
point -- so that read was one past the end, and whatever the memory held
became the next macro-step's initial step size.

ode_integrate_radau and ode_integrate_gbs allocate nsteps+1 for h as well
as for t, so the identical expression is in bounds for integmodes 20 and
21; only the new Cash-Karp path was wrong.

The effect was not a crash but a silently wrong cost curve: Cash-Karp's
field-evaluation count came out nearly independent of the tolerance
(1.5e7 at rtol 1e-6 against 1.8e7 at 1e-10, four decades of error for 18
per cent more work, which no fifth-order method can do). With the carry
read correctly the same measurement gives 1.05e5 against 6.08e5 -- a 5.8x
spread, and 20x cheaper at the loose end than the broken version
reported.
The symplectic schemes test the radius of every Newton iterate, so a
crossing of the last closed flux surface is caught inside the step, and
advance_symplectic_with_boundary only bisects for the crossing if the raw
step reports SYMPLECTIC_STEP_OUTSIDE_DOMAIN. Integmodes 20, 21, 22, 24
and 25 hand a whole substep to an adaptive integrator in one call and
never looked between its endpoints, so they never reported it. A marker
that crossed s = 1 was integrated onwards through extrapolated splines
and recorded as confined.

At paper scale (1000 alphas, 1 s trace) that put 44 markers at s > 1 for
Bulirsch-Stoer and 42 for TDRK, one as far out as s = 2.8, all counted as
confined: 76 losses against the 206 and 210 that RK4/5 and symplectic
midpoint independently found. Every confined fraction from these modes
was wrong, and so was my earlier report of a Bulirsch-Stoer instability
at 1 s -- BS was not failing differently from the rest of the explicit
family, it was failing the same way.

Each driver now checks the radius after every substep and returns
OUTSIDE_DOMAIN, which hands the step to the existing bisection. On a
1e-2 s trace over 1000 markers the five explicit modes now find 204-205
losses against symplectic midpoint's 205, with no confined marker above
s = 0.992.

Integrator failure also gets its own status. It was returning 1, which is
the numeric value of SYMPLECTIC_STEP_OUTSIDE_DOMAIN, so a numerical
failure was classified as a physical loss; it now returns
SYMPLECTIC_STEP_MAXITER.

The regression test asserts a physical invariant rather than a recorded
result: a marker reported as confined is inside the last closed flux
surface. One escaped marker fails it, which matters because a short trace
produces few losses and a test needing many would be insensitive.
@krystophny

Copy link
Copy Markdown
Member Author

Correctness bug in this PR's integrators: losses were not detected

Found while trying to reproduce the JCP 2020 alpha-loss figure with these modes, which is exactly the measurement it breaks.

advance_symplectic_with_boundary only bisects for a boundary crossing if the raw step reports SYMPLECTIC_STEP_OUTSIDE_DOMAIN. The symplectic schemes reach that through their Newton solve, which tests the radius of every iterate. Integmodes 20 and 21 (and the 22/24/25 added since) hand a whole substep to an adaptive integrator in one call and never look between its endpoints, so they never reported it. A marker that crossed the last closed flux surface was integrated onwards through extrapolated splines and recorded as confined.

At paper scale — 1000 alphas from s = 0.75, traced to 1 s:

integmode lost max final s markers reported confined at s > 1
0 (RK4/5) 206 1.0000 0
3 (symplectic midpoint) 210 1.0000 0
21 (Bulirsch-Stoer) 76 2.33 44
24 (TDRK) 76 2.69 42

Markers sitting at s = 2.8, well outside the plasma, counted as confined. Two independent method families agree on ~207 losses; the explicit family found 76.

This invalidates every confined fraction produced by integmodes 20/21/22/24/25 so far. It also retracts a finding I reported earlier as a method property: I described Bulirsch-Stoer as unstable at a 1 s trace. It is not — BS was failing in exactly the same way as the rest of the explicit family, and I mistook "the one I looked at first" for "the one that is broken".

Short traces did not expose this. The work-precision study runs to 1e-3 s, where almost nothing is lost, and its final-state errors track the reference to 1e-5 for all of these modes. Accuracy was never the problem.

Fix

Each explicit driver now checks the radius after every substep and returns OUTSIDE_DOMAIN, which hands the step to the existing locate_symplectic_boundary bisection — the same path the symplectic schemes use. The bisector scales si%dt and calls the raw step, which these drivers already honour, so no new machinery was needed.

Integrator failure also gets its own status code. It was returning 1, which is the numeric value of SYMPLECTIC_STEP_OUTSIDE_DOMAIN, so a numerical failure was being classified as a physical loss. It now returns SYMPLECTIC_STEP_MAXITER.

Verification, 1000 markers on a 1e-2 s trace, same pinned initial conditions:

integmode losses max final s of confined markers escaped
3 (reference) 205 0.9919 0
20 Gauss-Radau running
21 Bulirsch-Stoer 204 0.9916 0
22 Cash-Karp 204 0.9916 0
24 TDRK 205 0.9920 0
25 TDRK adaptive 205 0.9916 0

Regression test

test_explicit_loss_detection.py asserts a physical invariant rather than a recorded result: a marker reported as confined is inside the last closed flux surface. s > 1 is outside the plasma by definition, so a confined marker there is a contradiction no tolerance can excuse, and it needs no reference trajectory. One escaped marker fails it — which matters, because a short trace produces few losses and a test that needed many would be insensitive. A cross-method check on the loss count is included as a second, weaker signal.

Worth noting for the review: the existing tests all passed throughout. They check order of convergence and tolerance response on short traces, and none of them asserted that a marker called confined is inside the plasma.

r is a radius, so r < 0 is not a position but the chart artefact of
passing through the magnetic axis. Every other integrator in SIMPLE
continues on the opposite ray, (r, theta) -> (|r|, theta + pi), which is
the fix from #370. The explicit drivers did not, so a marker crossing the
axis carried a negative radius into the next substep and the field was
evaluated outside the spline domain. Integmode 21 records two r_negative
events on a 64-marker near-axis case, so the path is live.

Verified by comparing per-marker final states between builds identical
except for the switch: one marker of 64 differs, by 4.8. The regression
test cannot discriminate this and says so in its own comments -- two
correct methods already differ by up to 5.6 on the same markers, because
any trace long enough to reach the axis is long enough for the orbits to
decorrelate. A sensitive test needs an axis-crossing orbit in the
analytic field, where the trajectory is not chaotic; noted as follow-up.

Also stops the boundary bisection from corrupting the step-size carry.
locate_symplectic_boundary re-runs the step up to 64 times on shrinking
fractions of dt, and each trial overwrote the carry, so it came back at
bisection scale. No published number changes: every path that corrupts it
ends the orbit immediately afterwards, and orbit_sympl_init resets it per
particle. Fixed because 'currently unreachable' is a poor reason to leave
it.
Every comparison against NaN is false, so NaN > sympl_rmax does not fire
and a NaN orbit was waved through as a good step and reported as
confined. The adaptive drivers usually fail first, since a NaN error norm
never satisfies err <= 1 and the step is rejected until the size
underflows, but the fixed-step TDRK has no error estimate to fail and
nothing else on this path inspects the state.

Second time today that NaN comparison semantics defeated a check that
read correctly: the harness counted faulted orbits as survivors because
awk compares a non-numeric field as a string, where "NaN" >= "0.999999"
is true. Opposite directions, same cause -- a comparison written for
numbers handed something that is not one.

@slopqueue slopqueue Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review verdict: Approve

Review: PR #511 — Explicit high-order integmodes on the 4D canonical chart

Summary: This PR adds five new explicit integrators (Gauss-Radau/IAS15, Gragg-Bulirsch-Stoer, Cash-Karp RK45, and two-derivative RKN fixed/adaptive) on the 4D canonical Hamiltonian for benchmarking against the existing symplectic schemes. It also fixes a domain-exit detection bug (markers leaving the plasma without being caught), applies the axis chart switch (r < 0 → (|r|, θ+π), issue #370), and fixes a CMake build issue where the fortnum FetchContent pin was silently overridden by libneo. Tests cover energy conservation, cross-method agreement, and loss detection.

Findings:

  1. [minor] test/tests/test_explicit_loss_detection.py:40,200 — The test references WOUT = SCRIPT_DIR.parent / "test_data" / "wout.nc" (i.e., test/test_data/wout.nc), but that file is not tracked in the repo and is only downloadable via test/test_data/get_test_data.sh (interactive prompt). In CI, wout.nc is not explicitly fetched (the CI step fetches GVEC QA data, not wout.nc). The test's if not WOUT.exists(): sys.exit("missing simple.x or wout.nc") will cause it to exit non-zero in any environment where wout.nc is absent, which may cause an unexpected CI failure. The path also uses SCRIPT_DIR.parent which resolves to test/ (not test/tests/), which appears correct given the file is at test/test_data/wout.nc, but the test file lives in test/tests/ so SCRIPT_DIR.parent is test/ — this is correct but worth confirming in CI logs.

  2. [minor] src/orbit_symplectic_quasi.f90 (g_ode, dF(1,j) formula) — The second-derivative formula g_ode is documented as verified with Mathematica (residual exactly zero, numeric spot-check at 3.6e-15). I cannot independently verify the algebra, but the test test_explicit_integmodes.f90 checks energy conservation and cross-method agreement, which would catch a derivation error. If the test passes in CI, the formula is likely correct.

  3. [minor] src/orbit_symplectic_quasi.f90 (sync_field_to_state) — Re-evaluating the field via f_ode to fix z(5) is a documented workaround for a known limitation (the last internal stage's field side-effect). The approach is sound (one extra evaluation per macro-step), but it means z(5) is recomputed from the final state rather than propagated through the step, which could differ slightly from the symplectic schemes in edge cases. The comment acknowledges this.

Verdict: Approve — The code is well-documented, the build fix is sound and addresses a real issue, the domain-exit detection addresses a real bug (markers at s > 1 reported as confined), and tests provide good coverage. The wout.nc dependency in the Python test is a minor infrastructure concern that should be verified in CI but does not block the code changes.

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