Skip to content

Profiling examples: polar vs tensor-product splines (Poisson, CurlCurl, GyrokineticPoisson; cylinder, torus); pproc of polar fields - #759

Merged
spossann merged 7 commits into
polar-stiffnessfrom
polar-profiling
Oct 13, 2026
Merged

spossann merged 7 commits into
polar-stiffnessfrom
polar-profiling

Conversation

@spossann

@spossann spossann commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Summary

Profiling examples that show what polar splines can do: Poisson, CurlCurl and GyrokineticPoisson on a cylinder and on a torus. The manufactured solution has its largest gradient on the axis r = 0. Each case compares:

  • C1 polar splines on IGAPolarCylinder / IGAPolarTorus (no hole);
  • tensor-product splines on HollowCylinder / HollowTorus with a 1e-2 hole, the geometry of the existing profiling examples.

Stacked on #758 (polar StiffnessPreconditioner). The base is polar-stiffness, so this diff shows only the new commits.

Layout

  • One params file per model and geometry: profiling/examples/{Poisson,CurlCurl}/polar_{cylinder,torus}_strong_scaling/params_*.py. Command-line flags select the variant:
    • --splines polar|tensor
    • --precond
    • --poloidal-solver schur|lu|ilu
    • --hole-bc free|dirichlet
    • --num-elements
  • One submit script per model and geometry, with --variant: submit_{poisson,curl_curl}_polar_{cylinder,torus}_strong_scaling.py. The variant table sets the flags through ProfilingCase.launch(param_flags=...), and the script is run once per variant. Several ProfilingCases can't share one process: without --upload, setup_run deletes the shared output folder.
  • Variants:
  • Grid: (64, 66, 64), degree (3, 3, 3), for both spline spaces. Polar splines need a multiple of 3 elements in η2.
  • Exact solution: a Cartesian Gaussian (w = 0.2) centred w/√2 off the axis, where its gradient is largest. The right-hand side is derived with sympy; for CurlCurl, E = g c. On top of the existing outputs, the error close to the axis (r < 0.1) and variant.json are saved.
  • BC at the hole (tensor-product variants):
    • Poisson uses the natural BC.
    • CurlCurl uses Dirichlet. With the natural BC, the BC at the hole contradicts the solution, whose curl is largest on the axis. With σ = 1e-2 the error then grows to O(10³).

Fix: post-processing of polar fields

Output._load_femfields wrote the saved coefficients into a PolarVector. That either failed, or the field evaluated to zero, because SplineFunction.__call__ re-extracts from the polar vector. Now the saved tensor-product (extracted) coefficients are loaded into vector_stencil, and the polar vector is recovered with the restart extraction operator: once per snapshot, cached per space. New test test_pproc_polar (serial and parallel pproc) fails without the fix.

GyrokineticPoisson

  • profiling/examples/GyrokineticPoisson/polar_{cylinder,torus}_strong_scaling/params_gyrokinetic_poisson.py and their submit scripts, with the same flags.
  • Equilibria, as in the existing hollow GKP examples:
    • ScrewPinch (a=1, R0=1, Lz=2π), which_geometry="cylindrical";
    • AdhocTorus (a=1, R0=3, q_kind=0, p_kind=1), which_geometry="toroidal".
    • Defaults stab_mat="M0ad_withT" and diffusion_mat="M1gyro".
  • Manufactured solution: the same off-axis Gaussian, times a modulation along η3, so the average ⟨φ⟩ is non-zero.
    • Cylinder: ⟨φ⟩ is exact.
    • Torus: ⟨φ⟩(r) = ∫g R dθ / ∫R dθ, tabulated by trapezoidal quadrature in θ.
    • ρ is computed with sympy in Cartesian coordinates. At startup, the symbolic n0, p and B0 are checked against the struphy equilibrium (n_xyz, p_xyz, b_xyz).
    • Leaving ⟨φ⟩ out of ρ raises the error from 6e-4 to 36%, so the averaged term is really tested.
  • Variants: tensor_{none,stiffness,multigrid}, polar_{none,tensor_mass,mass,mass_ilu,stiffness,stiffness_ilu}.

Small grid (16 × 18 × 16, serial):

variant cylinder: its torus: its
polar, stiffness (Schur / ILU) 7 / 7 5 / 5
polar, none 55 76
polar, polar mass 419 311
polar, tensor mass 738 521
tensor-product, none 63 82
tensor-product, stiffness 82 72
tensor-product, multigrid 264 226

The error in φ depends only on the spline space: polar 4.9e-4 (cylinder) and 6.2e-4 (torus), tensor-product 1.5e-2 and 1.4e-2.

Torus at 32 × 33 × 32, 4 ranks, stiffness preconditioner:

splines its solve time error in φ peak memory (4 ranks)
polar 4 3.1 s 4.0e-5 7.0 GB
tensor-product 109 31 s 2.2e-2 5.6 GB
  • Full-size runs (64 × 66 × 64) are for the cluster. They did not fit into 15 GB on a workstation; already at 32³ the tensor-product run takes 5.6 GB.
  • The polar stiffness preconditioner is not exact for GKP (4–7 iterations rather than 2), because it drops the C^23 (η2–η3) coupling of M1gyro, see Polar splines: StiffnessPreconditioner (grad, curl) and stab_average for GyrokineticPoisson #758.
  • The tensor-product preconditioners are worse than none on the hollow cylinder with the free hole BC: stiffness 82, multigrid 264, none 63.

Compacted layout of the existing cube/hollow examples

  • The existing Poisson, CurlCurl and GyrokineticPoisson cube/hollow strong scaling cases now use the same layout as the polar ones:
    • one params file per model and geometry ({geo}_strong_scaling/params_*.py, flags --precond and --num-elements);
    • one submit script per model and geometry (--variant, default none).
  • Removed: 19 per-variant params folders and 19 submit scripts (about −6300 lines).
  • The labels of the former variants are unchanged (e.g. poisson_hollow_cylinder_strong_scaling_multigrid), so the result histories in the profiling-data repo continue. Names and descriptions are also byte-identical.
  • The flags of a run are recorded in the packaged job script (slurm_script_str).
  • Verified:
    • All 27 former variants (at 16³) give identical CG iterations and errors with the old and the new files.
    • A review compared each variant's effective parameters, names, descriptions and labels against the old files. The explicit multigrid=MultiGridOptions() was removed because it equals the propagator default.

Results

Small grid (16 × 18 × 16, serial; Poisson cylinder 16 × 18 × 8), Stiffness preconditioner:

case polar: iterations, error tensor-product (hole): iterations, error
Poisson cylinder 2, 8.8e-4 81 (no pc), 1.35e-2
Poisson torus 2, 5.4e-4 89 (no pc), 1.36e-2
CurlCurl cylinder 6, 1.4e-3 65, 1.24
CurlCurl torus 8, 1.3e-3 118, 24.6

Full size, Poisson cylinder, 64 × 66 × 64, 4 ranks, Stiffness preconditioner:

splines iterations solve time max error in φ
polar 2 4.0 s 2.1e-6
tensor-product 192 158 s 2.9e-2

The tensor-product error does not converge under refinement because of the hole.

Notes for interpreting the runs:

  • At full size, the unpreconditioned CurlCurl variants (and probably polar_tensor_mass) will hit maxiter = 10000. Their timings then measure 10000 iterations, not a solve.
  • The mass-matrix preconditioners are worse than none for Poisson (they approximate M0, not the Laplacian). They are kept as reference variants.
  • MultiGrid on the hollow torus with the free hole BC is worse than no preconditioner for Poisson. This is not a regression of this PR.

Tests

  • test_pproc_polar (new): serial and 2 ranks.
  • post_processing/tests/test_output.py and test_pproc.py (including --with-mpi on 2 ranks) pass.
  • All six params files run in serial and on 2 ranks. All 17 GKP variants run at small size. The reviewer ran all 37 variants at small size: all exit 0.
  • Two separate review passes (Poisson/CurlCurl, and GKP with an independent finite-difference check of the operator) found no bugs; their suggestions are applied.

🤖 Generated with Claude Code

spossann and others added 5 commits October 12, 2026 12:16
…l; cylinder, torus); pproc of polar fields

- New profiling cases Poisson/CurlCurl polar_{cylinder,torus}_strong_scaling: one params file per
  model and geometry, variants (spline space, preconditioner, poloidal solver) selected with flags,
  set by the submit scripts (--variant). Polar splines on IGAPolarCylinder/IGAPolarTorus versus
  tensor-product splines on HollowCylinder/HollowTorus (a1=1e-2); manufactured solutions with the
  largest gradient on the axis (Gaussian centered w/sqrt(2) off the axis), RHS by sympy; errors
  also saved close to the axis.
- Output: load polar fields from the saved extracted coefficients and recover the polar vector
  (as for restarts); before, polar fields evaluated to zero (or failed). Test test_pproc_polar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts once per snapshot, cached; parallel pproc in test; --precond choices)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es (cylinder/ScrewPinch, torus/AdhocTorus)

- polar_{cylinder,torus}_strong_scaling/params_gyrokinetic_poisson.py with the flags of the Poisson
  and CurlCurl examples, and submit scripts with --variant.
- Manufactured solution with the largest gradient on the axis and a non-zero average <phi>
  (exact for the cylinder, quadrature in theta for the torus); rho from sympy in Cartesian
  coordinates, with the equilibrium checked against the struphy equilibrium at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mory), wider equilibrium checks, polar_mass_ilu variant

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@spossann spossann changed the title Profiling examples: polar vs tensor-product splines (Poisson, CurlCurl; cylinder, torus); pproc of polar fields Profiling examples: polar vs tensor-product splines (Poisson, CurlCurl, GyrokineticPoisson; cylinder, torus); pproc of polar fields Oct 12, 2026
…nd geometry for the existing cases

The per-variant folders of the Poisson, CurlCurl and GyrokineticPoisson cube/hollow strong scaling
cases differed only in the preconditioner. They are merged into one params file per model and
geometry (flags --precond and --num-elements; name and description from the preconditioner) and
one submit script with --variant (default none). The labels of the former variants are kept, so
that the result histories in the profiling-data repo continue. 19 params files and 19 submit
scripts removed (about -6300 lines); all 27 former variants give identical iterations and errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@spossann
spossann requested a review from max-models October 13, 2026 14:29

@max-models max-models left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In these submit scripts, there are way more parameters than we had in the old ones. It's not really a problem, but sometimes it may be hard to know exactly what was used when inspecting the results. Looks clear enough to my human eyes though.

@spossann
spossann merged commit e310422 into devel Oct 13, 2026
28 checks passed
@spossann
spossann deleted the polar-profiling branch October 13, 2026 15:35
spossann added a commit that referenced this pull request Oct 13, 2026
…ines in particle propagators) (#761)

Stacked on #759 (`polar-profiling`). It retargets to `devel` once
#758/#759 are merged.

## Summary

Particle markers can now run on domains with a **pole at η1 = 0** (no
hole around the axis), with polar splines for the FEEC parts. Examples
are `IGAPolarCylinder` and `IGAPolarTorus` (HollowCylinder/HollowTorus
with `a1=0` also work in kernels). Before this PR, `Vlasov` and
`GuidingCenter` failed on such domains: several propagators did not
handle `PolarVector`, no marker boundary condition handled the axis, and
the kernels divided by det DF = 0.

This prepares the Vlasov/GuidingCenter profiling examples (next PR,
stacked on this one).

### Marker push through the axis
- **New `update_position_stage`**
(`pic/pushing/pusher_utilities_kernels.py`): the stage update of the
explicit Runge–Kutta pushers (PushEta, explicit GC bxE\* and B\*) is
done in ξ = η1·(cos 2πη2, sin 2πη2), keeping η3.
- In these coordinates the mapping and the marker velocity dξ/dt =
(∂ξ/∂η)·dη/dt are regular at the pole, and markers move through the
axis.
- Straight lines through the axis of a cylinder are exact (tested to
1e-10).
  - Without a pole the update is unchanged.
- **Axis map** in `apply_kinetic_bc_marker` (Pyccel and CUDA) and
`Particles.apply_kinetic_bc`:
- A marker with η1 < 0 is mapped to (−η1, η2 + ½), with unchanged
velocities.
- η1 is kept ≥ 1e-10, also after loading (Sobol loading starts at η =
0).
  - The boundary condition of axis 0 then applies at η1 = 1 only.
- **New field `MarkerArguments.pole`**, also in `CudaMarkerArguments`
and `pusher_args.cuh`.
- **Not supported yet:** pole domains on the CuPy backend, and
`poc`/`pol_period` ≠ 1. Both raise `NotImplementedError`.

### Polar splines in particle propagators
- **PushVxB:** the projected equilibrium `b2` (already tensor-product)
was extracted a second time, which failed with polar splines. Only the
`b2` variable is extracted now.
- **PushGuidingCenterBxEstar/Parallel:** φ, the electric field and b̃
are passed to the kernels as tensor-product coefficients (`PolarVector`
has no `_data`). The gradients use `derham.grad`, boundary operators
included, followed by E^T. Without polar splines nothing changes.

### Other fixes found along the way
- **Reflecting boundary:** only Cartesian velocities (vdim = 3) are
reflected. For Particles5D the columns 3:6 (v∥, μ, **weight**) were
overwritten.
- **`CanonicalMaxwellian2D`:** removed an unused read of
`domain.params["a1"]`, which raised a KeyError on IGAPolarTorus.
- **`AdhocTorus`, `AdhocTorusQPsi`:** the ψ derivatives were 0/0 (NaN)
on the magnetic axis. Off the axis they are unchanged (4e-16); on the
axis they take the correct limits.
- Note: AdhocTorusQPsi's interpolated ψ has ψ′(0) ≈ −6e-5 ≠ 0, so its
field is not exactly regular on the axis. That is pre-existing and not
changed here.
- **`GuidingCenter`:** `en_fB` summed over holes and ghosts instead of
the valid markers. Each removed marker added +1 to `en_tot`.

### Disc loading under MPI (pre-existing bug, not pole-related)
With `spatial="disc"` on more than one process, markers were lost in the
first push or the run hung:
- **Disc transform after the selection:** `pseudo_random` loading
selected the uniform draws on each process's domain and applied η1 → √η1
afterwards. That moved many markers to another process's domain.
- Without sorting boxes they were not sent there, so the first push
evaluated the fields outside the local domain.
- Example: GuidingCenter on 4 processes removed 15% of the markers in
the first step.
  - The transform is now applied before the domain check.
- **Sort after loading:** markers are sent to their process even without
sorting boxes (not for restart loading). Sobol loading draws on the
whole domain on each process, so it needed this too.
- **Marker array size:** the number of markers per process was estimated
with the uniform measure. With the disc measure 2η1, processes at larger
η1 overflowed the marker array: a ValueError on some processes and a
hang on the others.
- **New test:** `test_draw_disc_on_own_process` (pseudo_random and
sobol_standard) in `test_draw_parallel.py`.

### Known limitations
- **Discrete-gradient GC pushers:** they only get the axis map. Their
mid-points in η are not regular near the axis.
- In the test (ε = 1), relative energy errors near the axis reach 1e-3
to 5e-2.
- With the explicit RK4 pushers, markers that cross the axis conserve
their energy to about 1e-6.
- **Buffer size:** with few markers per process, the discrete-gradient
pushers need a larger `bufsize` on 4 ranks (load-imbalance abort in the
box sort). This is also the case on a torus with a hole, so it is not
pole-related.

## Tests
New file `src/struphy/pic/tests/test_pole.py`, which passes serially and
on 4 MPI ranks:
- the axis map in the kernel and in Python, including a marker with η1 >
1;
- reflection for vdim = 2 and 3;
- PushEta straight lines through the axis (forward Euler, Heun2, RK4)
against the exact Cartesian end points;
- Vlasov on IGAPolarCylinder with ScrewPinch and polar splines: kinetic
energy conserved to 1e-12;
- GuidingCenter on IGAPolarTorus with AdhocTorus and polar splines,
explicit and discrete-gradient pushers: energies per marker, compared by
ID, including markers that cross the axis.

Also run locally:
- `test_pushers.py`, `test_reflect.py`, `test_kernel_setup.py`,
`test_kernel_backends.py`, `test_accum_gc_gradB.py`,
`test_maxwellians.py` and `fields_background/tests` (serial);
- the default-params runs of Vlasov and GuidingCenter.

A separate review agent checked the branch before the push. Its findings
are fixed in the second commit:
- the Python axis map caught η1 > 1;
- the clamp covered only one loading branch;
- the GC gradients had dropped the boundary operators.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


Follow-up (stacked): #762 adds the Vlasov/GuidingCenter profiling
examples that use this.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
spossann added a commit that referenced this pull request Oct 13, 2026
…ingCenter (cylinder/torus without hole) (#762)

Stacked on #761 (`particle-pole`, which is stacked on #759). It
retargets once those are merged.

## Summary
New profiling examples for the particle-tracing models **Vlasov** and
**GuidingCenter**. They are long-time equilibrium tests with binning
plots in position and velocity space, and the propagator algorithms are
compared through variants.

The layout is the same as for the existing cases (#759):
- one params file plus one submit script per model and geometry;
- the variant is chosen with `--variant`;
- rank counts 1, 2, 4, …, 256;
- post-processing writes `.png`/`.npy` files to `sim_<id>/results`.

### Cases
- **`Vlasov/cuboid_strong_scaling`**
- Setup: Maxwellian3D with constant moments (n=1, u=0, vth=1) in a
periodic cube (Cuboid, side 2π) with a uniform B along z (HomogenSlab).
This is an exact equilibrium.
- Binning: η1, (η1, η2), v1, v3 and (v1, v2), compared with the initial
and the exact profiles.
- Variants: push_vxb analytic/implicit; push_eta forward
Euler/Heun2/Heun3/RK4; Strang splitting; Sobol loading.
- **`GuidingCenter/polar_cylinder_strong_scaling`**
- Geometry: straight cylinder **without hole** (IGAPolarCylinder, C1
polar splines) with ScrewPinch (Lz = 2πR0, pressure offset p0 so that
vth > 0 at the edge).
- Initial condition: GyroMaxwellian2D with n(r) and vth(r) = √(p/n) from
the equilibrium. It is an exact equilibrium, because the GC drifts keep
r constant. Markers move through the axis.
- Binning: η1, (η1, η2), v∥, μ and (η1, v∥). The radial profile is also
compared with the exact one, n/|B0|.
- **`GuidingCenter/polar_torus_strong_scaling`**
- Geometry: full torus **without hole** (IGAPolarTorus, polar splines)
with AdhocTorus.
- Initial condition: CanonicalMaxwellian2D with n and vth as functions
of ψc (an exact equilibrium). The density vanishes at the edge, where
markers on orbits that leave the plasma are removed.
- Control: a GyroMaxwellian2D with the same profiles in the flux label
of the position. It is not an equilibrium in a torus.
- **GuidingCenter variants:** discrete gradient 1st order (fixed point,
Newton), 2nd order, explicit RK4 and Heun3. The torus adds
GyroMaxwellian controls.
- Default grid: 16×48×48, decomposed only in η2/η3, so 1–256 processes
work. ppc 25, dt 0.2, T = 100.
- **`profiling/examples/particle_diagnostics.py`:** shared
post-processing.
- Binning plots at the first and last time (2D slices also as difference
maps).
  - Relative L2 deviation from t=0 and from the exact profile over time.
- Relative drift of `kinetic_energy`/`en_tot`; scalars that start at 0,
such as lost markers, are plotted as absolute values.

### Notes
- **No hole at r = 0:** a Simulation needs polar splines whenever the
domain has a pole, and polar splines need a spline mapping. So the cases
use IGAPolarCylinder/IGAPolarTorus. HollowCylinder/HollowTorus with a1=0
cannot be used.
- **Why not CircularTokamak for the cylinder:** it is a toroidal field
(B_φ ∝ 1/R, circular flux surfaces around R = R0), and its ψ is not
force-balanced: Δ*ψ ≠ 0 with constant p and g. Adding a pressure p(ψ)
cannot balance it. The force-balanced ScrewPinch is used instead.
- **Discrete-gradient GC pushers near the axis:** they are less accurate
there. They only get the axis map from #761, not the regular ξ update.
- **Marker loss on several processes:** with disc loading, the first
test runs lost markers on several processes. That is fixed in #761 (disc
loading under MPI).

## Checks
- Tiny smoke runs (2 and 4 processes) of all cases complete and write
the results.
- On the cylinder with 4 processes (discrete gradient and explicit), the
radial profile changes by about 1e-6 per step, no marker is lost and
`en_tot` is constant.
- Vlasov: `kinetic_energy` is constant and the v3 distribution is
unchanged to 1e-16.
- The flags of all 19 variants are accepted by the params files.
- Two separate review agents checked the examples. Their findings are
fixed:
  - the decomposition at 128/256 processes;
  - the marker loss under MPI (in #761);
  - the plot of scalars that start at zero;
  - the edge losses in the torus;
  - the exact radial profile;
  - the B0 used for loading.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
spossann added a commit that referenced this pull request Oct 21, 2026
…l, GyrokineticPoisson; cylinder, torus); pproc of polar fields (#759)

## Summary

Profiling examples that show what polar splines can do: Poisson,
CurlCurl and GyrokineticPoisson on a cylinder and on a torus. The
manufactured solution has its **largest gradient on the axis r = 0**.
Each case compares:
- C1 polar splines on IGAPolarCylinder / IGAPolarTorus (no hole);
- tensor-product splines on HollowCylinder / HollowTorus with a 1e-2
hole, the geometry of the existing profiling examples.

Stacked on #758 (polar StiffnessPreconditioner). The base is
`polar-stiffness`, so this diff shows only the new commits.

### Layout
- **One params file per model and geometry:**
`profiling/examples/{Poisson,CurlCurl}/polar_{cylinder,torus}_strong_scaling/params_*.py`.
Command-line flags select the variant:
  - `--splines polar|tensor`
  - `--precond`
  - `--poloidal-solver schur|lu|ilu`
  - `--hole-bc free|dirichlet`
  - `--num-elements`
- **One submit script per model and geometry, with `--variant`:**
`submit_{poisson,curl_curl}_polar_{cylinder,torus}_strong_scaling.py`.
The variant table sets the flags through
`ProfilingCase.launch(param_flags=...)`, and the script is run once per
variant. Several `ProfilingCase`s can't share one process: without
`--upload`, `setup_run` deletes the shared output folder.
- **Variants:**
- Poisson: `tensor_{none,stiffness,multigrid}`,
`polar_{none,tensor_mass,mass,mass_ilu,stiffness,stiffness_ilu}`.
- CurlCurl: `tensor_{none,stiffness,hiptmair_xu,multigrid}`,
`polar_{none,tensor_mass,mass,stiffness,stiffness_lu}`.
- There are no ILU variants for CurlCurl, because ILU is not robust
there at σ = 1e-2 (see #758).
- **Grid:** (64, 66, 64), degree (3, 3, 3), for both spline spaces.
Polar splines need a multiple of 3 elements in η2.
- **Exact solution:** a Cartesian Gaussian (w = 0.2) centred w/√2 off
the axis, where its gradient is largest. The right-hand side is derived
with sympy; for CurlCurl, E = g c. On top of the existing outputs, the
error close to the axis (r < 0.1) and `variant.json` are saved.
- **BC at the hole (tensor-product variants):**
  - Poisson uses the natural BC.
- CurlCurl uses Dirichlet. With the natural BC, the BC at the hole
contradicts the solution, whose curl is largest on the axis. With σ =
1e-2 the error then grows to O(10³).

### Fix: post-processing of polar fields
`Output._load_femfields` wrote the saved coefficients into a
`PolarVector`. That either failed, or the field evaluated to zero,
because `SplineFunction.__call__` re-extracts from the polar vector. Now
the saved tensor-product (extracted) coefficients are loaded into
`vector_stencil`, and the polar vector is recovered with the restart
extraction operator: once per snapshot, cached per space. New test
`test_pproc_polar` (serial and parallel pproc) fails without the fix.

### GyrokineticPoisson
-
`profiling/examples/GyrokineticPoisson/polar_{cylinder,torus}_strong_scaling/params_gyrokinetic_poisson.py`
and their submit scripts, with the same flags.
- **Equilibria**, as in the existing hollow GKP examples:
  - ScrewPinch (a=1, R0=1, Lz=2π), `which_geometry="cylindrical"`;
- AdhocTorus (a=1, R0=3, q_kind=0, p_kind=1),
`which_geometry="toroidal"`.
  - Defaults `stab_mat="M0ad_withT"` and `diffusion_mat="M1gyro"`.
- **Manufactured solution:** the same off-axis Gaussian, times a
modulation along η3, so the average ⟨φ⟩ is **non-zero**.
  - Cylinder: ⟨φ⟩ is exact.
- Torus: ⟨φ⟩(r) = ∫g R dθ / ∫R dθ, tabulated by trapezoidal quadrature
in θ.
- ρ is computed with sympy in Cartesian coordinates. At startup, the
symbolic n0, p and B0 are checked against the struphy equilibrium
(`n_xyz`, `p_xyz`, `b_xyz`).
- Leaving ⟨φ⟩ out of ρ raises the error from 6e-4 to 36%, so the
averaged term is really tested.
- **Variants:** `tensor_{none,stiffness,multigrid}`,
`polar_{none,tensor_mass,mass,mass_ilu,stiffness,stiffness_ilu}`.

Small grid (16 × 18 × 16, serial):

| variant | cylinder: its | torus: its |
|---|---|---|
| polar, stiffness (Schur / ILU) | 7 / 7 | 5 / 5 |
| polar, none | 55 | 76 |
| polar, polar mass | 419 | 311 |
| polar, tensor mass | 738 | 521 |
| tensor-product, none | 63 | 82 |
| tensor-product, stiffness | 82 | 72 |
| tensor-product, multigrid | 264 | 226 |

The error in φ depends only on the spline space: polar 4.9e-4 (cylinder)
and 6.2e-4 (torus), tensor-product 1.5e-2 and 1.4e-2.

Torus at 32 × 33 × 32, 4 ranks, stiffness preconditioner:

| splines | its | solve time | error in φ | peak memory (4 ranks) |
|---|---|---|---|---|
| polar | 4 | 3.1 s | 4.0e-5 | 7.0 GB |
| tensor-product | 109 | 31 s | 2.2e-2 | 5.6 GB |

- **Full-size runs (64 × 66 × 64) are for the cluster.** They did not
fit into 15 GB on a workstation; already at 32³ the tensor-product run
takes 5.6 GB.
- **The polar stiffness preconditioner is not exact for GKP** (4–7
iterations rather than 2), because it drops the C^23 (η2–η3) coupling of
M1gyro, see #758.
- **The tensor-product preconditioners are worse than none** on the
hollow cylinder with the free hole BC: stiffness 82, multigrid 264, none
63.

### Compacted layout of the existing cube/hollow examples
- The existing Poisson, CurlCurl and GyrokineticPoisson cube/hollow
strong scaling cases now use the same layout as the polar ones:
- one params file per model and geometry
(`{geo}_strong_scaling/params_*.py`, flags `--precond` and
`--num-elements`);
- one submit script per model and geometry (`--variant`, default
`none`).
- Removed: 19 per-variant params folders and 19 submit scripts (about
−6300 lines).
- **The labels of the former variants are unchanged** (e.g.
`poisson_hollow_cylinder_strong_scaling_multigrid`), so the result
histories in the profiling-data repo continue. Names and descriptions
are also byte-identical.
- The flags of a run are recorded in the packaged job script
(`slurm_script_str`).
- **Verified:**
- All 27 former variants (at 16³) give identical CG iterations and
errors with the old and the new files.
- A review compared each variant's effective parameters, names,
descriptions and labels against the old files. The explicit
`multigrid=MultiGridOptions()` was removed because it equals the
propagator default.

## Results

Small grid (16 × 18 × 16, serial; Poisson cylinder 16 × 18 × 8),
Stiffness preconditioner:

| case | polar: iterations, error | tensor-product (hole): iterations,
error |
|---|---|---|
| Poisson cylinder | 2, 8.8e-4 | 81 (no pc), 1.35e-2 |
| Poisson torus | 2, 5.4e-4 | 89 (no pc), 1.36e-2 |
| CurlCurl cylinder | 6, 1.4e-3 | 65, 1.24 |
| CurlCurl torus | 8, 1.3e-3 | 118, 24.6 |

Full size, Poisson cylinder, 64 × 66 × 64, 4 ranks, Stiffness
preconditioner:

| splines | iterations | solve time | max error in φ |
|---|---|---|---|
| polar | 2 | 4.0 s | 2.1e-6 |
| tensor-product | 192 | 158 s | 2.9e-2 |

The tensor-product error does not converge under refinement because of
the hole.

Notes for interpreting the runs:
- At full size, the unpreconditioned CurlCurl variants (and probably
`polar_tensor_mass`) will hit `maxiter = 10000`. Their timings then
measure 10000 iterations, not a solve.
- The mass-matrix preconditioners are worse than none for Poisson (they
approximate M0, not the Laplacian). They are kept as reference variants.
- MultiGrid on the hollow torus with the free hole BC is worse than no
preconditioner for Poisson. This is not a regression of this PR.

## Tests
- `test_pproc_polar` (new): serial and 2 ranks.
- `post_processing/tests/test_output.py` and `test_pproc.py` (including
`--with-mpi` on 2 ranks) pass.
- All six params files run in serial and on 2 ranks. All 17 GKP variants
run at small size. The reviewer ran all 37 variants at small size: all
exit 0.
- Two separate review passes (Poisson/CurlCurl, and GKP with an
independent finite-difference check of the operator) found no bugs;
their suggestions are applied.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
spossann added a commit that referenced this pull request Oct 21, 2026
…ines in particle propagators) (#761)

Stacked on #759 (`polar-profiling`). It retargets to `devel` once
#758/#759 are merged.

## Summary

Particle markers can now run on domains with a **pole at η1 = 0** (no
hole around the axis), with polar splines for the FEEC parts. Examples
are `IGAPolarCylinder` and `IGAPolarTorus` (HollowCylinder/HollowTorus
with `a1=0` also work in kernels). Before this PR, `Vlasov` and
`GuidingCenter` failed on such domains: several propagators did not
handle `PolarVector`, no marker boundary condition handled the axis, and
the kernels divided by det DF = 0.

This prepares the Vlasov/GuidingCenter profiling examples (next PR,
stacked on this one).

### Marker push through the axis
- **New `update_position_stage`**
(`pic/pushing/pusher_utilities_kernels.py`): the stage update of the
explicit Runge–Kutta pushers (PushEta, explicit GC bxE\* and B\*) is
done in ξ = η1·(cos 2πη2, sin 2πη2), keeping η3.
- In these coordinates the mapping and the marker velocity dξ/dt =
(∂ξ/∂η)·dη/dt are regular at the pole, and markers move through the
axis.
- Straight lines through the axis of a cylinder are exact (tested to
1e-10).
  - Without a pole the update is unchanged.
- **Axis map** in `apply_kinetic_bc_marker` (Pyccel and CUDA) and
`Particles.apply_kinetic_bc`:
- A marker with η1 < 0 is mapped to (−η1, η2 + ½), with unchanged
velocities.
- η1 is kept ≥ 1e-10, also after loading (Sobol loading starts at η =
0).
  - The boundary condition of axis 0 then applies at η1 = 1 only.
- **New field `MarkerArguments.pole`**, also in `CudaMarkerArguments`
and `pusher_args.cuh`.
- **Not supported yet:** pole domains on the CuPy backend, and
`poc`/`pol_period` ≠ 1. Both raise `NotImplementedError`.

### Polar splines in particle propagators
- **PushVxB:** the projected equilibrium `b2` (already tensor-product)
was extracted a second time, which failed with polar splines. Only the
`b2` variable is extracted now.
- **PushGuidingCenterBxEstar/Parallel:** φ, the electric field and b̃
are passed to the kernels as tensor-product coefficients (`PolarVector`
has no `_data`). The gradients use `derham.grad`, boundary operators
included, followed by E^T. Without polar splines nothing changes.

### Other fixes found along the way
- **Reflecting boundary:** only Cartesian velocities (vdim = 3) are
reflected. For Particles5D the columns 3:6 (v∥, μ, **weight**) were
overwritten.
- **`CanonicalMaxwellian2D`:** removed an unused read of
`domain.params["a1"]`, which raised a KeyError on IGAPolarTorus.
- **`AdhocTorus`, `AdhocTorusQPsi`:** the ψ derivatives were 0/0 (NaN)
on the magnetic axis. Off the axis they are unchanged (4e-16); on the
axis they take the correct limits.
- Note: AdhocTorusQPsi's interpolated ψ has ψ′(0) ≈ −6e-5 ≠ 0, so its
field is not exactly regular on the axis. That is pre-existing and not
changed here.
- **`GuidingCenter`:** `en_fB` summed over holes and ghosts instead of
the valid markers. Each removed marker added +1 to `en_tot`.

### Disc loading under MPI (pre-existing bug, not pole-related)
With `spatial="disc"` on more than one process, markers were lost in the
first push or the run hung:
- **Disc transform after the selection:** `pseudo_random` loading
selected the uniform draws on each process's domain and applied η1 → √η1
afterwards. That moved many markers to another process's domain.
- Without sorting boxes they were not sent there, so the first push
evaluated the fields outside the local domain.
- Example: GuidingCenter on 4 processes removed 15% of the markers in
the first step.
  - The transform is now applied before the domain check.
- **Sort after loading:** markers are sent to their process even without
sorting boxes (not for restart loading). Sobol loading draws on the
whole domain on each process, so it needed this too.
- **Marker array size:** the number of markers per process was estimated
with the uniform measure. With the disc measure 2η1, processes at larger
η1 overflowed the marker array: a ValueError on some processes and a
hang on the others.
- **New test:** `test_draw_disc_on_own_process` (pseudo_random and
sobol_standard) in `test_draw_parallel.py`.

### Known limitations
- **Discrete-gradient GC pushers:** they only get the axis map. Their
mid-points in η are not regular near the axis.
- In the test (ε = 1), relative energy errors near the axis reach 1e-3
to 5e-2.
- With the explicit RK4 pushers, markers that cross the axis conserve
their energy to about 1e-6.
- **Buffer size:** with few markers per process, the discrete-gradient
pushers need a larger `bufsize` on 4 ranks (load-imbalance abort in the
box sort). This is also the case on a torus with a hole, so it is not
pole-related.

## Tests
New file `src/struphy/pic/tests/test_pole.py`, which passes serially and
on 4 MPI ranks:
- the axis map in the kernel and in Python, including a marker with η1 >
1;
- reflection for vdim = 2 and 3;
- PushEta straight lines through the axis (forward Euler, Heun2, RK4)
against the exact Cartesian end points;
- Vlasov on IGAPolarCylinder with ScrewPinch and polar splines: kinetic
energy conserved to 1e-12;
- GuidingCenter on IGAPolarTorus with AdhocTorus and polar splines,
explicit and discrete-gradient pushers: energies per marker, compared by
ID, including markers that cross the axis.

Also run locally:
- `test_pushers.py`, `test_reflect.py`, `test_kernel_setup.py`,
`test_kernel_backends.py`, `test_accum_gc_gradB.py`,
`test_maxwellians.py` and `fields_background/tests` (serial);
- the default-params runs of Vlasov and GuidingCenter.

A separate review agent checked the branch before the push. Its findings
are fixed in the second commit:
- the Python axis map caught η1 > 1;
- the clamp covered only one loading branch;
- the GC gradients had dropped the boundary operators.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


Follow-up (stacked): #762 adds the Vlasov/GuidingCenter profiling
examples that use this.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
spossann added a commit that referenced this pull request Oct 21, 2026
…ingCenter (cylinder/torus without hole) (#762)

Stacked on #761 (`particle-pole`, which is stacked on #759). It
retargets once those are merged.

## Summary
New profiling examples for the particle-tracing models **Vlasov** and
**GuidingCenter**. They are long-time equilibrium tests with binning
plots in position and velocity space, and the propagator algorithms are
compared through variants.

The layout is the same as for the existing cases (#759):
- one params file plus one submit script per model and geometry;
- the variant is chosen with `--variant`;
- rank counts 1, 2, 4, …, 256;
- post-processing writes `.png`/`.npy` files to `sim_<id>/results`.

### Cases
- **`Vlasov/cuboid_strong_scaling`**
- Setup: Maxwellian3D with constant moments (n=1, u=0, vth=1) in a
periodic cube (Cuboid, side 2π) with a uniform B along z (HomogenSlab).
This is an exact equilibrium.
- Binning: η1, (η1, η2), v1, v3 and (v1, v2), compared with the initial
and the exact profiles.
- Variants: push_vxb analytic/implicit; push_eta forward
Euler/Heun2/Heun3/RK4; Strang splitting; Sobol loading.
- **`GuidingCenter/polar_cylinder_strong_scaling`**
- Geometry: straight cylinder **without hole** (IGAPolarCylinder, C1
polar splines) with ScrewPinch (Lz = 2πR0, pressure offset p0 so that
vth > 0 at the edge).
- Initial condition: GyroMaxwellian2D with n(r) and vth(r) = √(p/n) from
the equilibrium. It is an exact equilibrium, because the GC drifts keep
r constant. Markers move through the axis.
- Binning: η1, (η1, η2), v∥, μ and (η1, v∥). The radial profile is also
compared with the exact one, n/|B0|.
- **`GuidingCenter/polar_torus_strong_scaling`**
- Geometry: full torus **without hole** (IGAPolarTorus, polar splines)
with AdhocTorus.
- Initial condition: CanonicalMaxwellian2D with n and vth as functions
of ψc (an exact equilibrium). The density vanishes at the edge, where
markers on orbits that leave the plasma are removed.
- Control: a GyroMaxwellian2D with the same profiles in the flux label
of the position. It is not an equilibrium in a torus.
- **GuidingCenter variants:** discrete gradient 1st order (fixed point,
Newton), 2nd order, explicit RK4 and Heun3. The torus adds
GyroMaxwellian controls.
- Default grid: 16×48×48, decomposed only in η2/η3, so 1–256 processes
work. ppc 25, dt 0.2, T = 100.
- **`profiling/examples/particle_diagnostics.py`:** shared
post-processing.
- Binning plots at the first and last time (2D slices also as difference
maps).
  - Relative L2 deviation from t=0 and from the exact profile over time.
- Relative drift of `kinetic_energy`/`en_tot`; scalars that start at 0,
such as lost markers, are plotted as absolute values.

### Notes
- **No hole at r = 0:** a Simulation needs polar splines whenever the
domain has a pole, and polar splines need a spline mapping. So the cases
use IGAPolarCylinder/IGAPolarTorus. HollowCylinder/HollowTorus with a1=0
cannot be used.
- **Why not CircularTokamak for the cylinder:** it is a toroidal field
(B_φ ∝ 1/R, circular flux surfaces around R = R0), and its ψ is not
force-balanced: Δ*ψ ≠ 0 with constant p and g. Adding a pressure p(ψ)
cannot balance it. The force-balanced ScrewPinch is used instead.
- **Discrete-gradient GC pushers near the axis:** they are less accurate
there. They only get the axis map from #761, not the regular ξ update.
- **Marker loss on several processes:** with disc loading, the first
test runs lost markers on several processes. That is fixed in #761 (disc
loading under MPI).

## Checks
- Tiny smoke runs (2 and 4 processes) of all cases complete and write
the results.
- On the cylinder with 4 processes (discrete gradient and explicit), the
radial profile changes by about 1e-6 per step, no marker is lost and
`en_tot` is constant.
- Vlasov: `kinetic_energy` is constant and the v3 distribution is
unchanged to 1e-16.
- The flags of all 19 variants are accepted by the params files.
- Two separate review agents checked the examples. Their findings are
fixed:
  - the decomposition at 128/256 processes;
  - the marker loss under MPI (in #761);
  - the plot of scalars that start at zero;
  - the edge losses in the torus;
  - the exact radial profile;
  - the B0 used for loading.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants