From 007ebce5022493f6b25130101c39995a24c7458e Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 08:13:40 +0200 Subject: [PATCH 01/23] Document the discretization of MaxwellWeakAmpere and VlasovAmpereCoupling Semi-discrete systems in struphy operator notation (M1, M2, C, Lambda^1, W), Crank-Nicolson update, SchurSolver blocks A, B, C, resulting Schur system, accumulated matrix/vector and conserved quantities. Co-Authored-By: Claude Opus 5.5 --- .../propagators/maxwell_weak_ampere.py | 45 ++++++++++++++-- .../propagators/vlasov_ampere_coupling.py | 53 ++++++++++++++++++- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/struphy/propagators/maxwell_weak_ampere.py b/src/struphy/propagators/maxwell_weak_ampere.py index 9c5d45486..777753235 100644 --- a/src/struphy/propagators/maxwell_weak_ampere.py +++ b/src/struphy/propagators/maxwell_weak_ampere.py @@ -30,12 +30,49 @@ class MaxwellWeakAmpere(Propagator): \\[2mm] \frac{\partial \mathbf B}{\partial t} + \nabla\times\mathbf E &= 0\,. - Time discretization: + Ampère's law holds weakly, Faraday's law strongly. With the FE coefficients + :math:`\mathbf e \in \mathbb R^{N_1}` of :math:`\mathbf E \in V^1_h` and :math:`\mathbf b \in \mathbb R^{N_2}` + of :math:`\mathbf B \in V^2_h`, the semi-discrete system reads - - implicit: Crank-Nicolson (implicit mid-point) - - explicit: explicit RK methods from ButcherTableau + .. math:: + + \mathbb M_1 \dot{\mathbf e} = \mathbb C^\top \mathbb M_2 \mathbf b\,, \qquad + \dot{\mathbf b} = - \mathbb C \mathbf e\,, + + with the mass matrices :math:`\mathbb M_1`, :math:`\mathbb M_2` + (:class:`~struphy.feec.mass.WeightedMassOperators`) and the discrete curl :math:`\mathbb C` + (``derham.curl``). It conserves the energy + :math:`\frac 12 \mathbf e^\top \mathbb M_1 \mathbf e + \frac 12 \mathbf b^\top \mathbb M_2 \mathbf b` + and the discrete divergence :math:`\mathbb D \mathbf b` (since :math:`\mathbb D \mathbb C = 0`). + + :ref:`time_discret`: + + - implicit: Crank-Nicolson (implicit mid-point), + + .. math:: + + \mathbb M_1 \frac{\mathbf e^{n+1} - \mathbf e^n}{\Delta t} = \mathbb C^\top \mathbb M_2 \frac{\mathbf b^{n+1} + \mathbf b^n}{2}\,, + \qquad + \frac{\mathbf b^{n+1} - \mathbf b^n}{\Delta t} = - \mathbb C \frac{\mathbf e^{n+1} + \mathbf e^n}{2}\,, + + which conserves both invariants exactly (up to the solver tolerance). The magnetic field is eliminated by + :class:`~struphy.linear_algebra.schur_solver.SchurSolver` with :math:`x = \mathbf e`, :math:`y = \mathbf b`, + :math:`A = \mathbb M_1`, :math:`B = -\frac 12 \mathbb C^\top \mathbb M_2` and :math:`C = \frac 12 \mathbb C`, + which gives the curl-curl system + + .. math:: + + \left(\mathbb M_1 + \frac{\Delta t^2}{4} \mathbb C^\top \mathbb M_2 \mathbb C\right) \mathbf e^{n+1} + = \left(\mathbb M_1 - \frac{\Delta t^2}{4} \mathbb C^\top \mathbb M_2 \mathbb C\right) \mathbf e^n + + \Delta t \, \mathbb C^\top \mathbb M_2 \mathbf b^n\,, + + solved with ``solver`` and ``precond`` (built from :math:`\mathbb M_1`). Then + :math:`\mathbf b^{n+1} = \mathbf b^n - \frac{\Delta t}{2} \mathbb C (\mathbf e^{n+1} + \mathbf e^n)`. - System size reduction via :class:`~struphy.linear_algebra.schur_solver.SchurSolver`. + - explicit: Runge-Kutta method from :class:`~struphy.ode.utils.ButcherTableau` for the system + :math:`\dot{\mathbf e} = \mathbb M_1^{-1} \mathbb C^\top \mathbb M_2 \mathbf b`, + :math:`\dot{\mathbf b} = - \mathbb C \mathbf e`, where :math:`\mathbb M_1^{-1}` is applied with ``solver`` and + ``precond`` in each stage. The time step is limited by the CFL condition of light waves. """ class Variables: diff --git a/src/struphy/propagators/vlasov_ampere_coupling.py b/src/struphy/propagators/vlasov_ampere_coupling.py index 02bf5ed52..00be6cc72 100644 --- a/src/struphy/propagators/vlasov_ampere_coupling.py +++ b/src/struphy/propagators/vlasov_ampere_coupling.py @@ -31,9 +31,58 @@ class VlasovAmpereCoupling(Propagator): \\[2mm] \frac{\partial f}{\partial t} + \frac{1}{\varepsilon}\, \mathbf{E} \cdot \frac{\partial f}{\partial \mathbf{v}} &= 0 . - Time discretization: Crank-Nicolson (implicit mid-point). + With :math:`f` approximated by :math:`N` markers with positions :math:`\boldsymbol \eta_p` (logical, fixed in this + step), velocities :math:`\mathbf v_p` and weights :math:`w_p` (including the factor :math:`1/N`), and the FE + coefficients :math:`\mathbf e \in \mathbb R^{N_1}` of :math:`\mathbf E \in V^1_h`, the semi-discrete system reads - System size reduction via :class:`~struphy.linear_algebra.schur_solver.SchurSolver`. + .. math:: + + \mathbb M_1 \dot{\mathbf e} = - \frac{\alpha^2}{\varepsilon} \, (\mathbf \Lambda^1)^\top \, \overline{DF^{-1}} \, \mathbb W \, \mathbf V \,, + \qquad + \dot{\mathbf V} = \frac{1}{\varepsilon} \, \overline{DF^{-\top}} \, \mathbf \Lambda^1 \, \mathbf e\,, + + where :math:`\mathbf V = (\mathbf v_p)_p \in \mathbb R^{3N}`, :math:`\mathbb W = \textnormal{diag}(w_p)` + (acting on each velocity component), :math:`\mathbf \Lambda^1 \in \mathbb R^{3N \times N_1}` holds the + :math:`V^1_h` basis functions evaluated at the markers, :math:`(\mathbf \Lambda^1 \mathbf e)_p = \hat{\mathbf E}^1(\boldsymbol \eta_p)`, + and :math:`\overline{DF^{-1}}` is block-diagonal with the inverse Jacobian matrices :math:`DF^{-1}(\boldsymbol \eta_p)`. + It conserves the energy + :math:`\frac 12 \mathbf e^\top \mathbb M_1 \mathbf e + \frac{\alpha^2}{2} \mathbf V^\top \mathbb W \mathbf V`. + + :ref:`time_discret`: Crank-Nicolson (implicit mid-point), + + .. math:: + + \mathbb M_1 \frac{\mathbf e^{n+1} - \mathbf e^n}{\Delta t} = - \frac{\alpha^2}{\varepsilon} \, (\mathbf \Lambda^1)^\top \, \overline{DF^{-1}} \, \mathbb W \, \frac{\mathbf V^{n+1} + \mathbf V^n}{2}\,, + \qquad + \frac{\mathbf V^{n+1} - \mathbf V^n}{\Delta t} = \frac{1}{\varepsilon} \, \overline{DF^{-\top}} \, \mathbf \Lambda^1 \, \frac{\mathbf e^{n+1} + \mathbf e^n}{2}\,, + + which conserves the energy exactly (up to the solver tolerance; for constant weights, i.e. without control + variate). The velocities are eliminated by :class:`~struphy.linear_algebra.schur_solver.SchurSolver` with + :math:`x = \mathbf e`, :math:`y = \mathbf V`, :math:`A = \mathbb M_1`, + :math:`B = \frac{\alpha^2}{2 \varepsilon} (\mathbf \Lambda^1)^\top \overline{DF^{-1}} \mathbb W` and + :math:`C = - \frac{1}{2 \varepsilon} \overline{DF^{-\top}} \mathbf \Lambda^1`, which gives + + .. math:: + + \left(\mathbb M_1 + \frac{\Delta t^2 \alpha^2}{4 \varepsilon^2} \mathbb A\right) \mathbf e^{n+1} + = \left(\mathbb M_1 - \frac{\Delta t^2 \alpha^2}{4 \varepsilon^2} \mathbb A\right) \mathbf e^n + - \Delta t \frac{\alpha^2}{\varepsilon} \, \mathbf j\,, + + with the accumulated matrix and vector (:class:`~struphy.pic.accumulation.particles_to_grid.Accumulator`, + kernel :func:`~struphy.pic.accumulation.kernels.vlasov_maxwell.vlasov_maxwell`) + + .. math:: + + \mathbb A = (\mathbf \Lambda^1)^\top \, \overline{DF^{-1}} \, \mathbb W \, \overline{DF^{-\top}} \, \mathbf \Lambda^1 + = \sum_p w_p \, \mathbf \Lambda^1_p{}^\top G^{-1}(\boldsymbol \eta_p) \, \mathbf \Lambda^1_p\,, + \qquad + \mathbf j = (\mathbf \Lambda^1)^\top \, \overline{DF^{-1}} \, \mathbb W \, \mathbf V^n\,. + + :math:`\mathbb A` is a particle approximation of the :math:`n`-weighted mass matrix :math:`\mathbb M_1`, + so the Schur operator is a mass matrix with weight :math:`1 + \Delta t^2 \alpha^2 n / (4 \varepsilon^2)`, + solved with ``solver`` and ``precond`` (built from :math:`\mathbb M_1`). The velocities are then updated by + :func:`~struphy.pic.pushing.kernels.push_v_with_efield.push_v_with_efield` with the mid-point field + :math:`\frac 12 (\mathbf e^{n+1} + \mathbf e^n)`. """ class Variables: From 693a4b4c842476ec095a81f0d28480089814071c Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 08:39:43 +0200 Subject: [PATCH 02/23] MaxwellWeakAmpere: StiffnessPreconditioner as default precond [preview docs] The Schur operator M1 + dt^2/4 C^T M2 C is preconditioned by StiffnessPreconditioner(curl, mid=dt^2/4 M2, stab=M1), built per time step in prepare_time_step. Iterations hardly depend on dt (hollow cylinder 16x16x4, p=3: 39 instead of 411 PCG iterations at dt = 100 h); for dt <~ h the mass preconditioner stays slightly better. algo="explicit" falls back to the mass preconditioner, solver="cg" drops the preconditioner with a warning. New test file test_maxwell_weak_ampere.py. Co-Authored-By: Claude Opus 5.5 --- src/struphy/io/options.py | 3 + .../propagators/maxwell_weak_ampere.py | 61 ++++++-- .../tests/test_maxwell_weak_ampere.py | 140 ++++++++++++++++++ 3 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 src/struphy/propagators/tests/test_maxwell_weak_ampere.py diff --git a/src/struphy/io/options.py b/src/struphy/io/options.py index fbde739ce..1c3c19948 100644 --- a/src/struphy/io/options.py +++ b/src/struphy/io/options.py @@ -80,6 +80,9 @@ class LiteralOptions: OptsShearAlfvenPrecond = Literal[ "MassMatrixPreconditioner", "PolarMassMatrixPreconditioner", "MultiGrid", "StiffnessPreconditioner", None ] + OptsMaxwellPrecond = Literal[ + "StiffnessPreconditioner", "MassMatrixPreconditioner", "PolarMassMatrixPreconditioner", None + ] OptsShearAlfvenCurlCurlPrecond = Literal[ "HiptmairXu", "MassMatrixPreconditioner", "PolarMassMatrixPreconditioner", None ] diff --git a/src/struphy/propagators/maxwell_weak_ampere.py b/src/struphy/propagators/maxwell_weak_ampere.py index 777753235..36e1bd237 100644 --- a/src/struphy/propagators/maxwell_weak_ampere.py +++ b/src/struphy/propagators/maxwell_weak_ampere.py @@ -66,13 +66,21 @@ class MaxwellWeakAmpere(Propagator): = \left(\mathbb M_1 - \frac{\Delta t^2}{4} \mathbb C^\top \mathbb M_2 \mathbb C\right) \mathbf e^n + \Delta t \, \mathbb C^\top \mathbb M_2 \mathbf b^n\,, - solved with ``solver`` and ``precond`` (built from :math:`\mathbb M_1`). Then + solved with ``solver`` and ``precond``. By default (``precond="StiffnessPreconditioner"``) the Schur operator + is preconditioned by :class:`~struphy.feec.preconditioner.StiffnessPreconditioner` for + :math:`\mathbb C^\top \left(\frac{\Delta t^2}{4} \mathbb M_2\right) \mathbb C + \mathbb M_1`, built for the + current time step (:meth:`prepare_time_step`). Its number of iterations hardly depends on :math:`\Delta t`, + whereas with the mass matrix preconditioner of :math:`\mathbb M_1` it grows with + :math:`\Delta t / h` (16x16x4 cells, p=3, hollow cylinder, :math:`\Delta t = 10 h`: 35 instead of 257 PCG + iterations, :math:`\Delta t = 100 h`: 39 instead of 411). For :math:`\Delta t \lesssim h` the mass matrix + preconditioner needs fewer iterations. Then :math:`\mathbf b^{n+1} = \mathbf b^n - \frac{\Delta t}{2} \mathbb C (\mathbf e^{n+1} + \mathbf e^n)`. - explicit: Runge-Kutta method from :class:`~struphy.ode.utils.ButcherTableau` for the system :math:`\dot{\mathbf e} = \mathbb M_1^{-1} \mathbb C^\top \mathbb M_2 \mathbf b`, :math:`\dot{\mathbf b} = - \mathbb C \mathbf e`, where :math:`\mathbb M_1^{-1}` is applied with ``solver`` and - ``precond`` in each stage. The time step is limited by the CFL condition of light waves. + ``precond`` in each stage (``"StiffnessPreconditioner"`` is replaced by the mass matrix preconditioner of + :math:`\mathbb M_1` here). The time step is limited by the CFL condition of light waves. """ class Variables: @@ -115,7 +123,7 @@ def __init__(self): @dataclass(repr=False) class Options(OptionsBase): - """Configuration options for :class:`MaxwellWeakAmpere`. + r"""Configuration options for :class:`MaxwellWeakAmpere`. Parameters ---------- @@ -129,9 +137,21 @@ class Options(OptionsBase): Name of the symmetric iterative solver passed to :func:`psydac.linalg.solvers.inverse`. - precond : LiteralOptions.OptsMassPrecond, default="MassMatrixPreconditioner" - Name of the preconditioner configuration. - Currently used only for implicit time stepping. + precond : LiteralOptions.OptsMaxwellPrecond, default="StiffnessPreconditioner" + Preconditioner of the linear solves. + + - ``"StiffnessPreconditioner"``: Kronecker (fast diagonalization) approximation of the inverse of the + Schur operator :math:`\mathbb M_1 + \frac{\Delta t^2}{4} \mathbb C^\top \mathbb M_2 \mathbb C` + (:class:`~struphy.feec.preconditioner.StiffnessPreconditioner` with ``derivative="curl"``; with polar + splines a :class:`~struphy.feec.preconditioner.PolarStiffnessPreconditioner`), rebuilt when the time + step changes. For ``algo="explicit"``, the mass matrix preconditioner of :math:`\mathbb M_1` is used + instead. (With polar splines, the implicit step currently fails in the + :class:`~struphy.linear_algebra.schur_solver.SchurSolver` with any preconditioner.) + - ``"MassMatrixPreconditioner"``, ``"PolarMassMatrixPreconditioner"``: preconditioner of + :math:`\mathbb M_1`, better for small time steps :math:`\Delta t \lesssim h`, e.g. in CFL-limited + PIC runs. + - ``None``: no preconditioner (set automatically, with a warning, for ``solver="cg"``, which takes no + preconditioner). solver_params : SolverParameters, default=None Iterative-solver controls (for example ``tol``, ``maxiter``, @@ -155,7 +175,7 @@ class Options(OptionsBase): # propagator options algo: OptsAlgo = "implicit" solver: LiteralOptions.OptsSymmSolver = "pcg" - precond: LiteralOptions.OptsMassPrecond = "MassMatrixPreconditioner" + precond: LiteralOptions.OptsMaxwellPrecond = "StiffnessPreconditioner" solver_params: SolverParameters = None butcher: ButcherTableau = None @@ -163,7 +183,10 @@ def __post_init__(self): # checks check_option(self.algo, self.OptsAlgo) check_option(self.solver, LiteralOptions.OptsSymmSolver) - check_option(self.precond, LiteralOptions.OptsMassPrecond) + check_option(self.precond, LiteralOptions.OptsMaxwellPrecond) + if self.solver == "cg" and self.precond is not None: + logger.warning(f'solver="cg" takes no preconditioner; ignoring precond="{self.precond}".') + self.precond = None # defaults if self.solver_params is None: @@ -191,11 +214,15 @@ def allocate(self): M2 = self.mass_ops.M2 curl = self.derham.curl - # Preconditioner for M1 + ... - if self.options.precond is None: + # Preconditioner (the one of the Schur operator depends on dt and is built in prepare_time_step) + self._precond = self.options.precond + self._schur_precond_dt = None + if self._precond == "StiffnessPreconditioner" and self.options.algo == "explicit": + self._precond = "PolarMassMatrixPreconditioner" if self.derham.polar_splines else "MassMatrixPreconditioner" + if self._precond in (None, "StiffnessPreconditioner"): pc = None else: - pc_class = getattr(preconditioner, self.options.precond) + pc_class = getattr(preconditioner, self._precond) pc = pc_class(M1) if self.options.algo == "implicit": @@ -257,6 +284,15 @@ def f2(t, y1, y2, out: BlockVector = out2): self._e_tmp2 = self.variables.e.spline.vector.space.zeros() self._b_tmp1 = self.variables.b.spline.vector.space.zeros() + def prepare_time_step(self, dt: float): + """Stiffness preconditioner of the Schur operator M1 + dt^2/4 C^T M2 C for the time step ``dt``: built in the + setup, rebuilt when dt changes.""" + if self._precond == "StiffnessPreconditioner" and dt != self._schur_precond_dt: + self._schur_solver.precond = preconditioner.StiffnessPreconditioner( + self.mass_ops, "curl", sigma=1.0, mid=(dt**2 / 4) * self.mass_ops.M2, stab="M1" + ) + self._schur_precond_dt = dt + @profile def __call__(self, dt): # current FE coeffs @@ -264,6 +300,9 @@ def __call__(self, dt): bn = self.variables.b.spline.vector if self.options.algo == "implicit": + # preconditioner for the current time step (no-op unless dt changed since the setup) + self.prepare_time_step(dt) + # solve for new e coeffs self._B.dot(bn, out=self._byn) diff --git a/src/struphy/propagators/tests/test_maxwell_weak_ampere.py b/src/struphy/propagators/tests/test_maxwell_weak_ampere.py new file mode 100644 index 000000000..4b73b95b7 --- /dev/null +++ b/src/struphy/propagators/tests/test_maxwell_weak_ampere.py @@ -0,0 +1,140 @@ +import cunumpy as xp +import pytest +from maybempi import MPI + +from struphy import domains +from struphy.feec.mass import WeightedMassOperators +from struphy.feec.psydac_derham import Derham +from struphy.io.options import DerhamOptions +from struphy.linear_algebra.solver import SolverParameters +from struphy.models.variables import FEECVariable +from struphy.propagators.base import Propagator +from struphy.propagators.maxwell_weak_ampere import MaxwellWeakAmpere +from struphy.topology.grids import TensorProductGrid + +comm = MPI.COMM_WORLD + + +def _setup(mapping): + polar = mapping == "IGAPolarCylinder" + if mapping == "Cuboid": + dom = domains.Cuboid() + elif mapping == "HollowCylinder": + dom = domains.HollowCylinder(a1=0.1, a2=1.0, Lz=3.0) + else: + dom = domains.IGAPolarCylinder(num_elements=(12, 12), degree=(2, 2), a=1.0, Lz=3.0) + derham = Derham( + grid=TensorProductGrid(num_elements=(12, 12, 4)), + options=DerhamOptions( + degree=(2, 2, 1), + bcs=(("free", "dirichlet") if polar else ("dirichlet", "dirichlet"), None, None), + polar_splines=polar, + ), + comm=comm, + domain=dom, + ) + Propagator.derham = derham + Propagator.domain = dom + Propagator.mass_ops = WeightedMassOperators(derham=derham, domain=dom) + return derham, dom + + +def _propagator(derham, dom, seed=1, **options): + e = FEECVariable(space="Hcurl") + e.allocate(derham=derham, domain=dom) + b = FEECVariable(space="Hdiv") + b.allocate(derham=derham, domain=dom) + # smooth logical components (projected, so that they are also in the polar spaces) + funcs = [ + lambda e1, e2, e3, k=k: ( + xp.sin(xp.pi * e1) * xp.cos(2 * xp.pi * (e2 + k * seed / 7)) * xp.sin(2 * xp.pi * e3 + k) + ) + for k in range(3) + ] + e.spline.vector = derham.boundary_ops["1"].dot(derham.P1(funcs)) + b.spline.vector = derham.boundary_ops["2"].dot(derham.P2(funcs[::-1])) + prop = MaxwellWeakAmpere() + prop.variables.e = e + prop.variables.b = b + prop.options = prop.Options(**options) + prop.allocate() + return prop + + +def _energy(prop): + e = prop.variables.e.spline.vector + b = prop.variables.b.spline.vector + return 0.5 * e.inner(prop.mass_ops.M1.dot(e)) + 0.5 * b.inner(prop.mass_ops.M2.dot(b)) + + +@pytest.mark.parametrize("mapping", ["Cuboid", "HollowCylinder"]) +def test_stiffness_preconditioner(mapping): + """Implicit step with the stiffness and the mass matrix preconditioner: same solution, exact energy conservation, + and fewer iterations with the stiffness preconditioner for dt >> h.""" + derham, dom = _setup(mapping) + dt = 1.0 + solver_params = SolverParameters(tol=1e-12, maxiter=3000) + + niter = {} + solution = {} + mass_pc = "PolarMassMatrixPreconditioner" if derham.polar_splines else "MassMatrixPreconditioner" + for precond in (mass_pc, "StiffnessPreconditioner"): + prop = _propagator(derham, dom, precond=precond, solver_params=solver_params) + prop.prepare_time_step(dt) + e = prop.variables.e.spline.vector + b = prop.variables.b.spline.vector + _, info = prop._schur_solver(e, prop._B.dot(b), dt) + assert info["success"] + niter[precond] = info["niter"] + + energy_0 = _energy(prop) + prop(dt) + assert abs(_energy(prop) - energy_0) < 1e-8 * energy_0 + solution[precond] = prop.variables.e.spline.vector.copy() + + diff = solution["StiffnessPreconditioner"] - solution[mass_pc] + assert diff.inner(diff) ** 0.5 < 1e-8 * solution[mass_pc].inner(solution[mass_pc]) ** 0.5 + assert niter["StiffnessPreconditioner"] < niter[mass_pc] + + +def test_stiffness_preconditioner_dt_change(): + """The stiffness preconditioner is rebuilt only when dt changes.""" + derham, dom = _setup("Cuboid") + prop = _propagator(derham, dom) + prop(0.1) + pc = prop._schur_solver.precond + prop(0.1) + assert prop._schur_solver.precond is pc + prop(0.2) + assert prop._schur_solver.precond is not pc + assert prop._schur_precond_dt == 0.2 + + +def test_polar_stiffness_preconditioner(): + """With polar splines, the default preconditioner is a PolarStiffnessPreconditioner. (No solve: the implicit step + with polar splines currently fails in SchurSolver with any preconditioner.)""" + from struphy.feec.preconditioner import PolarStiffnessPreconditioner + + derham, dom = _setup("IGAPolarCylinder") + prop = _propagator(derham, dom) + prop.prepare_time_step(1.0) + assert isinstance(prop._schur_solver.precond, PolarStiffnessPreconditioner) + + +def test_explicit_default_precond(): + """For algo="explicit", the default stiffness preconditioner falls back to the mass matrix preconditioner.""" + derham, dom = _setup("Cuboid") + prop = _propagator(derham, dom, algo="explicit") + assert prop._precond == "MassMatrixPreconditioner" + energy_0 = _energy(prop) + prop(0.01) + assert abs(_energy(prop) - energy_0) < 1e-3 * energy_0 + + +@pytest.mark.mpi_skip +def test_precond_options(): + """Invalid preconditioners are rejected; solver="cg" takes no preconditioner.""" + with pytest.raises(AssertionError): + MaxwellWeakAmpere.Options(precond="HiptmairXu") + assert MaxwellWeakAmpere.Options().precond == "StiffnessPreconditioner" + assert MaxwellWeakAmpere.Options(solver="cg").precond is None From f98a69526f4656c645bf108f191ad70a3c09fe48 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 09:30:20 +0200 Subject: [PATCH 03/23] Docs: working links in MaxwellWeakAmpere/VlasovAmpereCoupling, document preconditioners and solvers [preview docs] - remove the iteration-count sentence from the MaxwellWeakAmpere docstring - full-path cross-references (Derham.curl, SchurSolver, prepare_time_step, kernel functions in their *_kernels modules); feectools inverse as literal - dev-feec.rst: new sections Preconditioners (struphy.feec.preconditioner, struphy.polar.kron_2dx1d, multigrid preconditioner) and Linear solvers (schur_solver, solver) - dev-pic.rst: vlasov_maxwell accumulation kernel, new Pusher kernels section with push_v_with_efield Co-Authored-By: Claude Opus 5.5 --- doc/sections/subsections/dev-feec.rst | 37 +++++++++++++++++++ doc/sections/subsections/dev-pic.rst | 8 ++++ .../propagators/maxwell_weak_ampere.py | 14 +++---- .../propagators/vlasov_ampere_coupling.py | 10 ++--- 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/doc/sections/subsections/dev-feec.rst b/doc/sections/subsections/dev-feec.rst index 98ae440b1..6cebc7079 100644 --- a/doc/sections/subsections/dev-feec.rst +++ b/doc/sections/subsections/dev-feec.rst @@ -46,3 +46,40 @@ Kronecker approximations of operators :special-members: :show-inheritance: :exclude-members: __init__ + + +Preconditioners +^^^^^^^^^^^^^^^ + +.. automodule:: struphy.feec.preconditioner + :members: + :show-inheritance: + :exclude-members: __init__ + +Polar 2D x 1D Kronecker solvers used by the polar preconditioners: + +.. automodule:: struphy.polar.kron_2dx1d + :members: + :show-inheritance: + :exclude-members: __init__ + +Geometric multigrid preconditioner: + +.. automodule:: struphy.linear_algebra.multigrid.preconditioner + :members: + :show-inheritance: + :exclude-members: __init__ + + +Linear solvers +^^^^^^^^^^^^^^ + +.. automodule:: struphy.linear_algebra.schur_solver + :members: + :show-inheritance: + :exclude-members: __init__ + +.. automodule:: struphy.linear_algebra.solver + :members: + :show-inheritance: + :exclude-members: __init__ diff --git a/doc/sections/subsections/dev-pic.rst b/doc/sections/subsections/dev-pic.rst index 9192a6686..993614ce8 100644 --- a/doc/sections/subsections/dev-pic.rst +++ b/doc/sections/subsections/dev-pic.rst @@ -55,6 +55,8 @@ Accumulation kernels .. automodule:: struphy.pic.accumulation.kernels +.. autofunction:: struphy.pic.accumulation.kernels.vlasov_maxwell.vlasov_maxwell_kernels.vlasov_maxwell + Pusher class ^^^^^^^^^^^^ @@ -101,3 +103,9 @@ are zero. Initialization setups must use ``alpha=0`` (the default). :special-members: :show-inheritance: :exclude-members: __init__ + + +Pusher kernels +^^^^^^^^^^^^^^ + +.. autofunction:: struphy.pic.pushing.kernels.push_v_with_efield.push_v_with_efield_kernels.push_v_with_efield diff --git a/src/struphy/propagators/maxwell_weak_ampere.py b/src/struphy/propagators/maxwell_weak_ampere.py index 36e1bd237..88caeee23 100644 --- a/src/struphy/propagators/maxwell_weak_ampere.py +++ b/src/struphy/propagators/maxwell_weak_ampere.py @@ -41,7 +41,7 @@ class MaxwellWeakAmpere(Propagator): with the mass matrices :math:`\mathbb M_1`, :math:`\mathbb M_2` (:class:`~struphy.feec.mass.WeightedMassOperators`) and the discrete curl :math:`\mathbb C` - (``derham.curl``). It conserves the energy + (:attr:`~struphy.feec.psydac_derham.Derham.curl`). It conserves the energy :math:`\frac 12 \mathbf e^\top \mathbb M_1 \mathbf e + \frac 12 \mathbf b^\top \mathbb M_2 \mathbf b` and the discrete divergence :math:`\mathbb D \mathbf b` (since :math:`\mathbb D \mathbb C = 0`). @@ -69,11 +69,7 @@ class MaxwellWeakAmpere(Propagator): solved with ``solver`` and ``precond``. By default (``precond="StiffnessPreconditioner"``) the Schur operator is preconditioned by :class:`~struphy.feec.preconditioner.StiffnessPreconditioner` for :math:`\mathbb C^\top \left(\frac{\Delta t^2}{4} \mathbb M_2\right) \mathbb C + \mathbb M_1`, built for the - current time step (:meth:`prepare_time_step`). Its number of iterations hardly depends on :math:`\Delta t`, - whereas with the mass matrix preconditioner of :math:`\mathbb M_1` it grows with - :math:`\Delta t / h` (16x16x4 cells, p=3, hollow cylinder, :math:`\Delta t = 10 h`: 35 instead of 257 PCG - iterations, :math:`\Delta t = 100 h`: 39 instead of 411). For :math:`\Delta t \lesssim h` the mass matrix - preconditioner needs fewer iterations. Then + current time step (:meth:`~struphy.propagators.maxwell_weak_ampere.MaxwellWeakAmpere.prepare_time_step`). Then :math:`\mathbf b^{n+1} = \mathbf b^n - \frac{\Delta t}{2} \mathbb C (\mathbf e^{n+1} + \mathbf e^n)`. - explicit: Runge-Kutta method from :class:`~struphy.ode.utils.ButcherTableau` for the system @@ -123,7 +119,7 @@ def __init__(self): @dataclass(repr=False) class Options(OptionsBase): - r"""Configuration options for :class:`MaxwellWeakAmpere`. + r"""Configuration options for :class:`~struphy.propagators.maxwell_weak_ampere.MaxwellWeakAmpere`. Parameters ---------- @@ -135,7 +131,7 @@ class Options(OptionsBase): solver : LiteralOptions.OptsSymmSolver, default="pcg" Name of the symmetric iterative solver passed to - :func:`psydac.linalg.solvers.inverse`. + ``feectools.linalg.solvers.inverse``. precond : LiteralOptions.OptsMaxwellPrecond, default="StiffnessPreconditioner" Preconditioner of the linear solves. @@ -165,7 +161,7 @@ class Options(OptionsBase): Notes ----- - System size reduction is performed via :class:`SchurSolver` for + System size reduction is performed via :class:`~struphy.linear_algebra.schur_solver.SchurSolver` for implicit time stepping to eliminate the magnetic field and reduce the system to the electric field equation. """ diff --git a/src/struphy/propagators/vlasov_ampere_coupling.py b/src/struphy/propagators/vlasov_ampere_coupling.py index 00be6cc72..65c9cc282 100644 --- a/src/struphy/propagators/vlasov_ampere_coupling.py +++ b/src/struphy/propagators/vlasov_ampere_coupling.py @@ -69,7 +69,7 @@ class VlasovAmpereCoupling(Propagator): - \Delta t \frac{\alpha^2}{\varepsilon} \, \mathbf j\,, with the accumulated matrix and vector (:class:`~struphy.pic.accumulation.particles_to_grid.Accumulator`, - kernel :func:`~struphy.pic.accumulation.kernels.vlasov_maxwell.vlasov_maxwell`) + kernel :func:`~struphy.pic.accumulation.kernels.vlasov_maxwell.vlasov_maxwell_kernels.vlasov_maxwell`) .. math:: @@ -81,12 +81,12 @@ class VlasovAmpereCoupling(Propagator): :math:`\mathbb A` is a particle approximation of the :math:`n`-weighted mass matrix :math:`\mathbb M_1`, so the Schur operator is a mass matrix with weight :math:`1 + \Delta t^2 \alpha^2 n / (4 \varepsilon^2)`, solved with ``solver`` and ``precond`` (built from :math:`\mathbb M_1`). The velocities are then updated by - :func:`~struphy.pic.pushing.kernels.push_v_with_efield.push_v_with_efield` with the mid-point field + :func:`~struphy.pic.pushing.kernels.push_v_with_efield.push_v_with_efield_kernels.push_v_with_efield` with the mid-point field :math:`\frac 12 (\mathbf e^{n+1} + \mathbf e^n)`. """ class Variables: - """Container for variables advanced by :class:`VlasovAmpereCoupling`. + """Container for variables advanced by :class:`~struphy.propagators.vlasov_ampere_coupling.VlasovAmpereCoupling`. Attributes ---------- @@ -125,12 +125,12 @@ def __init__(self): @dataclass(repr=False) class Options(OptionsBase): - """Configuration options for :class:`VlasovAmpereCoupling`. + """Configuration options for :class:`~struphy.propagators.vlasov_ampere_coupling.VlasovAmpereCoupling`. Parameters ---------- solver : LiteralOptions.OptsSymmSolver, default="pcg" - Symmetric iterative solver used by :class:`SchurSolver`. + Symmetric iterative solver used by :class:`~struphy.linear_algebra.schur_solver.SchurSolver`. precond : LiteralOptions.OptsMassPrecond, default="MassMatrixPreconditioner" Preconditioner for electric-field mass matrix block. solver_params : SolverParameters, default=None From 3edcd69eb628bffed1e171e9c2e73851e8d187e2 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 09:35:35 +0200 Subject: [PATCH 04/23] Fix the SchurSolver docstrings (broken math, parameters) [preview docs] The raw docstrings of SchurSolver, SchurSolverFull and SchurSolverFull3 used \\text and \\right (a LaTeX line break in a raw string), which broke the math; rewritten with pmatrix. Markdown links replaced by plain names, and the SchurSolver Parameters section now matches the signature (precond, solver_params). Co-Authored-By: Claude Opus 5.5 --- src/struphy/linear_algebra/schur_solver.py | 95 +++++++++------------- 1 file changed, 39 insertions(+), 56 deletions(-) diff --git a/src/struphy/linear_algebra/schur_solver.py b/src/struphy/linear_algebra/schur_solver.py index 586421cf1..177afe8ea 100644 --- a/src/struphy/linear_algebra/schur_solver.py +++ b/src/struphy/linear_algebra/schur_solver.py @@ -12,42 +12,38 @@ class SchurSolver: .. math:: - \left( \matrix{ - A & \Delta t B \cr - \Delta t C & \\text{Id} - } \\right) - \left( \matrix{ - x^{n+1} \cr y^{n+1} - } \\right) + \begin{pmatrix} A & \Delta t\, B \\ \Delta t\, C & \mathbb I \end{pmatrix} + \begin{pmatrix} x^{n+1} \\ y^{n+1} \end{pmatrix} = - \left( \matrix{ - A & - \Delta t B \cr - - \Delta t C & \\text{Id} - } \\right) - \left( \matrix{ - x^n \cr y^n - } \\right) + \begin{pmatrix} A & - \Delta t\, B \\ - \Delta t\, C & \mathbb I \end{pmatrix} + \begin{pmatrix} x^n \\ y^n \end{pmatrix} - using the Schur complement :math:`S = A - \Delta t^2 BC`, where Id is the identity matrix - and :math:`(x^n, y^n)` is given. The solution is given by + using the Schur complement :math:`S = A - \Delta t^2 BC`, where :math:`\mathbb I` is the identity matrix + and :math:`(x^n, y^n)` is given. The solution is .. math:: - x^{n+1} = S^{-1} \left[ (A + \Delta t^2 BC) \, x^n - 2 \Delta t B \, y^n \\right] \,. + x^{n+1} = S^{-1} \left[ (A + \Delta t^2 BC) \, x^n - 2 \Delta t\, B \, y^n \right] \,. + + The caller passes :math:`B y^n` and computes :math:`y^{n+1} = y^n - \Delta t\, C (x^{n+1} + x^n)` itself. Parameters ---------- A : LinearOperator - Upper left block from [[A B], [C Id]]. + Upper left block :math:`A`. BC : LinearOperator - Product from [[A B], [C Id]]. + Product :math:`BC` of the off-diagonal blocks (same domain and codomain as :math:`A`). solver_name : str - See [feectools.linalg.solvers](https://github.com/pyccel/psydac/blob/535717c6f5ea328aacbbbbcc2d582a92b31c9377/psydac/linalg/solvers.py#L47) for possible names. + Name of the iterative solver, passed to ``feectools.linalg.solvers.inverse`` (e.g. ``"pcg"``, ``"cg"``, + ``"pbicgstab"``, ``"gmres"``). - **solver_params : - Must correspond to the chosen solver. + precond : LinearOperator, optional + Preconditioner of the Schur complement :math:`S` (can be replaced later with :attr:`precond`). + + solver_params : SolverParameters, optional + Solver controls (``tol``, ``maxiter``, ...); defaults to ``SolverParameters()``. """ def __init__( @@ -186,37 +182,31 @@ class SchurSolverFull: .. math:: - \left( \matrix{ - A & B \cr - C & \\text{Id} - } \\right) - \left( \matrix{ - x \cr y - } \\right) + \begin{pmatrix} A & B \\ C & \mathbb I \end{pmatrix} + \begin{pmatrix} x \\ y \end{pmatrix} = - \left( \matrix{ - b_x \cr b_y - } \\right) + \begin{pmatrix} b_x \\ b_y \end{pmatrix} - using the Schur complement :math:`S = A - BC`, where Id is the identity matrix - and :math:`(b_x, b_y)^T` is given. The solution is given by + using the Schur complement :math:`S = A - BC`, where :math:`\mathbb I` is the identity matrix + and :math:`(b_x, b_y)^\top` is given. The solution is .. math:: x &= S^{-1} \, (b_x - B b_y ) \,, - + \\ y &= b_y - C x \,. Parameters ---------- M : BlockLinearOperator - Matrix [[A B], [C Id]]. + Block matrix :math:`[[A, B], [C, \mathbb I]]`. solver_name : str - See [feectools.linalg.solvers](https://github.com/pyccel/psydac/blob/535717c6f5ea328aacbbbbcc2d582a92b31c9377/psydac/linalg/solvers.py#L47) for possible names. + Name of the iterative solver, passed to ``feectools.linalg.solvers.inverse`` (e.g. ``"pcg"``, ``"cg"``, + ``"pbicgstab"``, ``"gmres"``). **solver_params : - Must correspond to the chosen solver. + Keyword arguments of the chosen solver. """ def __init__(self, M, solver_name, **solver_params): @@ -295,40 +285,33 @@ class SchurSolverFull3: .. math:: - \left( \matrix{ - A & B & D \cr - C & \\text{Id} & 0 \cr - E & 0 & \\text{Id} \cr - } \\right) - \left( \matrix{ - x \cr y \cr z - } \\right) + \begin{pmatrix} A & B & D \\ C & \mathbb I & 0 \\ E & 0 & \mathbb I \end{pmatrix} + \begin{pmatrix} x \\ y \\ z \end{pmatrix} = - \left( \matrix{ - b_x \cr b_y \cr b_z - } \\right) + \begin{pmatrix} b_x \\ b_y \\ b_z \end{pmatrix} - using the Schur complement :math:`S = A - BC - DE`, where Id is the identity matrix - and :math:`(b_x, b_y, b_z)^T` is given. The solution is given by + using the Schur complement :math:`S = A - BC - DE`, where :math:`\mathbb I` is the identity matrix + and :math:`(b_x, b_y, b_z)^\top` is given. The solution is .. math:: x &= S^{-1} \, (b_x - B b_y - D b_z) \,, - + \\ y &= b_y - C x \,, - + \\ z &= b_z - E x \,. Parameters ---------- M : BlockLinearOperator - Matrix [[A B D], [C Id 0], [E 0 Id]]. + Block matrix :math:`[[A, B, D], [C, \mathbb I, 0], [E, 0, \mathbb I]]`. solver_name : str - See [feectools.linalg.solvers](https://github.com/pyccel/psydac/blob/535717c6f5ea328aacbbbbcc2d582a92b31c9377/psydac/linalg/solvers.py#L47) for possible names. + Name of the iterative solver, passed to ``feectools.linalg.solvers.inverse`` (e.g. ``"pcg"``, ``"cg"``, + ``"pbicgstab"``, ``"gmres"``). **solver_params : - Must correspond to the chosen solver. + Keyword arguments of the chosen solver. """ def __init__(self, M, solver_name, **solver_params): From b8d04fa90b18063742229e845124b2bf79c86960 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 09:36:49 +0200 Subject: [PATCH 05/23] StiffnessPreconditioner docstring: references for fast diagonalization [preview docs] Lynch, Rice and Thomas (1964) for the method, Sangalli and Tani (2016) for preconditioning spline stiffness matrices; short description of the generalized eigendecomposition used by KroneckerSumSolver. Co-Authored-By: Claude Opus 5.5 --- src/struphy/feec/preconditioner.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/struphy/feec/preconditioner.py b/src/struphy/feec/preconditioner.py index cb75576eb..7dd3153f6 100644 --- a/src/struphy/feec/preconditioner.py +++ b/src/struphy/feec/preconditioner.py @@ -624,8 +624,12 @@ class StiffnessPreconditioner(KroneckerPreconditioner): with the 1d stiffness matrices :math:`S_d = \mathbb d_d^T M^D_d \mathbb d_d` in the directions :math:`N_c` in which component c is differentiated (its B-spline directions). These blocks - are inverted exactly by fast diagonalization - (:class:`~feectools.linalg.kron.KroneckerSumSolver`). For the gradient this is the exact + are inverted exactly by fast diagonalization (Lynch, Rice and Thomas 1964; for spline stiffness matrices + see Sangalli and Tani 2016, in the References below; implemented in + :class:`~feectools.linalg.kron.KroneckerSumSolver`): with the generalized eigendecompositions + :math:`S_d U_d = M_d U_d \Lambda_d`, :math:`U_d^\top M_d U_d = I`, the inverse of a Kronecker sum is + :math:`(U_1 \otimes U_2 \otimes U_3) \, D^{-1} \, (U_1 \otimes U_2 \otimes U_3)^\top` with a diagonal + :math:`D`, at the cost of a few 1d matrix products per direction. For the gradient this is the exact inverse on the logical cube. For curl and div, the off-diagonal blocks are neglected (block Jacobi, :math:`P_{BJ}`). @@ -734,6 +738,14 @@ class StiffnessPreconditioner(KroneckerPreconditioner): stab_average : str | None Gradient only: the directions averaged in the stabilization :math:`\mathbb S (\mathbb I - \mathbb A)`, e.g. ``"eta3"``, ``"eta2 eta3"`` or ``"eta1 eta2 eta3"`` (global mean); None (default) for no averaging. + + References + ---------- + R. E. Lynch, J. R. Rice and D. H. Thomas, Direct solution of partial difference equations by tensor product + methods, Numer. Math. 6, 185-199 (1964), `doi:10.1007/BF01386067 `_. + + G. Sangalli and M. Tani, Isogeometric preconditioners based on fast solvers for the Sylvester equation, + SIAM J. Sci. Comput. 38(6), A3644-A3671 (2016), `doi:10.1137/16M1062788 `_. """ _FORMS = {"grad": 0, "curl": 1, "div": 2} From 8dd6dc0fb71400c7506016ee5fd39838281d10ae Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 09:41:38 +0200 Subject: [PATCH 06/23] Review fixes: SchurSolver property docstrings, define D in fast diagonalization [preview docs] Co-Authored-By: Claude Opus 5.5 --- src/struphy/feec/preconditioner.py | 7 ++++--- src/struphy/linear_algebra/schur_solver.py | 12 ++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/struphy/feec/preconditioner.py b/src/struphy/feec/preconditioner.py index 7dd3153f6..c3c9da0c7 100644 --- a/src/struphy/feec/preconditioner.py +++ b/src/struphy/feec/preconditioner.py @@ -627,9 +627,10 @@ class StiffnessPreconditioner(KroneckerPreconditioner): are inverted exactly by fast diagonalization (Lynch, Rice and Thomas 1964; for spline stiffness matrices see Sangalli and Tani 2016, in the References below; implemented in :class:`~feectools.linalg.kron.KroneckerSumSolver`): with the generalized eigendecompositions - :math:`S_d U_d = M_d U_d \Lambda_d`, :math:`U_d^\top M_d U_d = I`, the inverse of a Kronecker sum is - :math:`(U_1 \otimes U_2 \otimes U_3) \, D^{-1} \, (U_1 \otimes U_2 \otimes U_3)^\top` with a diagonal - :math:`D`, at the cost of a few 1d matrix products per direction. For the gradient this is the exact + :math:`S_d U_d = M_d U_d \Lambda_d`, :math:`U_d^T M_d U_d = I`, and :math:`U = U_1 \otimes U_2 \otimes U_3`, + the block is :math:`A_{cc} = U^{-T} D \, U^{-1}` with the diagonal matrix :math:`D = U^T A_{cc} U` (sums of + Kronecker products of the :math:`\Lambda_d` and identities, plus :math:`\sigma`), hence + :math:`A_{cc}^{-1} = U D^{-1} U^T`, at the cost of a few 1d matrix products per direction. For the gradient this is the exact inverse on the logical cube. For curl and div, the off-diagonal blocks are neglected (block Jacobi, :math:`P_{BJ}`). diff --git a/src/struphy/linear_algebra/schur_solver.py b/src/struphy/linear_algebra/schur_solver.py index 177afe8ea..a46b98fe8 100644 --- a/src/struphy/linear_algebra/schur_solver.py +++ b/src/struphy/linear_algebra/schur_solver.py @@ -87,12 +87,12 @@ def __init__( @property def A(self): - """Upper left block from [[A B], [C Id]].""" + """Upper left block :math:`A`.""" return self._A @property def BC(self): - """Product from [[A B], [C Id]].""" + """Product :math:`BC` of the off-diagonal blocks.""" return self._BC @property @@ -110,12 +110,12 @@ def precond(self, pc): @A.setter def A(self, a): - """Upper left block from [[A B], [C Id]].""" + """Upper left block :math:`A`.""" self._A = a @BC.setter def BC(self, bc): - """Product from [[A B], [C Id]].""" + """Product :math:`BC` of the off-diagonal blocks.""" self._BC = bc @profile @@ -206,7 +206,7 @@ class SchurSolverFull: ``"pbicgstab"``, ``"gmres"``). **solver_params : - Keyword arguments of the chosen solver. + Keyword arguments of the chosen solver; must contain ``pc`` (the preconditioner, None for none). """ def __init__(self, M, solver_name, **solver_params): @@ -311,7 +311,7 @@ class SchurSolverFull3: ``"pbicgstab"``, ``"gmres"``). **solver_params : - Keyword arguments of the chosen solver. + Keyword arguments of the chosen solver; must contain ``pc`` (the preconditioner, None for none). """ def __init__(self, M, solver_name, **solver_params): From 481c4eb8449692ac8c01d3695cc3d0cd3262fd7c Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 10:23:13 +0200 Subject: [PATCH 07/23] VlasovMaxwellCoupling propagator and VlasovMaxwellQuasiNeutral model One implicit Crank-Nicolson step for Ampere (kappa dE/dt), Faraday and the electric force on the markers, with free coefficients c1, c2, c3. The Schur system kappa M1 + dt^2/4 (c1 C^T M2 C + c2 c3 A) is solved for the mid-point field; it stays well-posed for kappa -> 0 (no displacement current), where the plasma term absorbs the kernel of the curl-curl operator. Exact energy conservation (full-f), stiffness preconditioner with stabilization kappa + dt^2 c2 c3 n_ref / 4. The model subclasses VlasovMaxwellOneSpecies (propagator linking factored out), adds the scalar en_H and a Weibel example for kappa = 1 and 0. Co-Authored-By: Claude Opus 5.5 --- doc/sections/subsections/models-all.rst | 5 + .../subsections/propagators-avail.rst | 5 + .../params_weibel_quasi_neutral.py | 177 +++++++ .../pproc_weibel_quasi_neutral.py | 71 +++ src/struphy/api/models/__init__.py | 2 + src/struphy/models/__init__.py | 3 + .../models/vlasov_maxwell_one_species.py | 28 +- .../models/vlasov_maxwell_quasi_neutral.py | 181 +++++++ src/struphy/propagators/__init__.py | 3 + .../tests/test_vlasov_maxwell_coupling.py | 194 ++++++++ .../propagators/vlasov_maxwell_coupling.py | 466 ++++++++++++++++++ 11 files changed, 1126 insertions(+), 9 deletions(-) create mode 100644 examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py create mode 100644 examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/pproc_weibel_quasi_neutral.py create mode 100644 src/struphy/models/vlasov_maxwell_quasi_neutral.py create mode 100644 src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py create mode 100644 src/struphy/propagators/vlasov_maxwell_coupling.py diff --git a/doc/sections/subsections/models-all.rst b/doc/sections/subsections/models-all.rst index 484310d6e..4ae0c3886 100644 --- a/doc/sections/subsections/models-all.rst +++ b/doc/sections/subsections/models-all.rst @@ -21,6 +21,11 @@ Full-orbit Vlasov :undoc-members: :show-inheritance: +.. autoclass:: struphy.models.VlasovMaxwellQuasiNeutral + :members: doc_pde, doc_scalar_quantities, doc_normalization, doc_discretization, doc_long_description, doc_examples, doc_use_cases, doc_cannot_be_used_for + :undoc-members: + :show-inheritance: + .. autoclass:: struphy.models.LinearVlasovMaxwellOneSpecies :members: doc_pde, doc_scalar_quantities, doc_normalization, doc_discretization, doc_long_description, doc_examples, doc_use_cases, doc_cannot_be_used_for :undoc-members: diff --git a/doc/sections/subsections/propagators-avail.rst b/doc/sections/subsections/propagators-avail.rst index 7fd276e1c..ac971567a 100644 --- a/doc/sections/subsections/propagators-avail.rst +++ b/doc/sections/subsections/propagators-avail.rst @@ -197,6 +197,11 @@ Vlasov-Maxwell coupling :exclude-members: options, allocate :show-inheritance: +.. automodule:: struphy.propagators.vlasov_maxwell_coupling + :members: + :exclude-members: options, allocate + :show-inheritance: + MHD-kinetic coupling ^^^^^^^^^^^^^^^^^^^^ diff --git a/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py new file mode 100644 index 000000000..01c563885 --- /dev/null +++ b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py @@ -0,0 +1,177 @@ +"""Weibel instability with the VlasovMaxwellQuasiNeutral model, with and without displacement current +(`--kappa 1` or `--kappa 0`), at a time step 20 times larger than in the VlasovMaxwellOneSpecies example.""" + +import argparse +from pathlib import Path + +# Command line options +# no -h here when imported by the pproc script, which has its own help +parser = argparse.ArgumentParser(add_help=__name__ == "__main__") +parser.add_argument( + "--out-folder", + default=str(Path(__file__).resolve().parent), + help="Folder in which the output folder is created (default: beside this file).", +) +parser.add_argument("--sim-folder", default=None, help="Name of the output folder (default: sim_kappa).") +parser.add_argument( + "--kappa", type=float, default=0.0, help="Coefficient of the displacement current (1: Maxwell, 0: quasi-neutral)." +) +parser.add_argument("--dt", type=float, default=1.0, help="Time step.") +parser.add_argument("--tend", type=float, default=400.0, help="End time.") +parser.add_argument( + "--test-run", + action="store_true", + help="Few markers and three saved time steps, for the tests (test_examples.py).", +) +args, _ = parser.parse_known_args() +if args.sim_folder is None: + args.sim_folder = f"sim_kappa{args.kappa:g}" +if args.test_run: + args.tend = 2 * args.dt + +name = "Weibel instability in the quasi-neutral limit" + +description = f""" +Weibel instability with the VlasovMaxwellQuasiNeutral model: 1D-3V kinetic electrons (PIC) with Maxwell's +equations in a periodic box with a neutralizing ion background, solved with the implicit, energy conserving +VlasovMaxwellCoupling step. The electrons are hotter in v2 than in v1 (along the wave vector); this temperature +anisotropy makes a small transverse magnetic field B3 grow exponentially until magnetic trapping saturates it. +Here kappa = {args.kappa:g}. + +Physics. With alpha = 1 and epsilon = -1 (electrons, time in units of 1/omega_p, velocities in units of c, lengths +in units of the skin depth c/omega_p) the model solves + df/dt + v . grad_x f - (E + v x B) . grad_v f = 0, + kappa dE/dt = curl B + int v f d^3v, + dB/dt = -curl E. +kappa = 1 is the Vlasov-Maxwell system; kappa = 0 drops the displacement current (quasi-neutral, Darwin-type +limit), where E is the Lagrange multiplier of the constraint curl B = J. For a bi-Maxwellian with thermal speeds +vth1 (along k) and vth2, the transverse mode (E2, B3) obeys + kappa omega^2 - k^2 - 1 + (vth2^2 / vth1^2) [1 + xi Z(xi)] = 0, xi = omega / (sqrt(2) k vth1) +(the 1 is the plasma term, which stays for kappa = 0). The mode is purely growing, omega = i gamma, and +kappa gamma^2 ~ 8e-4 is negligible against k^2 + 1 = 2.56: the Weibel instability is a low-frequency, transverse +(div J = 0) mode that the quasi-neutral limit keeps. + +Setup. As in the VlasovMaxwellOneSpecies Weibel example: periodic box of length 2 pi / k with k = 1.25, 32 cells +and cubic splines in x, Maxwellian electrons with n = 1 and vth1 = 0.02 / sqrt(2), vth2 = sqrt(12) vth1 (v3 = 0), +the seed field B3 = -1e-4 cos(k x), 100000 markers with full-f weights (seed 1234; the quasi-neutral limit needs +full-f weights). Time step dt = {args.dt:g} (20 times the dt = 0.05 of the VlasovMaxwellOneSpecies example; the +implicit coupling step is stable for dt omega_p > 1) up to t = {args.tend:g}, Lie-Trotter splitting. + +Expected result. For k = 1.25 the dispersion relation gives gamma = 0.02784 for both kappa = 1 and kappa = 0 (the +growth rate of the amplitude of B3; the magnetic energy grows as exp(2 gamma t)). The energy en_H conserved by the +coupling step changes only by the splitting with the particle pushers. The pproc fits gamma to the magnetic energy +(between t = Tend/5 and 2 Tend/5), compares with the analytical value and plots the energies. + +References: +- E. S. Weibel, "Spontaneously growing transverse waves in a plasma due to an anisotropic velocity distribution", + Phys. Rev. Lett. 2, 83 (1959). +- M. Kraus, K. Kormann, P. J. Morrison and E. Sonnendruecker, "GEMPIC: geometric electromagnetic particle-in-cell + methods", J. Plasma Phys. 83, 905830401 (2017). +""" + +# ------------------ +# Import Struphy API +# ------------------ + +import logging +import math + +from struphy import ( + BaseUnits, + BoundaryParameters, + DerhamOptions, + EnvironmentOptions, + LoadingParameters, + SavingParameters, + Simulation, + SortingParameters, + Time, + WeightsParameters, + domains, + grids, + maxwellians, + perturbations, + set_logging_level, +) + +set_logging_level(logging.WARNING) + +# --------------------- +# Instance of the model +# --------------------- + +from struphy.models import VlasovMaxwellQuasiNeutral + +model = VlasovMaxwellQuasiNeutral(base_units=BaseUnits(), alpha=1.0, epsilon=-1.0) + +k = 1.25 +B_pert_amp = -1e-4 +vth1_background_val = 0.02 / math.sqrt(2) +vth2_background_val = vth1_background_val * math.sqrt(12) + +model.em_fields.e_field.save_data = True +model.em_fields.b_field.save_data = True +model.kinetic_ions.var.save_data = False + +# -------------------------- +# Instance of the simulation +# -------------------------- + +env = EnvironmentOptions(out_folders=args.out_folder, sim_folder=args.sim_folder) +time_opts = Time(dt=args.dt, Tend=args.tend, split_algo="LieTrotter") +Lx = 2 * math.pi / k +domain = domains.Cuboid(r1=Lx) +grid = grids.TensorProductGrid(num_elements=(32, 1, 1)) +derham_opts = DerhamOptions(degree=(3, 1, 1)) + +sim = Simulation( + model=model, + name=name, + description=description, + params_path=__file__, + env=env, + time_opts=time_opts, + domain=domain, + grid=grid, + derham_opts=derham_opts, +) + +# ------------------- +# Particle parameters +# ------------------- + +model.kinetic_ions.set_markers( + loading_params=LoadingParameters( + Np=2000 if args.test_run else 100000, + set_zero_velocity=(False, False, True), + moments=(0.0, 0.0, 0.0, vth1_background_val, vth2_background_val, 1.0), + seed=1234, + ), + weights_params=WeightsParameters(control_variate=False), + boundary_params=BoundaryParameters(), + sorting_params=SortingParameters(boxes_per_dim=(16, 1, 1), do_sort=True), + saving_params=SavingParameters(n_markers=0), + bufsize=2.0, +) + +# ------------------ +# Propagator options +# ------------------ + +model.propagators.coupling.options = model.propagators.coupling.Options(kappa=args.kappa) +model.propagators.push_eta.options = model.propagators.push_eta.Options() +model.propagators.push_vxb.options = model.propagators.push_vxb.Options() +model.initial_poisson.options = model.initial_poisson.Options(stab_mat="M0") + +# ------------------ +# Initial conditions +# ------------------ + +model.kinetic_ions.var.add_background( + maxwellians.Maxwellian3D(vth1=(vth1_background_val, None), vth2=(vth2_background_val, None)) +) +model.em_fields.b_field.add_perturbation(perturbation=perturbations.ModesCos(amps=(B_pert_amp,), ls=(1,), comp=2)) + +if __name__ == "__main__": + # post-processing and plots: pproc_weibel_quasi_neutral.py + sim.run() diff --git a/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/pproc_weibel_quasi_neutral.py b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/pproc_weibel_quasi_neutral.py new file mode 100644 index 000000000..5101bcd31 --- /dev/null +++ b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/pproc_weibel_quasi_neutral.py @@ -0,0 +1,71 @@ +"""Post-process the quasi-neutral Weibel example: growth rate of the magnetic energy and energy conservation. + +Run as ``python pproc_weibel_quasi_neutral.py [--kappa K]`` (same flags as the params file) or with the path of the +output folder. Writes ``energies.png`` into ``/results`` and prints the fitted growth rate. +""" + +import argparse +import sys +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np + +from struphy import Output + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) +from params_weibel_quasi_neutral import args # noqa: E402 + +DEFAULT_OUTPUT = Path(args.out_folder) / args.sim_folder +GAMMA_EXACT = 0.02784 + + +def main(path_out=DEFAULT_OUTPUT): + out = Output(path_out) + scalars = out.scalars + t = scalars["en_B"].t.values + en_B = scalars["en_B"].values + en_H = scalars["en_H"].values + + results_dir = Path(path_out) / "results" + results_dir.mkdir(exist_ok=True) + + # growth rate from the linear phase, Tend/5 < t < 2 Tend/5: en_B ~ exp(2 gamma t) + mask = (t > t[-1] / 5) & (t < 2 * t[-1] / 5) + if np.count_nonzero(mask) > 2: + slope, offset = np.polyfit(t[mask], np.log(en_B[mask]), 1) + gamma = slope / 2 + print( + f"fitted gamma = {gamma:.5f}, analytical gamma = {GAMMA_EXACT} (rel. error {gamma / GAMMA_EXACT - 1:.2%})" + ) + else: + gamma = None + print("Too few saved time steps for a growth-rate fit.") + print(f"max relative change of en_H: {np.max(np.abs(en_H - en_H[0])) / abs(en_H[0]):.2e}") + + fig, axs = plt.subplots(1, 2, figsize=(11, 4)) + axs[0].semilogy(t, en_B, label="en_B") + axs[0].semilogy(t, scalars["en_E"].values, label="en_E") + if gamma is not None: + axs[0].semilogy(t[mask], np.exp(offset + slope * t[mask]), "k--", label=f"fit, gamma = {gamma:.4f}") + axs[0].semilogy( + t, en_B[mask][0] * np.exp(2 * GAMMA_EXACT * (t - t[mask][0])), "r:", label=f"gamma = {GAMMA_EXACT}" + ) + axs[0].set_ylim(en_B.min() / 10, en_B.max() * 10) + axs[0].set_xlabel("t") + axs[0].legend() + axs[1].plot(t, (en_H - en_H[0]) / abs(en_H[0])) + axs[1].set_xlabel("t") + axs[1].set_ylabel("(en_H - en_H(0)) / en_H(0)") + fig.suptitle(f"Weibel instability, {out.path_out.name}") + fig.tight_layout() + fig.savefig(results_dir / "energies.png") + plt.close(fig) + + +if __name__ == "__main__": + pproc_parser = argparse.ArgumentParser(description="Post-process a saved simulation run.") + pproc_parser.add_argument("path_out", nargs="?", default=DEFAULT_OUTPUT, help="Output folder of the run.") + pproc_args, _ = pproc_parser.parse_known_args() + main(pproc_args.path_out) diff --git a/src/struphy/api/models/__init__.py b/src/struphy/api/models/__init__.py index 50d2cfa31..65f2628a3 100644 --- a/src/struphy/api/models/__init__.py +++ b/src/struphy/api/models/__init__.py @@ -50,6 +50,7 @@ from struphy.models.vlasov import Vlasov from struphy.models.vlasov_ampere_one_species import VlasovAmpereOneSpecies from struphy.models.vlasov_maxwell_one_species import VlasovMaxwellOneSpecies +from struphy.models.vlasov_maxwell_quasi_neutral import VlasovMaxwellQuasiNeutral from struphy.models.vlasov_poisson_adiabatic import VlasovPoissonAdiabatic from struphy.models.vlasov_poisson_adiabatic_pauli import VlasovPoissonAdiabaticPauli @@ -96,6 +97,7 @@ "Vlasov", "VlasovAmpereOneSpecies", "VlasovMaxwellOneSpecies", + "VlasovMaxwellQuasiNeutral", "VlasovPoissonAdiabatic", "VlasovPoissonAdiabaticPauli", ] diff --git a/src/struphy/models/__init__.py b/src/struphy/models/__init__.py index 2af019693..0ae7bbdd1 100644 --- a/src/struphy/models/__init__.py +++ b/src/struphy/models/__init__.py @@ -51,6 +51,7 @@ "Vlasov": "struphy.models.vlasov", "VlasovAmpereOneSpecies": "struphy.models.vlasov_ampere_one_species", "VlasovMaxwellOneSpecies": "struphy.models.vlasov_maxwell_one_species", + "VlasovMaxwellQuasiNeutral": "struphy.models.vlasov_maxwell_quasi_neutral", "VlasovPoissonAdiabatic": "struphy.models.vlasov_poisson_adiabatic", "VlasovPoissonAdiabaticPauli": "struphy.models.vlasov_poisson_adiabatic_pauli", } @@ -98,6 +99,7 @@ from struphy.models.vlasov import Vlasov from struphy.models.vlasov_ampere_one_species import VlasovAmpereOneSpecies from struphy.models.vlasov_maxwell_one_species import VlasovMaxwellOneSpecies + from struphy.models.vlasov_maxwell_quasi_neutral import VlasovMaxwellQuasiNeutral from struphy.models.vlasov_poisson_adiabatic import VlasovPoissonAdiabatic from struphy.models.vlasov_poisson_adiabatic_pauli import VlasovPoissonAdiabaticPauli @@ -144,6 +146,7 @@ "Vlasov", "VlasovAmpereOneSpecies", "VlasovMaxwellOneSpecies", + "VlasovMaxwellQuasiNeutral", "VlasovPoissonAdiabatic", "VlasovPoissonAdiabaticPauli", ] diff --git a/src/struphy/models/vlasov_maxwell_one_species.py b/src/struphy/models/vlasov_maxwell_one_species.py index 66ae1185d..46573cc61 100644 --- a/src/struphy/models/vlasov_maxwell_one_species.py +++ b/src/struphy/models/vlasov_maxwell_one_species.py @@ -116,12 +116,7 @@ def __init__( self.propagators = self.Propagators(b2_var=self.em_fields.b_field) # 4. assign variables to propagators - self.propagators.maxwell.variables.e = self.em_fields.e_field - self.propagators.maxwell.variables.b = self.em_fields.b_field - self.propagators.push_eta.variables.var = self.kinetic_ions.var - self.propagators.push_vxb.variables.ions = self.kinetic_ions.var - self.propagators.coupling_va.variables.e = self.em_fields.e_field - self.propagators.coupling_va.variables.ions = self.kinetic_ions.var + self._link_propagator_variables() # 5. define scalars to be tracked during simulation electric_energy = BilinearEnergyFEEC(self.em_fields.e_field) @@ -160,6 +155,18 @@ def __init__( # property to measure violation of gauss law from control variate self.measure_gauss_law = measure_gauss_law + def _link_propagator_variables(self): + """Assign the species variables to the propagators (overridden by models with other propagators). + + :meta private: + """ + self.propagators.maxwell.variables.e = self.em_fields.e_field + self.propagators.maxwell.variables.b = self.em_fields.b_field + self.propagators.push_eta.variables.var = self.kinetic_ions.var + self.propagators.push_vxb.variables.ions = self.kinetic_ions.var + self.propagators.coupling_va.variables.e = self.em_fields.e_field + self.propagators.coupling_va.variables.ions = self.kinetic_ions.var + @property def bulk_species(self): return self.kinetic_ions @@ -251,17 +258,20 @@ def calculate_gauss_error(self): ## default parameters def generate_default_parameter_file(self, path=None, prompt=True): params_path = super().generate_default_parameter_file(path=path, prompt=prompt) + # the options of the initial Poisson solve go after those of the last propagator + last_prop = list(self.propagators.__dict__)[-1] + model_name = type(self).__name__ new_file = [] with open(params_path, "r") as f: for line in f: - if "coupling_va.Options" in line: + if f"{last_prop}.Options" in line: new_file += [line] new_file += ["model.initial_poisson.options = model.initial_poisson.Options()\n"] elif "saving_params = " in line: new_file += ["\nbinplot = BinningPlot(slice='e1', n_bins=128, ranges=(0.0, 1.0))\n"] new_file += ["saving_params = SavingParameters(binning_plots=(binplot,))\n\n"] - elif "VlasovMaxwellOneSpecies()" in line: - new_file += ["\nmodel = VlasovMaxwellOneSpecies(measure_gauss_law=True)\n"] + elif f"{model_name}()" in line: + new_file += [f"\nmodel = {model_name}(measure_gauss_law=True)\n"] else: new_file += [line] diff --git a/src/struphy/models/vlasov_maxwell_quasi_neutral.py b/src/struphy/models/vlasov_maxwell_quasi_neutral.py new file mode 100644 index 000000000..300d9febb --- /dev/null +++ b/src/struphy/models/vlasov_maxwell_quasi_neutral.py @@ -0,0 +1,181 @@ +from struphy import BaseUnits +from struphy.models.scalars import FunctionScalarFEEC, Scalars +from struphy.models.vlasov_maxwell_one_species import VlasovMaxwellOneSpecies +from struphy.propagators.push_eta import PushEta +from struphy.propagators.push_vxb import PushVxB +from struphy.propagators.vlasov_maxwell_coupling import VlasovMaxwellCoupling + + +class VlasovMaxwellQuasiNeutral(VlasovMaxwellOneSpecies): + """Vlasov-Maxwell equations for one kinetic species with an implicit, energy conserving field-particle coupling + that also holds in the quasi-neutral limit (no displacement current). + + Same as :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies`, but Ampère's law, Faraday's + law and the electric force on the particles are solved together in one Crank-Nicolson step + (:class:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling`). Its option ``kappa`` multiplies the + displacement current; ``kappa=0.0`` is the quasi-neutral limit. + + Parameters + ---------- + base_units: BaseUnits + Base units for normalization (default: BaseUnits()) + charge_number: int + Charge number (in units of the positive elementary charge) of the species (default: 1) + mass_number: float + Mass number (in units of Proton mass) of the species (default: 1.0) + alpha: float, optional + Dimensionless parameter: plasma frequency / cyclotron frequency. If None, computed from units and charge/mass numbers. + epsilon: float, optional + Normalized cyclotron period: 1 / (cyclotron frequency × time unit). If None, computed from units and charge/mass numbers. + measure_gauss_law: bool + Whether to track the Gauss-law error as a scalar quantity (default: False) + """ + + class Propagators: + def __init__( + self, + b2_var=None, + ): + self.coupling = VlasovMaxwellCoupling() + self.push_eta = PushEta() + self.push_vxb = PushVxB(b2_var=b2_var) + + def __init__( + self, + base_units: BaseUnits = BaseUnits(), + charge_number: int = 1, + mass_number: float = 1.0, + alpha: float = None, + epsilon: float = None, + measure_gauss_law: bool = False, + ): + super().__init__( + base_units=base_units, + charge_number=charge_number, + mass_number=mass_number, + alpha=alpha, + epsilon=epsilon, + measure_gauss_law=measure_gauss_law, + ) + + # the energy conserved by the coupling step (with its coefficients kappa, c1, c2, c3) + self.scalars = Scalars(**self.scalars.dct, en_H=FunctionScalarFEEC(self.propagators.coupling.energy)) + + def _link_propagator_variables(self): + """Assign the species variables to the propagators. + + :meta private: + """ + self.propagators.coupling.variables.e = self.em_fields.e_field + self.propagators.coupling.variables.b = self.em_fields.b_field + self.propagators.coupling.variables.ions = self.kinetic_ions.var + self.propagators.push_eta.variables.var = self.kinetic_ions.var + self.propagators.push_vxb.variables.ions = self.kinetic_ions.var + + @classmethod + def doc_pde(cls): + r"""**PDEs solved by model:** + + Vlasov equation: + + .. math:: + + \frac{\partial f}{\partial t} + \mathbf{v} \cdot \nabla f + \frac{1}{\varepsilon} \left( \mathbf{E} + \mathbf{v} \times \left( \mathbf{B} + \mathbf{B}_0 \right) \right) \cdot \frac{\partial f}{\partial \mathbf{v}} = 0 + + Ampère's law, with the coefficient :math:`\kappa \geq 0` of the displacement current (option ``kappa`` of + :class:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling`, not the species parameter): + + .. math:: + + -\kappa \frac{\partial \mathbf{E}}{\partial t} + \nabla \times \mathbf{B} = \frac{\alpha^2}{\varepsilon} \int_{\mathbb{R}^3} \mathbf{v} f \, \text{d}^3 \mathbf{v} + + Faraday's law: + + .. math:: + + \frac{\partial \mathbf{B}}{\partial t} + \nabla \times \mathbf{E} = 0 + + For :math:`\kappa = 1` these are the equations of + :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies`. For :math:`\kappa = 0` + (quasi-neutral limit) Ampère's law becomes :math:`\nabla \times \mathbf B = \frac{\alpha^2}{\varepsilon} + \mathbf J`, which implies :math:`\nabla \cdot \mathbf J = 0`; the electric field is then determined by this + constraint, not by its own evolution. The coefficients of the three coupling terms can be changed by the + options ``c1``, ``c2``, ``c3`` of the propagator. + + At initial time the weak Poisson equation is solved once to weakly satisfy Gauss' law, as in + :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies` (for :math:`\kappa = 0` the + initial electric field does not enter the time stepping). + """ + + @classmethod + def doc_scalar_quantities(cls): + r"""**The following scalars are tracked during simulation:** + + - Electric field energy: ``en_E`` + - Magnetic field energy: ``en_B`` + - Particle kinetic energy: ``en_f`` + - Total energy: ``en_tot`` + - Energy conserved by the coupling step, + :math:`\frac{\kappa}{2} \| \mathbf E \|^2 + \frac{c_1}{2} \| \mathbf B \|^2 + \frac{c_2}{2 c_3} \sum_p w_p + |\mathbf v_p|^2`: ``en_H`` (equal to ``en_tot`` for the default coefficients and :math:`\kappa = 1`) + - Optional Gauss-law diagnostic: ``gauss_error``""" + + @classmethod + def doc_discretization(cls): + """Time integration is performed by the following propagators (in sequence): + + 1. :class:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling` + 2. :class:`~struphy.propagators.push_eta.PushEta` + 3. :class:`~struphy.propagators.push_vxb.PushVxB` + """ + doc = rf"""**1. VlasovMaxwellCoupling:** + +{VlasovMaxwellCoupling.__doc__} + +**2. PushEta:** + +{PushEta.__doc__} + +**3. PushVxB:** + +{PushVxB.__doc__} +""" + return doc + + @classmethod + def doc_long_description(cls): + r"""VlasovMaxwellQuasiNeutral solves the Vlasov-Maxwell system of + :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies` with one implicit step for + Maxwell's equations and the electric force, which conserves the energy exactly and allows time steps + larger than the inverse plasma frequency and the light-wave CFL limit. Setting the coefficient of the + displacement current to zero gives the quasi-neutral (Darwin-type) limit, in which the electric field is the + Lagrange multiplier of a divergence-free current.""" + + @classmethod + def doc_examples(cls): + r"""Create a quasi-neutral Vlasov-Maxwell model: + + .. code-block:: python + + from struphy.models import VlasovMaxwellQuasiNeutral + + model = VlasovMaxwellQuasiNeutral() + prop = model.propagators.coupling + prop.options = prop.Options(kappa=0.0) + """ + + @classmethod + def doc_use_cases(cls): + r"""This model is appropriate for: + + - electromagnetic kinetic simulations with time steps above the plasma period + - the quasi-neutral limit of the Vlasov-Maxwell system (``kappa=0.0``) + - comparison with :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies`""" + + @classmethod + def doc_cannot_be_used_for(cls): + r"""This model is not suitable for: + + - the quasi-neutral limit with control-variate (delta-f) weights or with vacuum regions + - multi-species plasma dynamics without extension + - collisional kinetic closures""" diff --git a/src/struphy/propagators/__init__.py b/src/struphy/propagators/__init__.py index a4b34c03e..770e51e8b 100644 --- a/src/struphy/propagators/__init__.py +++ b/src/struphy/propagators/__init__.py @@ -50,6 +50,7 @@ "VariationalResistivity": "struphy.propagators.variational_resistivity", "VariationalViscosity": "struphy.propagators.variational_viscosity", "VlasovAmpereCoupling": "struphy.propagators.vlasov_ampere_coupling", + "VlasovMaxwellCoupling": "struphy.propagators.vlasov_maxwell_coupling", } if TYPE_CHECKING: # static analysis and IDEs see the eager imports @@ -102,6 +103,7 @@ from struphy.propagators.variational_resistivity import VariationalResistivity from struphy.propagators.variational_viscosity import VariationalViscosity from struphy.propagators.vlasov_ampere_coupling import VlasovAmpereCoupling + from struphy.propagators.vlasov_maxwell_coupling import VlasovMaxwellCoupling __all__ = [ "AdiabaticPhi", @@ -151,6 +153,7 @@ "VariationalResistivity", "VariationalViscosity", "VlasovAmpereCoupling", + "VlasovMaxwellCoupling", ] diff --git a/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py b/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py new file mode 100644 index 000000000..c9afcf912 --- /dev/null +++ b/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py @@ -0,0 +1,194 @@ +import logging + +import cunumpy as xp +import pytest + +from struphy import set_logging_level + +set_logging_level(logging.WARNING) + + +def _model(model_name="VlasovMaxwellQuasiNeutral", control_variate=False, **options): + """Allocated model (1D-ish slab, full-f electrons with alpha=1, epsilon=-1, a density and a B perturbation) and + its coupling propagator(s) with the given options.""" + from struphy import ( + DerhamOptions, + LoadingParameters, + Simulation, + WeightsParameters, + domains, + grids, + maxwellians, + models, + perturbations, + ) + from struphy.linear_algebra.solver import SolverParameters + + model = getattr(models, model_name)(alpha=1.0, epsilon=-1.0) + model.kinetic_ions.set_markers( + loading_params=LoadingParameters(ppc=40, seed=1234), + weights_params=WeightsParameters(control_variate=control_variate), + ) + model.kinetic_ions.var.add_background(maxwellians.Maxwellian3D(n=(1.0, None))) + model.kinetic_ions.var.add_initial_condition( + maxwellians.Maxwellian3D(n=(1.0, perturbations.ModesCos(ls=(1,), amps=(1e-1,)))) + ) + model.em_fields.b_field.add_perturbation( + perturbations.ModesSin(ls=(1,), ms=(1,), given_in_basis="2", comp=2, amps=(1e-1,)) + ) + + solver_params = SolverParameters(tol=1e-13, maxiter=3000) + if model_name == "VlasovMaxwellQuasiNeutral": + prop = model.propagators.coupling + prop.options = prop.Options(solver_params=solver_params, **options) + else: + for prop in (model.propagators.maxwell, model.propagators.coupling_va): + prop.options = prop.Options(solver_params=solver_params) + + Simulation( + model=model, + domain=domains.Cuboid(r1=4.0, r2=2.0), + grid=grids.TensorProductGrid(num_elements=(8, 4, 1)), + derham_opts=DerhamOptions(degree=(2, 2, 1)), + ).allocate() + return model + + +def _state(model): + e = model.em_fields.e_field.spline.vector.copy() + b = model.em_fields.b_field.spline.vector.copy() + v = model.kinetic_ions.var.particles.velocities.copy() + return e, b, v + + +def _norm(x): + return x.inner(x) ** 0.5 + + +def _vnorm(v): + """Global 2-norm of the local marker velocities (a collective operation).""" + from maybempi import MPI + + from struphy.propagators.base import Propagator + + val = xp.array([float(xp.sum(v**2))]) + comm = Propagator.derham.comm + if comm is not None and comm.Get_size() > 1: + comm.Allreduce(MPI.IN_PLACE, val, op=MPI.SUM) + return float(val[0]) ** 0.5 + + +@pytest.mark.parametrize("kappa", [1.0, 0.0]) +@pytest.mark.parametrize("precond", ["StiffnessPreconditioner", "MassMatrixPreconditioner"]) +def test_energy_conservation(kappa, precond): + """The coupling step conserves its energy H exactly (up to the solver tolerance), also for dt >> 1/omega_p and + in the quasi-neutral limit kappa=0.""" + model = _model(kappa=kappa, precond=precond) + prop = model.propagators.coupling + b = model.em_fields.b_field.spline.vector + energy_0 = prop.energy() + energy_b_0 = 0.5 * b.inner(prop._M2.dot(b)) + for _ in range(3): + prop(2.0) + assert prop._solver._info["success"] + # H is dominated by the thermal energy: compare its change also with the energy exchanged with the B field + exchanged = abs(0.5 * b.inner(prop._M2.dot(b)) - energy_b_0) + assert exchanged > 10 * energy_b_0 + assert abs(prop.energy() - energy_0) < 1e-9 * exchanged + + +def test_agrees_with_split_propagators(): + """For kappa=1 and the species coefficients, one step agrees with MaxwellWeakAmpere followed by + VlasovAmpereCoupling up to the splitting error O(dt^2).""" + diffs = [] + for dt in (0.1, 0.05): + coupled = _model() + coupled.propagators.coupling(dt) + split = _model("VlasovMaxwellOneSpecies") + split.propagators.maxwell(dt) + split.propagators.coupling_va(dt) + # the two simulations have different Derham objects: compare the arrays (global 2-norms) + diff = 0.0 + for x_c, x_s in zip(_state(coupled), _state(split)): + x_c = x_c if isinstance(x_c, xp.ndarray) else x_c.toarray() + x_s = x_s if isinstance(x_s, xp.ndarray) else x_s.toarray() + diff += _vnorm(x_c - x_s) / _vnorm(x_s) + diffs.append(diff) + # local splitting error O(dt^2): halving dt divides the difference by about 4 + assert 3.0 < diffs[0] / diffs[1] < 5.0, diffs + + +def test_quasi_neutral_limit(): + """b and V converge like kappa to the kappa=0 solution, and at kappa=0 the displacement-free Ampère law + c1 C^T M2 b_mid = c2 j_mid holds; the iteration counts stay bounded as kappa -> 0.""" + dt = 0.5 + results = {} + niter = {} + for kappa in (1e-2, 1e-4, 0.0): + model = _model(kappa=kappa) + prop = model.propagators.coupling + e0, b0, v0 = _state(model) + prop._accum() + j0 = prop._accum.vectors[0].copy() + prop(dt) + niter[kappa] = prop._solver._info["niter"] + e1, b1, v1 = _state(model) + results[kappa] = (b1.toarray(), v1) + + if kappa == 0.0: + # j is linear in V at fixed positions: j_mid = (j(V^n) + j(V^{n+1})) / 2 + prop._accum() + j_mid = (j0 + prop._accum.vectors[0]) * 0.5 + b_mid = (b0 + b1) * 0.5 + curl_b = prop._curl.T.dot(prop._M2.dot(b_mid)) * prop._c1 + residual = curl_b - j_mid * prop._c2 + assert _norm(residual) < 1e-8 * _norm(curl_b) + # weakly divergence-free mid-point current + div_j = prop.derham.grad.T.dot(j_mid) + assert _norm(div_j) < 1e-8 * _norm(j_mid) + + b_ref, v_ref = results[0.0] + errs = [_vnorm(results[k][0] - b_ref) + _vnorm(results[k][1] - v_ref) for k in (1e-2, 1e-4)] + assert errs[1] < 3e-2 * errs[0], errs + assert max(niter.values()) <= 2 * min(niter.values()), niter + + +def test_quasi_neutral_independent_of_e0(): + """For kappa=0, the electric field at the beginning of the step does not enter: b, V and the stored + e = e_mid are the same for a different e^n.""" + dt = 0.5 + out = [] + for scale in (1.0, -3.0): + model = _model(kappa=0.0) + model.em_fields.e_field.spline.vector *= scale + model.em_fields.e_field.spline.vector.update_ghost_regions() + model.propagators.coupling(dt) + out.append(_state(model)) + for x, y in zip(out[0], out[1]): + x = x if isinstance(x, xp.ndarray) else x.toarray() + y = y if isinstance(y, xp.ndarray) else y.toarray() + assert _vnorm(x - y) < 1e-8 * _vnorm(x) + + +def test_control_variate_quasi_neutral_rejected(): + """The quasi-neutral limit needs full-f weights.""" + with pytest.raises(AssertionError): + _model(control_variate=True, kappa=0.0) + + +@pytest.mark.mpi_skip +def test_options(): + """Invalid coefficients are rejected; solver="cg" takes no preconditioner.""" + from struphy.propagators.vlasov_maxwell_coupling import VlasovMaxwellCoupling + + Options = VlasovMaxwellCoupling.Options + with pytest.raises(AssertionError): + Options(kappa=-1.0) + with pytest.raises(AssertionError): + Options(c1=0.0) + with pytest.raises(AssertionError): + Options(c2=1.0, c3=-1.0) + with pytest.raises(AssertionError): + Options(precond="HiptmairXu") + assert Options().precond == "StiffnessPreconditioner" + assert Options(solver="cg").precond is None diff --git a/src/struphy/propagators/vlasov_maxwell_coupling.py b/src/struphy/propagators/vlasov_maxwell_coupling.py new file mode 100644 index 000000000..6e5e4e9dd --- /dev/null +++ b/src/struphy/propagators/vlasov_maxwell_coupling.py @@ -0,0 +1,466 @@ +"Particle and FEEC variables are updated." + +import logging +from dataclasses import dataclass + +import cunumpy as xp +from feectools.linalg.solvers import inverse +from line_profiler import profile +from maybempi import MPI + +from struphy.feec import preconditioner +from struphy.io.options import LiteralOptions, OptionsBase +from struphy.linear_algebra.solver import SolverParameters +from struphy.models.variables import FEECVariable, PICVariable +from struphy.pic.accumulation.kernels.vlasov_maxwell import vlasov_maxwell +from struphy.pic.accumulation.particles_to_grid import Accumulator +from struphy.pic.pushing.kernels.push_v_with_efield import push_v_with_efield +from struphy.pic.pushing.pusher import Pusher +from struphy.propagators.base import Propagator +from struphy.utils.utils import check_option + +logger = logging.getLogger("struphy") + + +class VlasovMaxwellCoupling(Propagator): + r"""PIC-FEEC discretization of the following equations: + find :math:`\mathbf E \in H(\textnormal{curl})`, :math:`\mathbf B \in H(\textnormal{div})` and :math:`f` such that + + .. math:: + + \kappa \int_{\Omega} \frac{\partial \mathbf E}{\partial t} \cdot \mathbf F \,\textrm d \mathbf x + &= c_1 \int_{\Omega} \mathbf B \cdot \nabla \times \mathbf F \,\textrm d \mathbf x + - c_2 \int_{\Omega} \int_{\mathbb{R}^3} f \mathbf{v} \cdot \mathbf{F} \, \text{d}^3 \mathbf{v} \,\textrm d \mathbf x + \qquad \forall \, \mathbf F \in H(\textnormal{curl})\,, + \\[2mm] + \frac{\partial \mathbf B}{\partial t} + \nabla\times\mathbf E &= 0\,, + \\[2mm] + \frac{\partial f}{\partial t} + c_3\, \mathbf{E} \cdot \frac{\partial f}{\partial \mathbf{v}} &= 0\,, + + with :math:`\kappa \geq 0`, :math:`c_1 > 0` and :math:`c_2 c_3 > 0`. It solves the steps of + :class:`~struphy.propagators.maxwell_weak_ampere.MaxwellWeakAmpere` and + :class:`~struphy.propagators.vlasov_ampere_coupling.VlasovAmpereCoupling` in one implicit step (for + :math:`\kappa = 1`, :math:`c_1 = 1`, :math:`c_2 = \alpha^2/\varepsilon`, :math:`c_3 = 1/\varepsilon`). + For :math:`\kappa = 0` the displacement current vanishes, :math:`c_1 \nabla \times \mathbf B = c_2 \mathbf J` + (quasi-neutral limit). + + With the FE coefficients :math:`\mathbf e \in \mathbb R^{N_1}` of :math:`\mathbf E \in V^1_h` and + :math:`\mathbf b \in \mathbb R^{N_2}` of :math:`\mathbf B \in V^2_h`, and :math:`f` approximated by :math:`N` + markers with positions :math:`\boldsymbol \eta_p` (logical, fixed in this step), velocities + :math:`\mathbf V = (\mathbf v_p)_p \in \mathbb R^{3N}` and weights :math:`\mathbb W = \textnormal{diag}(w_p)`, + the semi-discrete system reads + + .. math:: + + \kappa\, \mathbb M_1 \dot{\mathbf e} = c_1\, \mathbb C^\top \mathbb M_2 \mathbf b + - c_2\, (\mathbf \Lambda^1)^\top \, \overline{DF^{-1}} \, \mathbb W \, \mathbf V \,, + \qquad + \dot{\mathbf b} = - \mathbb C \mathbf e\,, + \qquad + \dot{\mathbf V} = c_3\, \overline{DF^{-\top}} \, \mathbf \Lambda^1 \, \mathbf e\,, + + with the notation of :class:`~struphy.propagators.vlasov_ampere_coupling.VlasovAmpereCoupling`. It conserves the + energy + + .. math:: + + H = \frac{\kappa}{2}\, \mathbf e^\top \mathbb M_1 \mathbf e + \frac{c_1}{2}\, \mathbf b^\top \mathbb M_2 \mathbf b + + \frac{c_2}{2 c_3}\, \mathbf V^\top \mathbb W \mathbf V + \qquad (:meth:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling.energy`)\,. + + :ref:`time_discret`: Crank-Nicolson (implicit mid-point), with + :math:`\mathbf e^\textnormal{mid} = \frac 12 (\mathbf e^{n+1} + \mathbf e^n)` etc., + + .. math:: + + \kappa\, \mathbb M_1 \frac{\mathbf e^{n+1} - \mathbf e^n}{\Delta t} + = c_1\, \mathbb C^\top \mathbb M_2 \mathbf b^\textnormal{mid} + - c_2\, (\mathbf \Lambda^1)^\top \, \overline{DF^{-1}} \, \mathbb W \, \mathbf V^\textnormal{mid}\,, + \qquad + \frac{\mathbf b^{n+1} - \mathbf b^n}{\Delta t} = - \mathbb C \mathbf e^\textnormal{mid}\,, + \qquad + \frac{\mathbf V^{n+1} - \mathbf V^n}{\Delta t} = c_3\, \overline{DF^{-\top}} \, \mathbf \Lambda^1 \, \mathbf e^\textnormal{mid}\,, + + which conserves :math:`H` exactly (up to the solver tolerance; for constant weights, i.e. without control + variate). Eliminating :math:`\mathbf b^\textnormal{mid}` and :math:`\mathbf V^\textnormal{mid}` gives the Schur + system for the mid-point field + + .. math:: + + \left(\kappa\, \mathbb M_1 + \frac{\Delta t^2}{4}\, \mathbb K\right) \mathbf e^\textnormal{mid} + = \kappa\, \mathbb M_1 \mathbf e^n + + \frac{\Delta t}{2} \left( c_1\, \mathbb C^\top \mathbb M_2 \mathbf b^n - c_2\, \mathbf j \right)\,, + \qquad + \mathbb K = c_1\, \mathbb C^\top \mathbb M_2 \mathbb C + c_2 c_3\, \mathbb A\,, + + with the accumulated matrix :math:`\mathbb A` and vector :math:`\mathbf j` of + :class:`~struphy.propagators.vlasov_ampere_coupling.VlasovAmpereCoupling` (kernel + :func:`~struphy.pic.accumulation.kernels.vlasov_maxwell.vlasov_maxwell_kernels.vlasov_maxwell`). It is solved + with ``solver`` and ``precond``. Then + + .. math:: + + \mathbf b^{n+1} = \mathbf b^n - \Delta t\, \mathbb C \mathbf e^\textnormal{mid}\,, + \qquad + \mathbf e^{n+1} = 2 \mathbf e^\textnormal{mid} - \mathbf e^n \quad (\kappa > 0)\,, + + and the velocities are updated by + :func:`~struphy.pic.pushing.kernels.push_v_with_efield.push_v_with_efield_kernels.push_v_with_efield` with + :math:`\mathbf e^\textnormal{mid}`. + + **Quasi-neutral limit** :math:`\kappa = 0`. The curl-curl operator :math:`\mathbb C^\top \mathbb M_2 \mathbb C` + is singular on the discrete gradients (and harmonic fields); this kernel is absorbed by the plasma term + :math:`c_2 c_3 \mathbb A`, a particle approximation of the density-weighted mass matrix :math:`\mathbb M_1`. + The Schur operator is therefore positive definite for positive weights (full-f, no control variate) and plasma + everywhere in the domain. The scheme then imposes + :math:`c_1\, \mathbb C^\top \mathbb M_2 \mathbf b^\textnormal{mid} = c_2\, (\mathbf \Lambda^1)^\top + \overline{DF^{-1}} \mathbb W \mathbf V^\textnormal{mid}`, hence a weakly divergence-free mid-point current + (multiply by :math:`\mathbb G^\top`); the gradient part of :math:`\mathbf E` is its Lagrange multiplier. + :math:`\mathbf e^n` drops out of the system and only :math:`\mathbf e^\textnormal{mid}` is determined: the + propagator stores :math:`\mathbf e^{n+1} = \mathbf e^\textnormal{mid}` (the Crank-Nicolson update + :math:`2 \mathbf e^\textnormal{mid} - \mathbf e^n` would carry any error of :math:`\mathbf e^n` as an undamped + even-odd oscillation). The energy :math:`H` (without the electric part) is still conserved exactly. + + **Preconditioner.** With :math:`\mathbb A \approx \bar n\, \mathbb M_1`, the Schur operator is approximately the + stabilized curl-curl operator + :math:`\mathbb C^\top \left(\frac{\Delta t^2 c_1}{4} \mathbb M_2\right) \mathbb C + + \left(\kappa + \frac{\Delta t^2 c_2 c_3 \bar n}{4}\right) \mathbb M_1`, preconditioned (default) by + :class:`~struphy.feec.preconditioner.StiffnessPreconditioner`, built for the current time step + (:meth:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling.prepare_time_step`). Its + stabilization stays positive for :math:`\kappa = 0`. + """ + + class Variables: + """Container for variables advanced by :class:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling`. + + Attributes + ---------- + e : FEECVariable + Electric-field variable in ``"Hcurl"`` space. + b : FEECVariable + Magnetic-field variable in ``"Hdiv"`` space. + ions : PICVariable + Particle variable in ``"Particles6D"`` space. + """ + + def __init__(self): + self._e: FEECVariable = None + self._b: FEECVariable = None + self._ions: PICVariable = None + + @property + def e(self) -> FEECVariable: + return self._e + + @e.setter + def e(self, new): + assert isinstance(new, FEECVariable) + assert new.space == "Hcurl" + self._e = new + + @property + def b(self) -> FEECVariable: + return self._b + + @b.setter + def b(self, new): + assert isinstance(new, FEECVariable) + assert new.space == "Hdiv" + self._b = new + + @property + def ions(self) -> PICVariable: + return self._ions + + @ions.setter + def ions(self, new): + assert isinstance(new, PICVariable) + assert new.space == "Particles6D" + self._ions = new + + def __init__(self): + self.variables = self.Variables() + + @dataclass(repr=False) + class Options(OptionsBase): + r"""Configuration options for :class:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling`. + + Parameters + ---------- + kappa : float, default=1.0 + Coefficient :math:`\kappa \geq 0` of the displacement current :math:`\partial_t \mathbf E`; + ``kappa=0.0`` is the quasi-neutral limit (needs full-f weights, no control variate). + + c1 : float, default=1.0 + Coefficient :math:`c_1 > 0` of :math:`\nabla \times \mathbf B` in Ampère's law. + + c2 : float, default=None + Coefficient of the current density in Ampère's law. If ``None``, :math:`\alpha^2/\varepsilon` of the + species. + + c3 : float, default=None + Coefficient of the electric force in the Vlasov equation (:math:`c_2 c_3 > 0`). If ``None``, + :math:`1/\varepsilon` of the species. + + solver : LiteralOptions.OptsSymmSolver, default="pcg" + Symmetric iterative solver of the Schur system, passed to ``feectools.linalg.solvers.inverse``. + + precond : LiteralOptions.OptsMaxwellPrecond, default="StiffnessPreconditioner" + Preconditioner of the Schur system. + + - ``"StiffnessPreconditioner"``: :class:`~struphy.feec.preconditioner.StiffnessPreconditioner` with + ``derivative="curl"`` for the stabilized curl-curl approximation of the Schur operator (see the class + docstring), rebuilt when the time step changes. + - ``"MassMatrixPreconditioner"``, ``"PolarMassMatrixPreconditioner"``: preconditioner of + :math:`\mathbb M_1`, sufficient for :math:`\Delta t \lesssim h`. + - ``None``: no preconditioner (set automatically, with a warning, for ``solver="cg"``). + + solver_params : SolverParameters, default=None + Solver controls. If ``None``, defaults to ``SolverParameters()``. + + n_ref : float, default=None + Reference density :math:`\bar n` in the stiffness preconditioner. If ``None``, the Rayleigh quotient + :math:`\mathbf 1^\top \mathbb A \mathbf 1 / \mathbf 1^\top \mathbb M_1 \mathbf 1` of the accumulated + matrix at the setup (a density average). + """ + + kappa: float = 1.0 + c1: float = 1.0 + c2: float = None + c3: float = None + solver: LiteralOptions.OptsSymmSolver = "pcg" + precond: LiteralOptions.OptsMaxwellPrecond = "StiffnessPreconditioner" + solver_params: SolverParameters = None + n_ref: float = None + + def __post_init__(self): + # checks + check_option(self.solver, LiteralOptions.OptsSymmSolver) + check_option(self.precond, LiteralOptions.OptsMaxwellPrecond) + assert self.kappa >= 0.0, f"kappa must be >= 0, got {self.kappa}." + assert self.c1 > 0.0, f"c1 must be > 0, got {self.c1}." + if self.c2 is not None and self.c3 is not None: + assert self.c2 * self.c3 > 0.0, f"c2 * c3 must be > 0, got c2={self.c2}, c3={self.c3}." + if self.n_ref is not None: + assert self.n_ref > 0.0, f"n_ref must be > 0, got {self.n_ref}." + if self.solver == "cg" and self.precond is not None: + logger.warning(f'solver="cg" takes no preconditioner; ignoring precond="{self.precond}".') + self.precond = None + + # defaults + if self.solver_params is None: + self.solver_params = SolverParameters() + + @property + def options(self) -> Options: + if not hasattr(self, "_options"): + self._options = self.Options() + return self._options + + @options.setter + def options(self, new): + assert isinstance(new, self.Options) + self._options = new + logger.info(f"\nNew options for propagator '{self.__class__.__name__}':\n{self._options}") + + @profile + def allocate(self): + # coefficients + equation_params = self.variables.ions.species.equation_params + alpha = equation_params.alpha + epsilon = equation_params.epsilon + self._kappa = self.options.kappa + self._c1 = self.options.c1 + self._c2 = alpha**2 / epsilon if self.options.c2 is None else self.options.c2 + self._c3 = 1.0 / epsilon if self.options.c3 is None else self.options.c3 + assert self._c2 * self._c3 > 0.0, f"c2 * c3 must be > 0, got c2={self._c2}, c3={self._c3}." + + particles = self.variables.ions.particles + if particles.control_variate: + assert self._kappa > 0.0, ( + "The quasi-neutral limit kappa=0 needs full-f weights: with the control variate the accumulated " + "matrix is indefinite and the Schur system is not well-posed." + ) + logger.warning( + "VlasovMaxwellCoupling with control variate: the energy is not conserved exactly (the weights change)." + ) + + self._info = self.options.solver_params.info + + # accumulation of the matrix A and the vector j + self._accum = Accumulator( + particles, + "Hcurl", + vlasov_maxwell, + self.mass_ops, + self.domain.args_domain, + add_vector=True, + symmetry="symm", + ) + + # operators + self._M1 = self.mass_ops.M1 + self._M2 = self.mass_ops.M2 + self._curl = self.derham.curl + self._curl_curl = self._curl.T @ self._M2 @ self._curl + + # reference density for the stiffness preconditioner: density average of the accumulated matrix + if self.options.n_ref is None: + self._accum() + ones = self.derham.V1.zeros() + for block in ones.blocks: + block._data[:] = 1.0 + ones_A = ones.inner(self._accum.operators[0].dot(ones)) + ones_M1 = ones.inner(self._M1.dot(ones)) + if ones_A > 0.0: + self._n_ref = ones_A / ones_M1 + else: + self._n_ref = 1.0 + logger.warning("VlasovMaxwellCoupling: no positive density average of the markers, using n_ref=1.") + else: + self._n_ref = self.options.n_ref + + # preconditioner (the stiffness preconditioner depends on dt and is built in prepare_time_step) + self._precond = self.options.precond + if self._precond in (None, "StiffnessPreconditioner"): + self._mass_pc = None + else: + pc_class = getattr(preconditioner, self._precond) + self._mass_pc = pc_class(self._M1) + + # solver kwargs (the Schur operator and its solver are built in prepare_time_step) + kwargs = self.options.solver_params.__dict__.copy() + kwargs.pop("info") + self._solver_kwargs = kwargs + self._solver = None + self._schur_dt = None + + # buffers (the pusher reads e_mid) + self._e_mid = self.derham.V1.zeros() + self._e_new = self.derham.V1.zeros() + self._e_rhs = self.derham.V1.zeros() + self._e_tmp = self.derham.V1.zeros() + self._b_new = self.derham.V2.zeros() + self._b_tmp = self.derham.V2.zeros() + + # particle pusher: v^{n+1} = v^n + dt * c3 * DF^{-T} Lambda^1 e_mid + args_kernel = ( + self.derham.args_derham, + self._e_mid.blocks[0]._data, + self._e_mid.blocks[1]._data, + self._e_mid.blocks[2]._data, + self._c3, + ) + + self._pusher = Pusher( + particles, + push_v_with_efield, + args_kernel, + self.domain.args_domain, + alpha_in_kernel=1.0, + pushes_eta=False, + ) + + def prepare_time_step(self, dt: float): + """Schur operator :math:`\\kappa \\mathbb M_1 + \\frac{\\Delta t^2}{4} \\mathbb K`, its preconditioner and + solver for the time step ``dt``: built in the setup, rebuilt when dt changes. The accumulated matrix enters by + reference, so it is updated by the accumulation in every step.""" + if dt == self._schur_dt: + return + + schur = (dt**2 * self._c1 / 4) * self._curl_curl + (dt**2 * self._c2 * self._c3 / 4) * self._accum.operators[0] + if self._kappa > 0.0: + schur = self._kappa * self._M1 + schur + + if self._precond == "StiffnessPreconditioner": + pc = preconditioner.StiffnessPreconditioner( + self.mass_ops, + "curl", + sigma=self._kappa + dt**2 * self._c2 * self._c3 * self._n_ref / 4, + mid=(dt**2 * self._c1 / 4) * self._M2, + stab="M1", + ) + else: + pc = self._mass_pc + + kwargs = dict(self._solver_kwargs) + if pc is not None: + kwargs["pc"] = pc + self._solver = inverse(schur, self.options.solver, **kwargs) + self._schur_dt = dt + + def energy(self) -> float: + r"""The energy :math:`H = \frac{\kappa}{2} \mathbf e^\top \mathbb M_1 \mathbf e + \frac{c_1}{2} \mathbf b^\top + \mathbb M_2 \mathbf b + \frac{c_2}{2 c_3} \mathbf V^\top \mathbb W \mathbf V` conserved by the propagator + (summed over all MPI ranks; a collective operation).""" + e = self.variables.e.spline.vector + b = self.variables.b.spline.vector + particles = self.variables.ions.particles + + kinetic = xp.array([0.5 * xp.sum(particles.weights * xp.sum(particles.velocities**2, axis=0))]) + if self.derham.comm is not None: + self.derham.comm.Allreduce(MPI.IN_PLACE, kinetic, op=MPI.SUM) + if particles.clone_config is not None: + particles.clone_config.inter_comm.Allreduce(MPI.IN_PLACE, kinetic, op=MPI.SUM) + + energy = self._c1 / 2 * b.inner(self._M2.dot(b, out=self._b_tmp)) + self._c2 / self._c3 * float(kinetic[0]) + if self._kappa > 0.0: + energy += self._kappa / 2 * e.inner(self._M1.dot(e, out=self._e_tmp)) + return energy + + @profile + def __call__(self, dt): + en = self.variables.e.spline.vector + bn = self.variables.b.spline.vector + + # accumulate A and j at the current marker positions and velocities + self._accum() + + # Schur operator for the current time step (no-op unless dt changed since the setup) + self.prepare_time_step(dt) + + # rhs = kappa M1 e^n + dt/2 (c1 C^T M2 b^n - c2 j) + rhs = self._curl.T.dot(self._M2.dot(bn, out=self._b_tmp), out=self._e_rhs) + rhs *= self._c1 + j = self._accum.vectors[0] + j.copy(out=self._e_tmp) + self._e_tmp *= self._c2 + rhs -= self._e_tmp + rhs *= dt / 2 + if self._kappa > 0.0: + self._M1.dot(en, out=self._e_tmp) + self._e_tmp *= self._kappa + rhs += self._e_tmp + + # mid-point field; the pusher kernel reads its ghost regions + e_mid = self._solver.dot(rhs, out=self._e_mid) + info = self._solver._info + e_mid.update_ghost_regions() + + # new b coeffs: b^{n+1} = b^n - dt C e_mid + b_new = self._curl.dot(e_mid, out=self._b_new) + b_new *= -dt + b_new += bn + + # new e coeffs: Crank-Nicolson for kappa > 0, the mid-point field for kappa = 0 + e_new = e_mid.copy(out=self._e_new) + if self._kappa > 0.0: + e_new *= 2.0 + e_new -= en + + # update velocities with e_mid + self._pusher(dt) + + # update weights + if self.variables.ions.species.weights_params.control_variate: + self.variables.ions.particles.update_weights() + + # write new coeffs into self.variables + diffs = self.update_feec_variables(e=e_new, b=b_new) + + if self._info and MPI.COMM_WORLD.Get_rank() == 0: + logger.info(f"Status for VlasovMaxwellCoupling: {info['success']}") + logger.info(f"Iterations for VlasovMaxwellCoupling: {info['niter']}") + logger.info(f"Maxdiff e for VlasovMaxwellCoupling: {diffs['e']}") + logger.info(f"Maxdiff b for VlasovMaxwellCoupling: {diffs['b']}") + logger.info("") From 70d2e42987fd30ea71caea14e0f61800f9462d19 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 10:29:34 +0200 Subject: [PATCH 08/23] Review fixes: reject polar splines, test the discrete equations with general coefficients - assert no polar splines in VlasovMaxwellCoupling (the V1 buffers are tensor-product) - test_discrete_equations: residual of the CN Ampere and Faraday laws with c1, c2, c3 all different (catches wrong coefficients in the Schur system or the pusher) - energy test also with explicit coefficients; test the solver rebuild on dt change with a given n_ref; drop the tautological e^n-independence test Co-Authored-By: Claude Opus 5.5 --- .../tests/test_vlasov_maxwell_coupling.py | 98 +++++++++++-------- .../propagators/vlasov_maxwell_coupling.py | 5 +- 2 files changed, 63 insertions(+), 40 deletions(-) diff --git a/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py b/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py index c9afcf912..12d536b5d 100644 --- a/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py +++ b/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py @@ -78,12 +78,17 @@ def _vnorm(v): return float(val[0]) ** 0.5 +# explicit coefficients with c1 != |c2| != |c3| (the species ones are c1 = 1, c2 = c3 = -1 here) +COEFFS = {"species": {}, "explicit": {"c1": 2.0, "c2": -0.5, "c3": -3.0}} + + +@pytest.mark.parametrize("coeffs", list(COEFFS)) @pytest.mark.parametrize("kappa", [1.0, 0.0]) @pytest.mark.parametrize("precond", ["StiffnessPreconditioner", "MassMatrixPreconditioner"]) -def test_energy_conservation(kappa, precond): +def test_energy_conservation(kappa, precond, coeffs): """The coupling step conserves its energy H exactly (up to the solver tolerance), also for dt >> 1/omega_p and in the quasi-neutral limit kappa=0.""" - model = _model(kappa=kappa, precond=precond) + model = _model(kappa=kappa, precond=precond, **COEFFS[coeffs]) prop = model.propagators.coupling b = model.em_fields.b_field.spline.vector energy_0 = prop.energy() @@ -93,10 +98,58 @@ def test_energy_conservation(kappa, precond): assert prop._solver._info["success"] # H is dominated by the thermal energy: compare its change also with the energy exchanged with the B field exchanged = abs(0.5 * b.inner(prop._M2.dot(b)) - energy_b_0) - assert exchanged > 10 * energy_b_0 + assert exchanged > energy_b_0 assert abs(prop.energy() - energy_0) < 1e-9 * exchanged +@pytest.mark.parametrize("kappa", [1.0, 0.0]) +def test_discrete_equations(kappa): + """The new state satisfies the Crank-Nicolson equations: Ampère's law + kappa M1 (e^{n+1} - e^n) / dt = c1 C^T M2 b_mid - c2 j(V_mid) (with V^{n+1} from the pusher, j linear in V at + fixed positions) and Faraday's law (b^{n+1} - b^n) / dt = -C e_mid, with e_mid = e^{n+1} for kappa = 0.""" + dt = 0.5 + model = _model(kappa=kappa, **COEFFS["explicit"]) + prop = model.propagators.coupling + e0, b0, _ = _state(model) + prop._accum() + j0 = prop._accum.vectors[0].copy() + prop(dt) + e1, b1, _ = _state(model) + prop._accum() + j_mid = (j0 + prop._accum.vectors[0]) * 0.5 + b_mid = (b0 + b1) * 0.5 + e_mid = (e0 + e1) * 0.5 if kappa > 0.0 else e1 + + curl_b = prop._curl.T.dot(prop._M2.dot(b_mid)) * prop._c1 + current = j_mid * prop._c2 + residual = curl_b - current - prop._M1.dot(e1 - e0) * (kappa / dt) + assert _norm(residual) < 1e-8 * (_norm(curl_b) + _norm(current)) + + faraday = (b1 - b0) * (1 / dt) + prop._curl.dot(e_mid) + assert _norm(faraday) < 1e-10 * _norm(b1 - b0) / dt + + if kappa == 0.0: + # weakly divergence-free mid-point current + div_j = prop.derham.grad.T.dot(j_mid) + assert _norm(div_j) < 1e-8 * _norm(j_mid) + + +def test_dt_change(): + """The Schur solver and its preconditioner are rebuilt only when dt changes (here with a given n_ref, so the + Schur operator is built before any accumulation); the energy stays conserved across the change.""" + model = _model(n_ref=1.0) + prop = model.propagators.coupling + energy_0 = prop.energy() + prop(0.5) + solver = prop._solver + prop(0.5) + assert prop._solver is solver + prop(1.0) + assert prop._solver is not solver + assert prop._schur_dt == 1.0 + assert abs(prop.energy() - energy_0) < 1e-12 * energy_0 + + def test_agrees_with_split_propagators(): """For kappa=1 and the species coefficients, one step agrees with MaxwellWeakAmpere followed by VlasovAmpereCoupling up to the splitting error O(dt^2).""" @@ -119,60 +172,27 @@ def test_agrees_with_split_propagators(): def test_quasi_neutral_limit(): - """b and V converge like kappa to the kappa=0 solution, and at kappa=0 the displacement-free Ampère law - c1 C^T M2 b_mid = c2 j_mid holds; the iteration counts stay bounded as kappa -> 0.""" + """b and V converge like kappa to the kappa=0 solution; the iteration counts stay bounded as kappa -> 0.""" dt = 0.5 results = {} niter = {} for kappa in (1e-2, 1e-4, 0.0): model = _model(kappa=kappa) prop = model.propagators.coupling - e0, b0, v0 = _state(model) - prop._accum() - j0 = prop._accum.vectors[0].copy() prop(dt) niter[kappa] = prop._solver._info["niter"] - e1, b1, v1 = _state(model) + _, b1, v1 = _state(model) results[kappa] = (b1.toarray(), v1) - if kappa == 0.0: - # j is linear in V at fixed positions: j_mid = (j(V^n) + j(V^{n+1})) / 2 - prop._accum() - j_mid = (j0 + prop._accum.vectors[0]) * 0.5 - b_mid = (b0 + b1) * 0.5 - curl_b = prop._curl.T.dot(prop._M2.dot(b_mid)) * prop._c1 - residual = curl_b - j_mid * prop._c2 - assert _norm(residual) < 1e-8 * _norm(curl_b) - # weakly divergence-free mid-point current - div_j = prop.derham.grad.T.dot(j_mid) - assert _norm(div_j) < 1e-8 * _norm(j_mid) - b_ref, v_ref = results[0.0] errs = [_vnorm(results[k][0] - b_ref) + _vnorm(results[k][1] - v_ref) for k in (1e-2, 1e-4)] assert errs[1] < 3e-2 * errs[0], errs assert max(niter.values()) <= 2 * min(niter.values()), niter -def test_quasi_neutral_independent_of_e0(): - """For kappa=0, the electric field at the beginning of the step does not enter: b, V and the stored - e = e_mid are the same for a different e^n.""" - dt = 0.5 - out = [] - for scale in (1.0, -3.0): - model = _model(kappa=0.0) - model.em_fields.e_field.spline.vector *= scale - model.em_fields.e_field.spline.vector.update_ghost_regions() - model.propagators.coupling(dt) - out.append(_state(model)) - for x, y in zip(out[0], out[1]): - x = x if isinstance(x, xp.ndarray) else x.toarray() - y = y if isinstance(y, xp.ndarray) else y.toarray() - assert _vnorm(x - y) < 1e-8 * _vnorm(x) - - def test_control_variate_quasi_neutral_rejected(): """The quasi-neutral limit needs full-f weights.""" - with pytest.raises(AssertionError): + with pytest.raises(AssertionError, match="quasi-neutral"): _model(control_variate=True, kappa=0.0) diff --git a/src/struphy/propagators/vlasov_maxwell_coupling.py b/src/struphy/propagators/vlasov_maxwell_coupling.py index 6e5e4e9dd..7a9610444 100644 --- a/src/struphy/propagators/vlasov_maxwell_coupling.py +++ b/src/struphy/propagators/vlasov_maxwell_coupling.py @@ -212,7 +212,8 @@ class Options(OptionsBase): ``derivative="curl"`` for the stabilized curl-curl approximation of the Schur operator (see the class docstring), rebuilt when the time step changes. - ``"MassMatrixPreconditioner"``, ``"PolarMassMatrixPreconditioner"``: preconditioner of - :math:`\mathbb M_1`, sufficient for :math:`\Delta t \lesssim h`. + :math:`\mathbb M_1`, sufficient for :math:`\Delta t \lesssim h` (polar splines are not supported by + this propagator yet). - ``None``: no preconditioner (set automatically, with a warning, for ``solver="cg"``). solver_params : SolverParameters, default=None @@ -275,6 +276,8 @@ def allocate(self): self._c3 = 1.0 / epsilon if self.options.c3 is None else self.options.c3 assert self._c2 * self._c3 > 0.0, f"c2 * c3 must be > 0, got c2={self._c2}, c3={self._c3}." + assert not self.derham.polar_splines, "VlasovMaxwellCoupling does not support polar splines yet." + particles = self.variables.ions.particles if particles.control_variate: assert self._kappa > 0.0, ( From b788139febbd499ad546ecac6162ee5087906344 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 10:33:13 +0200 Subject: [PATCH 09/23] Weibel quasi-neutral example: measured growth rates in the description Co-Authored-By: Claude Opus 5.5 --- .../weibel_quasi_neutral/params_weibel_quasi_neutral.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py index 01c563885..8867f1db1 100644 --- a/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py +++ b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py @@ -60,7 +60,10 @@ Expected result. For k = 1.25 the dispersion relation gives gamma = 0.02784 for both kappa = 1 and kappa = 0 (the growth rate of the amplitude of B3; the magnetic energy grows as exp(2 gamma t)). The energy en_H conserved by the coupling step changes only by the splitting with the particle pushers. The pproc fits gamma to the magnetic energy -(between t = Tend/5 and 2 Tend/5), compares with the analytical value and plots the energies. +(between t = Tend/5 and 2 Tend/5), compares with the analytical value and plots the energies. Measured with this +setup (serial, 2026-10-23): gamma = 0.0249 for kappa = 1 and kappa = 0 (they agree to 0.2 %), 10 % below the +analytical value; with dt = 0.25 gamma = 0.0253, so the deviation is mostly due to the marker noise and resolution, +not to the large time step. The relative change of en_H stays below 2e-9. References: - E. S. Weibel, "Spontaneously growing transverse waves in a plasma due to an anisotropic velocity distribution", From 6fed2c5a943aa289d399c3fa662882e16dccdb45 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 11:10:20 +0200 Subject: [PATCH 10/23] VlasovMaxwellCoupling: matrix_free option (MarkerOperator), as in the other 6D couplings With matrix_free=True the vector j and the fillings w_p G^-1 per marker are accumulated by vlasov_maxwell_mf (AccumulatorVector) and the particle matrix in the Schur operator is a MarkerOperator (marker_operator_symm_dot). Tests: same steps as assembled (test_matrix_free_6d.py, kappa = 1 and 0), discrete equations with both variants. Co-Authored-By: Claude Opus 5.5 --- .../propagators/tests/test_matrix_free_6d.py | 38 ++++++++++ .../tests/test_vlasov_maxwell_coupling.py | 12 ++- .../propagators/vlasov_maxwell_coupling.py | 74 ++++++++++++++----- 3 files changed, 102 insertions(+), 22 deletions(-) diff --git a/src/struphy/propagators/tests/test_matrix_free_6d.py b/src/struphy/propagators/tests/test_matrix_free_6d.py index 267c37362..d8eebc414 100644 --- a/src/struphy/propagators/tests/test_matrix_free_6d.py +++ b/src/struphy/propagators/tests/test_matrix_free_6d.py @@ -64,6 +64,44 @@ def run(matrix_free: bool): _compare(run) +@pytest.mark.parametrize("kappa", [1.0, 0.0]) +def test_vlasov_maxwell_coupling_matrix_free(kappa: float): + """VlasovMaxwellCoupling: e-field, b-field and velocities after two steps (in a curved domain), with and without + displacement current.""" + from struphy import DerhamOptions, LoadingParameters, Simulation, domains, grids, maxwellians, perturbations + from struphy.models import VlasovMaxwellQuasiNeutral + + def run(matrix_free: bool): + model = VlasovMaxwellQuasiNeutral(alpha=1.0, epsilon=-1.0) + model.kinetic_ions.set_markers(loading_params=LoadingParameters(Np=1000, seed=1234)) + model.kinetic_ions.var.add_background(maxwellians.Maxwellian3D()) + model.em_fields.b_field.add_perturbation( + perturbations.ModesSin(ls=(1,), ms=(1,), amps=(1e-2,), given_in_basis="2", comp=2) + ) + prop = model.propagators.coupling + prop.options = prop.Options(kappa=kappa, matrix_free=matrix_free, solver_params=_solver_params()) + Simulation( + model=model, + domain=domains.Colella(alpha=0.1), + grid=grids.TensorProductGrid(num_elements=(6, 4, 2)), + derham_opts=DerhamOptions(degree=(2, 2, 1)), + ).allocate() + particles = model.kinetic_ions.var.particles + # one step for kappa=0: the initial data violate curl B = J, and the residual of this constraint changes sign + # in every step, so b and V return to their initial values after two steps (period-2 oscillation) + return _steps( + prop, + lambda: ( + model.em_fields.e_field.spline.vector.toarray(), + model.em_fields.b_field.spline.vector.toarray(), + particles.markers[3:6, particles.valid_mks], + ), + n=2 if kappa > 0.0 else 1, + ) + + _compare(run) + + def test_efield_weights_coupling_matrix_free(): """EfieldWeightsCoupling: e-field and weights after two steps (in a curved domain).""" from struphy import DerhamOptions, LoadingParameters, Simulation, domains, grids, maxwellians, perturbations diff --git a/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py b/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py index 12d536b5d..32295f216 100644 --- a/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py +++ b/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py @@ -102,20 +102,21 @@ def test_energy_conservation(kappa, precond, coeffs): assert abs(prop.energy() - energy_0) < 1e-9 * exchanged +@pytest.mark.parametrize("matrix_free", [False, True]) @pytest.mark.parametrize("kappa", [1.0, 0.0]) -def test_discrete_equations(kappa): +def test_discrete_equations(kappa, matrix_free): """The new state satisfies the Crank-Nicolson equations: Ampère's law kappa M1 (e^{n+1} - e^n) / dt = c1 C^T M2 b_mid - c2 j(V_mid) (with V^{n+1} from the pusher, j linear in V at fixed positions) and Faraday's law (b^{n+1} - b^n) / dt = -C e_mid, with e_mid = e^{n+1} for kappa = 0.""" dt = 0.5 - model = _model(kappa=kappa, **COEFFS["explicit"]) + model = _model(kappa=kappa, matrix_free=matrix_free, **COEFFS["explicit"]) prop = model.propagators.coupling e0, b0, _ = _state(model) - prop._accum() + prop._accum(*prop._args_accum) j0 = prop._accum.vectors[0].copy() prop(dt) e1, b1, _ = _state(model) - prop._accum() + prop._accum(*prop._args_accum) j_mid = (j0 + prop._accum.vectors[0]) * 0.5 b_mid = (b0 + b1) * 0.5 e_mid = (e0 + e1) * 0.5 if kappa > 0.0 else e1 @@ -210,5 +211,8 @@ def test_options(): Options(c2=1.0, c3=-1.0) with pytest.raises(AssertionError): Options(precond="HiptmairXu") + with pytest.raises(AssertionError): + Options(matrix_free=1) + assert Options().matrix_free is False assert Options().precond == "StiffnessPreconditioner" assert Options(solver="cg").precond is None diff --git a/src/struphy/propagators/vlasov_maxwell_coupling.py b/src/struphy/propagators/vlasov_maxwell_coupling.py index 7a9610444..f698a4eb3 100644 --- a/src/struphy/propagators/vlasov_maxwell_coupling.py +++ b/src/struphy/propagators/vlasov_maxwell_coupling.py @@ -12,8 +12,10 @@ from struphy.io.options import LiteralOptions, OptionsBase from struphy.linear_algebra.solver import SolverParameters from struphy.models.variables import FEECVariable, PICVariable +from struphy.pic.accumulation.kernels.marker_operator_symm_dot import marker_operator_symm_dot from struphy.pic.accumulation.kernels.vlasov_maxwell import vlasov_maxwell -from struphy.pic.accumulation.particles_to_grid import Accumulator +from struphy.pic.accumulation.kernels.vlasov_maxwell_mf import vlasov_maxwell_mf +from struphy.pic.accumulation.particles_to_grid import Accumulator, AccumulatorVector, MarkerOperator from struphy.pic.pushing.kernels.push_v_with_efield import push_v_with_efield from struphy.pic.pushing.pusher import Pusher from struphy.propagators.base import Propagator @@ -223,6 +225,13 @@ class Options(OptionsBase): Reference density :math:`\bar n` in the stiffness preconditioner. If ``None``, the Rayleigh quotient :math:`\mathbf 1^\top \mathbb A \mathbf 1 / \mathbf 1^\top \mathbb M_1 \mathbf 1` of the accumulated matrix at the setup (a density average). + + matrix_free : bool, default=False + Apply the particle matrix :math:`\mathbb A` matrix-free, with the filling + :math:`w_p G^{-1}(\boldsymbol \eta_p)` stored per marker + (:class:`~struphy.pic.accumulation.particles_to_grid.MarkerOperator`), instead of assembling it: one spline + evaluation and one deposition per marker and solver iteration instead of the products of all pairs of + basis functions per marker. Needs 6 auxiliary marker columns. Ignored on the CuPy backend. """ kappa: float = 1.0 @@ -233,6 +242,7 @@ class Options(OptionsBase): precond: LiteralOptions.OptsMaxwellPrecond = "StiffnessPreconditioner" solver_params: SolverParameters = None n_ref: float = None + matrix_free: bool = False def __post_init__(self): # checks @@ -244,6 +254,7 @@ def __post_init__(self): assert self.c2 * self.c3 > 0.0, f"c2 * c3 must be > 0, got c2={self.c2}, c3={self.c3}." if self.n_ref is not None: assert self.n_ref > 0.0, f"n_ref must be > 0, got {self.n_ref}." + assert isinstance(self.matrix_free, bool) if self.solver == "cg" and self.precond is not None: logger.warning(f'solver="cg" takes no preconditioner; ignoring precond="{self.precond}".') self.precond = None @@ -290,16 +301,42 @@ def allocate(self): self._info = self.options.solver_params.info - # accumulation of the matrix A and the vector j - self._accum = Accumulator( - particles, - "Hcurl", - vlasov_maxwell, - self.mass_ops, - self.domain.args_domain, - add_vector=True, - symmetry="symm", - ) + # accumulation of the matrix A (assembled, or matrix-free) and the vector j; + # the matrix-free kernels have no CUDA version + self._matrix_free = self.options.matrix_free and xp.get_backend() != "cupy" + if self._matrix_free: + # the vector, and the filling of the matrix per marker (in the first free columns of the marker array) + column = particles.args_markers.first_free_idx + assert particles.n_cols_aux >= 6, "matrix_free needs 6 free (auxiliary) marker columns." + self._args_accum = (column,) + self._accum = AccumulatorVector( + particles, + "Hcurl", + vlasov_maxwell_mf, + self.mass_ops, + self.domain.args_domain, + ) + self._A_particles = MarkerOperator( + particles, + "Hcurl", + marker_operator_symm_dot, + self.mass_ops, + self.domain.args_domain, + (1, column), + symmetry="symm", + ) + else: + self._args_accum = () + self._accum = Accumulator( + particles, + "Hcurl", + vlasov_maxwell, + self.mass_ops, + self.domain.args_domain, + add_vector=True, + symmetry="symm", + ) + self._A_particles = self._accum.operators[0] # operators self._M1 = self.mass_ops.M1 @@ -309,11 +346,11 @@ def allocate(self): # reference density for the stiffness preconditioner: density average of the accumulated matrix if self.options.n_ref is None: - self._accum() + self._accum(*self._args_accum) ones = self.derham.V1.zeros() for block in ones.blocks: block._data[:] = 1.0 - ones_A = ones.inner(self._accum.operators[0].dot(ones)) + ones_A = ones.inner(self._A_particles.dot(ones)) ones_M1 = ones.inner(self._M1.dot(ones)) if ones_A > 0.0: self._n_ref = ones_A / ones_M1 @@ -366,12 +403,13 @@ def allocate(self): def prepare_time_step(self, dt: float): """Schur operator :math:`\\kappa \\mathbb M_1 + \\frac{\\Delta t^2}{4} \\mathbb K`, its preconditioner and - solver for the time step ``dt``: built in the setup, rebuilt when dt changes. The accumulated matrix enters by - reference, so it is updated by the accumulation in every step.""" + solver for the time step ``dt``: built in the setup, rebuilt when dt changes. The particle matrix enters by + reference (assembled, or matrix-free reading the fillings stored in the markers), so it is updated by the + accumulation in every step.""" if dt == self._schur_dt: return - schur = (dt**2 * self._c1 / 4) * self._curl_curl + (dt**2 * self._c2 * self._c3 / 4) * self._accum.operators[0] + schur = (dt**2 * self._c1 / 4) * self._curl_curl + (dt**2 * self._c2 * self._c3 / 4) * self._A_particles if self._kappa > 0.0: schur = self._kappa * self._M1 + schur @@ -416,8 +454,8 @@ def __call__(self, dt): en = self.variables.e.spline.vector bn = self.variables.b.spline.vector - # accumulate A and j at the current marker positions and velocities - self._accum() + # accumulate A (or its fillings per marker) and j at the current marker positions and velocities + self._accum(*self._args_accum) # Schur operator for the current time step (no-op unless dt changed since the setup) self.prepare_time_step(dt) From 63ca036a65d542da65e49fe2d01b59eb7ff9e0e9 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 11:14:27 +0200 Subject: [PATCH 11/23] test_matrix_free_6d: compare n_ref of the assembled and matrix-free VlasovMaxwellCoupling Co-Authored-By: Claude Opus 5.5 --- src/struphy/propagators/tests/test_matrix_free_6d.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/struphy/propagators/tests/test_matrix_free_6d.py b/src/struphy/propagators/tests/test_matrix_free_6d.py index d8eebc414..28a78e3ac 100644 --- a/src/struphy/propagators/tests/test_matrix_free_6d.py +++ b/src/struphy/propagators/tests/test_matrix_free_6d.py @@ -68,6 +68,8 @@ def run(matrix_free: bool): def test_vlasov_maxwell_coupling_matrix_free(kappa: float): """VlasovMaxwellCoupling: e-field, b-field and velocities after two steps (in a curved domain), with and without displacement current.""" + import numpy as np + from struphy import DerhamOptions, LoadingParameters, Simulation, domains, grids, maxwellians, perturbations from struphy.models import VlasovMaxwellQuasiNeutral @@ -87,9 +89,11 @@ def run(matrix_free: bool): derham_opts=DerhamOptions(degree=(2, 2, 1)), ).allocate() particles = model.kinetic_ions.var.particles + # the reference density of the preconditioner, from the assembled or the matrix-free particle matrix + n_ref = np.array([prop._n_ref]) # one step for kappa=0: the initial data violate curl B = J, and the residual of this constraint changes sign # in every step, so b and V return to their initial values after two steps (period-2 oscillation) - return _steps( + return [n_ref] + _steps( prop, lambda: ( model.em_fields.e_field.spline.vector.toarray(), From f49f63beccac1ff1926770f29b06eadea3fbf818 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 11:20:44 +0200 Subject: [PATCH 12/23] VlasovMaxwellCoupling: project the initial state onto curl B = J for kappa = 0 At kappa = 0 the constraint holds at the mid-point only, so its residual r = c1 C^T M2 b - c2 j(V) changes sign in every step: initial data that violate it (e.g. marker noise in J) oscillate with period 2 without damping. The option initial_projection (default True) replaces (b, V) once in the setup (first prepare_time_step) by the mid-point state of one step, an implicit Euler step of dt/2 that satisfies the constraint exactly and does not depend on dt. Tests: sign flip without, vanishing residual, dt independence and stationary steps with the projection; step-level tests at kappa = 0 run without it. Co-Authored-By: Claude Opus 5.5 --- .../propagators/tests/test_matrix_free_6d.py | 8 +-- .../tests/test_vlasov_maxwell_coupling.py | 60 +++++++++++++++-- .../propagators/vlasov_maxwell_coupling.py | 64 ++++++++++++++++--- 3 files changed, 114 insertions(+), 18 deletions(-) diff --git a/src/struphy/propagators/tests/test_matrix_free_6d.py b/src/struphy/propagators/tests/test_matrix_free_6d.py index 28a78e3ac..a0cbbb26d 100644 --- a/src/struphy/propagators/tests/test_matrix_free_6d.py +++ b/src/struphy/propagators/tests/test_matrix_free_6d.py @@ -91,17 +91,17 @@ def run(matrix_free: bool): particles = model.kinetic_ions.var.particles # the reference density of the preconditioner, from the assembled or the matrix-free particle matrix n_ref = np.array([prop._n_ref]) - # one step for kappa=0: the initial data violate curl B = J, and the residual of this constraint changes sign - # in every step, so b and V return to their initial values after two steps (period-2 oscillation) - return [n_ref] + _steps( + # for kappa=0 the change of b and V is the initial projection onto curl B = J; the steps then leave them + # unchanged and give e = e_mid = 0 (the initial e here), so e is not compared + changes = _steps( prop, lambda: ( model.em_fields.e_field.spline.vector.toarray(), model.em_fields.b_field.spline.vector.toarray(), particles.markers[3:6, particles.valid_mks], ), - n=2 if kappa > 0.0 else 1, ) + return [n_ref] + (changes if kappa > 0.0 else changes[1:]) _compare(run) diff --git a/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py b/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py index 32295f216..17c9a494e 100644 --- a/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py +++ b/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py @@ -87,8 +87,9 @@ def _vnorm(v): @pytest.mark.parametrize("precond", ["StiffnessPreconditioner", "MassMatrixPreconditioner"]) def test_energy_conservation(kappa, precond, coeffs): """The coupling step conserves its energy H exactly (up to the solver tolerance), also for dt >> 1/omega_p and - in the quasi-neutral limit kappa=0.""" - model = _model(kappa=kappa, precond=precond, **COEFFS[coeffs]) + in the quasi-neutral limit kappa=0 (without the initial projection, after which the kappa=0 step at frozen + positions exchanges no energy).""" + model = _model(kappa=kappa, precond=precond, initial_projection=False, **COEFFS[coeffs]) prop = model.propagators.coupling b = model.em_fields.b_field.spline.vector energy_0 = prop.energy() @@ -107,9 +108,10 @@ def test_energy_conservation(kappa, precond, coeffs): def test_discrete_equations(kappa, matrix_free): """The new state satisfies the Crank-Nicolson equations: Ampère's law kappa M1 (e^{n+1} - e^n) / dt = c1 C^T M2 b_mid - c2 j(V_mid) (with V^{n+1} from the pusher, j linear in V at - fixed positions) and Faraday's law (b^{n+1} - b^n) / dt = -C e_mid, with e_mid = e^{n+1} for kappa = 0.""" + fixed positions) and Faraday's law (b^{n+1} - b^n) / dt = -C e_mid, with e_mid = e^{n+1} for kappa = 0 (without + the initial projection, so that the step is not trivial).""" dt = 0.5 - model = _model(kappa=kappa, matrix_free=matrix_free, **COEFFS["explicit"]) + model = _model(kappa=kappa, matrix_free=matrix_free, initial_projection=False, **COEFFS["explicit"]) prop = model.propagators.coupling e0, b0, _ = _state(model) prop._accum(*prop._args_accum) @@ -173,12 +175,13 @@ def test_agrees_with_split_propagators(): def test_quasi_neutral_limit(): - """b and V converge like kappa to the kappa=0 solution; the iteration counts stay bounded as kappa -> 0.""" + """b and V converge like kappa to the kappa=0 solution (of the step without the initial projection); the + iteration counts stay bounded as kappa -> 0.""" dt = 0.5 results = {} niter = {} for kappa in (1e-2, 1e-4, 0.0): - model = _model(kappa=kappa) + model = _model(kappa=kappa, initial_projection=False) prop = model.propagators.coupling prop(dt) niter[kappa] = prop._solver._info["niter"] @@ -191,6 +194,48 @@ def test_quasi_neutral_limit(): assert max(niter.values()) <= 2 * min(niter.values()), niter +def _constraint_residual(prop): + """r = c1 C^T M2 b - c2 j(V) at the current markers.""" + b = prop.variables.b.spline.vector + prop._accum(*prop._args_accum) + return prop._curl.T.dot(prop._M2.dot(b)) * prop._c1 - prop._accum.vectors[0] * prop._c2 + + +@pytest.mark.parametrize("matrix_free", [False, True]) +def test_initial_projection(matrix_free): + """kappa=0: without the initial projection, the constraint residual r = c1 C^T M2 b - c2 j(V) changes sign in + every step (period-2 oscillation). The projection in the setup makes r vanish, does not depend on dt, and the + steps then leave b and V unchanged (frozen positions).""" + # without projection: r^{n+1} = -r^n + model = _model(kappa=0.0, initial_projection=False, matrix_free=matrix_free) + prop = model.propagators.coupling + r0 = _constraint_residual(prop) + prop(0.5) + r1 = _constraint_residual(prop) + assert _norm(r0) > 0.0 + assert _norm(r1 + r0) < 1e-10 * _norm(r0) + + # with projection (default), for two dt + projected = [] + for dt in (0.5, 2.0): + model = _model(kappa=0.0, matrix_free=matrix_free) + prop = model.propagators.coupling + j_scale = abs(prop._c2) * _norm(_constraint_residual(prop) * 0.0 + prop._accum.vectors[0]) + prop.prepare_time_step(dt) + assert _norm(_constraint_residual(prop)) < 1e-10 * j_scale + e, b, v = _state(model) + projected.append((b.toarray(), v)) + for _ in range(2): + prop(dt) + _, b2, v2 = _state(model) + assert _norm(b2 - b) < 1e-10 * _norm(b) + assert _vnorm(v2 - v) < 1e-10 * _vnorm(v) + assert _norm(_constraint_residual(prop)) < 1e-10 * j_scale + (b_a, v_a), (b_b, v_b) = projected + assert _vnorm(b_a - b_b) < 1e-10 * _vnorm(b_a) + assert _vnorm(v_a - v_b) < 1e-10 * _vnorm(v_a) + + def test_control_variate_quasi_neutral_rejected(): """The quasi-neutral limit needs full-f weights.""" with pytest.raises(AssertionError, match="quasi-neutral"): @@ -214,5 +259,8 @@ def test_options(): with pytest.raises(AssertionError): Options(matrix_free=1) assert Options().matrix_free is False + with pytest.raises(AssertionError): + Options(initial_projection=None) + assert Options().initial_projection is True assert Options().precond == "StiffnessPreconditioner" assert Options(solver="cg").precond is None diff --git a/src/struphy/propagators/vlasov_maxwell_coupling.py b/src/struphy/propagators/vlasov_maxwell_coupling.py index f698a4eb3..44df0342d 100644 --- a/src/struphy/propagators/vlasov_maxwell_coupling.py +++ b/src/struphy/propagators/vlasov_maxwell_coupling.py @@ -123,6 +123,17 @@ class VlasovMaxwellCoupling(Propagator): :math:`2 \mathbf e^\textnormal{mid} - \mathbf e^n` would carry any error of :math:`\mathbf e^n` as an undamped even-odd oscillation). The energy :math:`H` (without the electric part) is still conserved exactly. + The constraint holds at the mid-point only: its residual + :math:`\mathbf r^n = c_1 \mathbb C^\top \mathbb M_2 \mathbf b^n - c_2 \mathbf j(\mathbf V^n)` (linear in + :math:`\mathbf V` at fixed positions) satisfies :math:`\mathbf r^{n+1} = - \mathbf r^n`. Initial data that violate + the constraint (e.g. by marker noise in :math:`\mathbf J`) would thus oscillate with period 2 without damping. With + ``initial_projection`` (default), the setup replaces :math:`(\mathbf b^0, \mathbf V^0)` by the mid-point state of + one step, :math:`\mathbf b^0 - \frac{\Delta t}{2} \mathbb C \mathbf e^\textnormal{mid}` and + :math:`\mathbf V^0 + \frac{\Delta t}{2} c_3 \overline{DF^{-\top}} \mathbf \Lambda^1 \mathbf e^\textnormal{mid}` + (an implicit Euler step of :math:`\Delta t / 2`), which satisfies the constraint exactly and does not depend on + :math:`\Delta t`: a projection onto the constraint. The particle pushers of the splitting change + :math:`\mathbf r` again by :math:`O(\Delta t)` in every step. + **Preconditioner.** With :math:`\mathbb A \approx \bar n\, \mathbb M_1`, the Schur operator is approximately the stabilized curl-curl operator :math:`\mathbb C^\top \left(\frac{\Delta t^2 c_1}{4} \mathbb M_2\right) \mathbb C @@ -232,6 +243,12 @@ class Options(OptionsBase): (:class:`~struphy.pic.accumulation.particles_to_grid.MarkerOperator`), instead of assembling it: one spline evaluation and one deposition per marker and solver iteration instead of the products of all pairs of basis functions per marker. Needs 6 auxiliary marker columns. Ignored on the CuPy backend. + + initial_projection : bool, default=True + Only for ``kappa=0``: in the setup (first :meth:`prepare_time_step`), replace :math:`(\mathbf b, \mathbf V)` + by the mid-point state of one step, which satisfies :math:`c_1 \nabla \times \mathbf B = c_2 \mathbf J` + exactly (see the class docstring). Without it, initial data that violate the constraint lead to an undamped + period-2 oscillation of :math:`\mathbf b` and :math:`\mathbf V`. Not applied to a restored (restart) state. """ kappa: float = 1.0 @@ -243,6 +260,7 @@ class Options(OptionsBase): solver_params: SolverParameters = None n_ref: float = None matrix_free: bool = False + initial_projection: bool = True def __post_init__(self): # checks @@ -255,6 +273,7 @@ def __post_init__(self): if self.n_ref is not None: assert self.n_ref > 0.0, f"n_ref must be > 0, got {self.n_ref}." assert isinstance(self.matrix_free, bool) + assert isinstance(self.initial_projection, bool) if self.solver == "cg" and self.precond is not None: logger.warning(f'solver="cg" takes no preconditioner; ignoring precond="{self.precond}".') self.precond = None @@ -374,6 +393,7 @@ def allocate(self): self._solver_kwargs = kwargs self._solver = None self._schur_dt = None + self._projected = False # buffers (the pusher reads e_mid) self._e_mid = self.derham.V1.zeros() @@ -405,7 +425,8 @@ def prepare_time_step(self, dt: float): """Schur operator :math:`\\kappa \\mathbb M_1 + \\frac{\\Delta t^2}{4} \\mathbb K`, its preconditioner and solver for the time step ``dt``: built in the setup, rebuilt when dt changes. The particle matrix enters by reference (assembled, or matrix-free reading the fillings stored in the markers), so it is updated by the - accumulation in every step.""" + accumulation in every step. For ``kappa=0`` (and ``initial_projection``), the first call also projects the + state onto the constraint :math:`c_1 \\mathbb C^\\top \\mathbb M_2 \\mathbf b = c_2 \\mathbf j`.""" if dt == self._schur_dt: return @@ -430,6 +451,11 @@ def prepare_time_step(self, dt: float): self._solver = inverse(schur, self.options.solver, **kwargs) self._schur_dt = dt + # kappa = 0: consistent initial state, once (in the setup, before the initial data are saved) + if self._kappa == 0.0 and self.options.initial_projection and not self._projected: + self._project_onto_constraint(dt) + self._projected = True + def energy(self) -> float: r"""The energy :math:`H = \frac{\kappa}{2} \mathbf e^\top \mathbb M_1 \mathbf e + \frac{c_1}{2} \mathbf b^\top \mathbb M_2 \mathbf b + \frac{c_2}{2 c_3} \mathbf V^\top \mathbb W \mathbf V` conserved by the propagator @@ -449,17 +475,15 @@ def energy(self) -> float: energy += self._kappa / 2 * e.inner(self._M1.dot(e, out=self._e_tmp)) return energy - @profile - def __call__(self, dt): + def _mid_point_field(self, dt: float): + """Accumulate at the current markers and solve the Schur system for the mid-point field (into ``_e_mid``, + with updated ghost regions, which the pusher kernel reads).""" en = self.variables.e.spline.vector bn = self.variables.b.spline.vector # accumulate A (or its fillings per marker) and j at the current marker positions and velocities self._accum(*self._args_accum) - # Schur operator for the current time step (no-op unless dt changed since the setup) - self.prepare_time_step(dt) - # rhs = kappa M1 e^n + dt/2 (c1 C^T M2 b^n - c2 j) rhs = self._curl.T.dot(self._M2.dot(bn, out=self._b_tmp), out=self._e_rhs) rhs *= self._c1 @@ -473,10 +497,34 @@ def __call__(self, dt): self._e_tmp *= self._kappa rhs += self._e_tmp - # mid-point field; the pusher kernel reads its ghost regions e_mid = self._solver.dot(rhs, out=self._e_mid) - info = self._solver._info e_mid.update_ghost_regions() + return e_mid, self._solver._info + + def _project_onto_constraint(self, dt: float): + """For kappa = 0: replace (b, V) by the mid-point state of one step, which satisfies + c1 C^T M2 b = c2 j(V) exactly (independent of dt), and e by the mid-point field.""" + bn = self.variables.b.spline.vector + e_mid, _ = self._mid_point_field(dt) + + # b_mid = b^n - dt/2 C e_mid, V_mid = V^n + dt/2 c3 DF^{-T} Lambda^1 e_mid + b_new = self._curl.dot(e_mid, out=self._b_new) + b_new *= -dt / 2 + b_new += bn + self._pusher(dt / 2) + self.update_feec_variables(e=e_mid.copy(out=self._e_new), b=b_new) + logger.info("VlasovMaxwellCoupling: projected the initial state onto c1 curl B = c2 J (kappa = 0).") + + @profile + def __call__(self, dt): + en = self.variables.e.spline.vector + bn = self.variables.b.spline.vector + + # Schur operator for the current time step (no-op unless dt changed since the setup) + self.prepare_time_step(dt) + + # mid-point field + e_mid, info = self._mid_point_field(dt) # new b coeffs: b^{n+1} = b^n - dt C e_mid b_new = self._curl.dot(e_mid, out=self._b_new) From c12682b2139105a6189826ac33508a268c7549a4 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 11:22:23 +0200 Subject: [PATCH 13/23] Weibel quasi-neutral example: results with the initial projection Co-Authored-By: Claude Opus 5.5 --- .../params_weibel_quasi_neutral.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py index 8867f1db1..2fbb7c211 100644 --- a/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py +++ b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py @@ -61,9 +61,15 @@ growth rate of the amplitude of B3; the magnetic energy grows as exp(2 gamma t)). The energy en_H conserved by the coupling step changes only by the splitting with the particle pushers. The pproc fits gamma to the magnetic energy (between t = Tend/5 and 2 Tend/5), compares with the analytical value and plots the energies. Measured with this -setup (serial, 2026-10-23): gamma = 0.0249 for kappa = 1 and kappa = 0 (they agree to 0.2 %), 10 % below the -analytical value; with dt = 0.25 gamma = 0.0253, so the deviation is mostly due to the marker noise and resolution, -not to the large time step. The relative change of en_H stays below 2e-9. +setup (serial, 2026-10-23): gamma = 0.0249 for kappa = 1 and gamma = 0.0251 for kappa = 0, about 10 % below the +analytical value; without the initial projection (kappa = 0, option initial_projection=False) gamma = 0.0249, with a +period-2 oscillation of en_B in the first 80 time units, and with dt = 0.25 gamma = 0.0253, so the deviation is +mostly due to the marker noise and resolution, not to the large time step. The relative change of en_H stays below +4e-9. + +For kappa = 0 the coupling step projects the initial state onto curl B = J (option initial_projection, default): +the constraint holds only at the mid-point of each step, so initial data that violate it (here by the marker noise +of J) would oscillate with period 2. References: - E. S. Weibel, "Spontaneously growing transverse waves in a plasma due to an anisotropic velocity distribution", From 1948c0cd1adcb73b4bd0b9eca812806a2f5158ef Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 11:25:58 +0200 Subject: [PATCH 14/23] Review fix: the initial projection keeps the electric field e_mid of the projection is an impulse of size 1/dt; writing it into e replaced the initial Poisson field in the saved t = 0 data (en_E, gauss_error). At kappa = 0, e does not enter the steps, so only b and V are projected. Co-Authored-By: Claude Opus 5.5 --- .../propagators/tests/test_vlasov_maxwell_coupling.py | 11 ++++++++--- src/struphy/propagators/vlasov_maxwell_coupling.py | 5 +++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py b/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py index 17c9a494e..f36b31e92 100644 --- a/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py +++ b/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py @@ -204,8 +204,8 @@ def _constraint_residual(prop): @pytest.mark.parametrize("matrix_free", [False, True]) def test_initial_projection(matrix_free): """kappa=0: without the initial projection, the constraint residual r = c1 C^T M2 b - c2 j(V) changes sign in - every step (period-2 oscillation). The projection in the setup makes r vanish, does not depend on dt, and the - steps then leave b and V unchanged (frozen positions).""" + every step (period-2 oscillation). The projection in the setup makes r vanish, does not depend on dt, keeps e, + and the steps then leave b and V unchanged (frozen positions).""" # without projection: r^{n+1} = -r^n model = _model(kappa=0.0, initial_projection=False, matrix_free=matrix_free) prop = model.propagators.coupling @@ -220,10 +220,15 @@ def test_initial_projection(matrix_free): for dt in (0.5, 2.0): model = _model(kappa=0.0, matrix_free=matrix_free) prop = model.propagators.coupling - j_scale = abs(prop._c2) * _norm(_constraint_residual(prop) * 0.0 + prop._accum.vectors[0]) + # accumulate at the initial markers for the scale of the current + prop._accum(*prop._args_accum) + j_scale = abs(prop._c2) * _norm(prop._accum.vectors[0]) + e_init = model.em_fields.e_field.spline.vector.copy() prop.prepare_time_step(dt) assert _norm(_constraint_residual(prop)) < 1e-10 * j_scale e, b, v = _state(model) + # the electric field is not changed by the projection + assert _norm(e - e_init) == 0.0 projected.append((b.toarray(), v)) for _ in range(2): prop(dt) diff --git a/src/struphy/propagators/vlasov_maxwell_coupling.py b/src/struphy/propagators/vlasov_maxwell_coupling.py index 44df0342d..d90f0c05d 100644 --- a/src/struphy/propagators/vlasov_maxwell_coupling.py +++ b/src/struphy/propagators/vlasov_maxwell_coupling.py @@ -503,7 +503,8 @@ def _mid_point_field(self, dt: float): def _project_onto_constraint(self, dt: float): """For kappa = 0: replace (b, V) by the mid-point state of one step, which satisfies - c1 C^T M2 b = c2 j(V) exactly (independent of dt), and e by the mid-point field.""" + c1 C^T M2 b = c2 j(V) exactly (independent of dt). The electric field is kept (e.g. from the initial Poisson + solve): e_mid is an impulse of size 1/dt here, and e does not enter the steps for kappa = 0.""" bn = self.variables.b.spline.vector e_mid, _ = self._mid_point_field(dt) @@ -512,7 +513,7 @@ def _project_onto_constraint(self, dt: float): b_new *= -dt / 2 b_new += bn self._pusher(dt / 2) - self.update_feec_variables(e=e_mid.copy(out=self._e_new), b=b_new) + self.update_feec_variables(b=b_new) logger.info("VlasovMaxwellCoupling: projected the initial state onto c1 curl B = c2 J (kappa = 0).") @profile From 977cb58a97bfe7a2bd58b859f37708bdee6d9d42 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 11:43:01 +0200 Subject: [PATCH 15/23] Verification test: Weibel growth rate of VlasovMaxwellQuasiNeutral at kappa = 0 1D-3V Weibel instability (k = 1.25, 1e5 full-f markers, dt = 1, t = 160) in the quasi-neutral limit; the growth rate fitted to the magnetic energy for 80 < t < 150 must be within 15 % of the analytical 0.02784 (fitted: 0.02506, the same on 1, 2 and 4 ranks). About 45 s serial, 25 s on 2 or 4 ranks. Co-Authored-By: Claude Opus 5.5 --- .../test_verif_VlasovMaxwellQuasiNeutral.py | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py diff --git a/src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py b/src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py new file mode 100644 index 000000000..176216648 --- /dev/null +++ b/src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py @@ -0,0 +1,107 @@ +import logging +import math +import os +import shutil + +import h5py +import numpy as np +from maybempi import MPI + +from struphy import ( + BoundaryParameters, + DerhamOptions, + EnvironmentOptions, + LoadingParameters, + SavingParameters, + Simulation, + SortingParameters, + Time, + WeightsParameters, + domains, + grids, + maxwellians, + perturbations, +) +from struphy.models import VlasovMaxwellQuasiNeutral + +logger = logging.getLogger("struphy") + +# growth rate of the amplitude of B3 for k = 1.25, vth2^2 / vth1^2 = 12 (Kraus et al. 2017); the same for kappa = 0 +# since kappa gamma^2 ~ 8e-4 << k^2 + 1 +GAMMA_EXACT = 0.02784 + + +def _weibel_growth_rate(kappa: float, Np: int, Tend: float, t_fit: tuple, dt: float = 1.0) -> float: + """Runs the Weibel instability of the example weibel_quasi_neutral (1D-3V, k = 1.25, full-f markers) and returns + the growth rate fitted to the magnetic energy, en_B ~ exp(2 gamma t), for t_fit[0] < t < t_fit[1].""" + model = VlasovMaxwellQuasiNeutral(alpha=1.0, epsilon=-1.0) + + test_folder = os.path.join(os.getcwd(), "struphy_verification_tests") + out_folders = os.path.join(test_folder, "VlasovMaxwellQuasiNeutral") + env = EnvironmentOptions(out_folders=out_folders, sim_folder=f"weibel_kappa{kappa:g}_Np{Np}") + + k = 1.25 + vth1 = 0.02 / math.sqrt(2) + vth2 = vth1 * math.sqrt(12) + + model.kinetic_ions.set_markers( + loading_params=LoadingParameters( + Np=Np, + set_zero_velocity=(False, False, True), + moments=(0.0, 0.0, 0.0, vth1, vth2, 1.0), + seed=1234, + ), + weights_params=WeightsParameters(control_variate=False), + boundary_params=BoundaryParameters(), + sorting_params=SortingParameters(boxes_per_dim=(16, 1, 1), do_sort=True), + saving_params=SavingParameters(n_markers=0), + bufsize=2.0, + ) + model.propagators.coupling.options = model.propagators.coupling.Options(kappa=kappa) + model.propagators.push_eta.options = model.propagators.push_eta.Options() + model.propagators.push_vxb.options = model.propagators.push_vxb.Options() + model.initial_poisson.options = model.initial_poisson.Options(stab_mat="M0") + + model.kinetic_ions.var.add_background(maxwellians.Maxwellian3D(vth1=(vth1, None), vth2=(vth2, None))) + model.em_fields.b_field.add_perturbation(perturbation=perturbations.ModesCos(amps=(-1e-4,), ls=(1,), comp=2)) + + sim = Simulation( + model=model, + env=env, + time_opts=Time(dt=dt, Tend=Tend, split_algo="LieTrotter"), + domain=domains.Cuboid(r1=2 * math.pi / k), + grid=grids.TensorProductGrid(num_elements=(32, 1, 1)), + derham_opts=DerhamOptions(degree=(3, 1, 1)), + ) + sim.run() + + # the scalars are summed over the ranks and written by rank 0 + comm = MPI.COMM_WORLD + comm.Barrier() + with h5py.File(os.path.join(env.path_out, "data", "data_proc0.hdf5"), "r") as file: + t = np.asarray(file["time/value"]) + en_B = np.asarray(file["scalar/en_B"]) + comm.Barrier() + + mask = (t > t_fit[0]) & (t < t_fit[1]) + gamma = np.polyfit(t[mask], np.log(en_B[mask]), 1)[0] / 2 + return float(gamma) + + +def test_weibel_growth_rate_quasi_neutral(): + """Weibel instability in the quasi-neutral limit (kappa = 0, no displacement current): the magnetic energy grows + with the analytical growth rate of the Vlasov-Maxwell system, gamma = 0.02784, which the displacement current does + not change. With 1e5 full-f markers and dt = 1 the fitted rate is about 10 % low (marker noise and resolution).""" + gamma = _weibel_growth_rate(kappa=0.0, Np=100_000, Tend=160.0, t_fit=(80.0, 150.0)) + logger.info(f"Weibel growth rate (kappa = 0): {gamma = }, analytical {GAMMA_EXACT}") + assert abs(gamma / GAMMA_EXACT - 1) < 0.15, f"{gamma = } vs. {GAMMA_EXACT = }" + + comm = MPI.COMM_WORLD + comm.Barrier() + if comm.Get_rank() == 0: + shutil.rmtree(os.path.join(os.getcwd(), "struphy_verification_tests")) + comm.Barrier() + + +if __name__ == "__main__": + test_weibel_growth_rate_quasi_neutral() From 17121d141d58fd758b1ce77d601fd88efc291448 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 12:00:54 +0200 Subject: [PATCH 16/23] Weibel verification: antithetic Sobol loading, 5 % tolerance; example with the same loading With pseudo-random markers the fitted growth rate is noise-limited (about 10 % low, 7 % seed-to-seed scatter at 1e5 markers). With the antithetic Sobol loading the current noise mostly cancels: gamma = 0.02770 / 0.02784 / 0.02782 on 1 / 2 / 4 ranks against the analytical 0.02784, so the tolerance is now 5 %. Review fixes: comments on the kappa = 0 dispersion relation and on reading the scalars, rmtree(ignore_errors=True). The example (Tend = 400) now gives gamma = 0.02805 (kappa = 1) and 0.02758 (kappa = 0). Co-Authored-By: Claude Opus 5.5 --- .../params_weibel_quasi_neutral.py | 17 +++++++----- .../test_verif_VlasovMaxwellQuasiNeutral.py | 26 ++++++++++++------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py index 2fbb7c211..c059b66f8 100644 --- a/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py +++ b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py @@ -53,19 +53,20 @@ Setup. As in the VlasovMaxwellOneSpecies Weibel example: periodic box of length 2 pi / k with k = 1.25, 32 cells and cubic splines in x, Maxwellian electrons with n = 1 and vth1 = 0.02 / sqrt(2), vth2 = sqrt(12) vth1 (v3 = 0), -the seed field B3 = -1e-4 cos(k x), 100000 markers with full-f weights (seed 1234; the quasi-neutral limit needs -full-f weights). Time step dt = {args.dt:g} (20 times the dt = 0.05 of the VlasovMaxwellOneSpecies example; the +the seed field B3 = -1e-4 cos(k x), 100000 markers with full-f weights (the quasi-neutral limit needs full-f +weights), loaded by an antithetic Sobol sequence (pairs of markers with opposite velocities), which removes most of +the noise of the current. Time step dt = {args.dt:g} (20 times the dt = 0.05 of the VlasovMaxwellOneSpecies example; the implicit coupling step is stable for dt omega_p > 1) up to t = {args.tend:g}, Lie-Trotter splitting. Expected result. For k = 1.25 the dispersion relation gives gamma = 0.02784 for both kappa = 1 and kappa = 0 (the growth rate of the amplitude of B3; the magnetic energy grows as exp(2 gamma t)). The energy en_H conserved by the coupling step changes only by the splitting with the particle pushers. The pproc fits gamma to the magnetic energy (between t = Tend/5 and 2 Tend/5), compares with the analytical value and plots the energies. Measured with this -setup (serial, 2026-10-23): gamma = 0.0249 for kappa = 1 and gamma = 0.0251 for kappa = 0, about 10 % below the -analytical value; without the initial projection (kappa = 0, option initial_projection=False) gamma = 0.0249, with a -period-2 oscillation of en_B in the first 80 time units, and with dt = 0.25 gamma = 0.0253, so the deviation is -mostly due to the marker noise and resolution, not to the large time step. The relative change of en_H stays below -4e-9. +setup (serial, 2026-10-23): gamma = 0.02805 for kappa = 1 and gamma = 0.02758 for kappa = 0, within 1 % of the +analytical value; the relative change of en_H stays below 6e-9. With pseudo-random markers instead, the fitted rate +is about 10 % low and scatters by about 7 % between seeds (marker noise); the large time step changes it only by +about 1 %. The verification test test_verif_VlasovMaxwellQuasiNeutral.py runs this setup for kappa = 0 up to +t = 160. For kappa = 0 the coupling step projects the initial state onto curl B = J (option initial_projection, default): the constraint holds only at the mid-point of each step, so initial data that violate it (here by the marker noise @@ -152,6 +153,8 @@ model.kinetic_ions.set_markers( loading_params=LoadingParameters( Np=2000 if args.test_run else 100000, + # markers in pairs with opposite velocities (quasi-random): much less noise in the current + loading="sobol_antithetic", set_zero_velocity=(False, False, True), moments=(0.0, 0.0, 0.0, vth1_background_val, vth2_background_val, 1.0), seed=1234, diff --git a/src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py b/src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py index 176216648..1c6292145 100644 --- a/src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py +++ b/src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py @@ -26,19 +26,25 @@ logger = logging.getLogger("struphy") -# growth rate of the amplitude of B3 for k = 1.25, vth2^2 / vth1^2 = 12 (Kraus et al. 2017); the same for kappa = 0 -# since kappa gamma^2 ~ 8e-4 << k^2 + 1 +# growth rate of the amplitude of B3 for k = 1.25, vth2^2 / vth1^2 = 12 (Kraus et al. 2017); the same for kappa = 0, +# since the displacement-current term gamma^2 ~ 8e-4 of the kappa = 1 dispersion relation is << k^2 + 1 GAMMA_EXACT = 0.02784 -def _weibel_growth_rate(kappa: float, Np: int, Tend: float, t_fit: tuple, dt: float = 1.0) -> float: +def _weibel_growth_rate( + kappa: float, Np: int, Tend: float, t_fit: tuple, dt: float = 1.0, loading: str = "sobol_antithetic" +) -> float: """Runs the Weibel instability of the example weibel_quasi_neutral (1D-3V, k = 1.25, full-f markers) and returns - the growth rate fitted to the magnetic energy, en_B ~ exp(2 gamma t), for t_fit[0] < t < t_fit[1].""" + the growth rate fitted to the magnetic energy, en_B ~ exp(2 gamma t), for t_fit[0] < t < t_fit[1]. + + The antithetic Sobol loading (markers in pairs with opposite velocities) removes most of the noise of the + current, which limits the fit: with pseudo-random markers the fitted rate scatters by about 7 % between seeds + (1e5 markers).""" model = VlasovMaxwellQuasiNeutral(alpha=1.0, epsilon=-1.0) test_folder = os.path.join(os.getcwd(), "struphy_verification_tests") out_folders = os.path.join(test_folder, "VlasovMaxwellQuasiNeutral") - env = EnvironmentOptions(out_folders=out_folders, sim_folder=f"weibel_kappa{kappa:g}_Np{Np}") + env = EnvironmentOptions(out_folders=out_folders, sim_folder=f"weibel_kappa{kappa:g}_Np{Np}_{loading}") k = 1.25 vth1 = 0.02 / math.sqrt(2) @@ -47,6 +53,7 @@ def _weibel_growth_rate(kappa: float, Np: int, Tend: float, t_fit: tuple, dt: fl model.kinetic_ions.set_markers( loading_params=LoadingParameters( Np=Np, + loading=loading, set_zero_velocity=(False, False, True), moments=(0.0, 0.0, 0.0, vth1, vth2, 1.0), seed=1234, @@ -75,7 +82,7 @@ def _weibel_growth_rate(kappa: float, Np: int, Tend: float, t_fit: tuple, dt: fl ) sim.run() - # the scalars are summed over the ranks and written by rank 0 + # the scalars are global (reduced over the ranks), so the file of rank 0 holds them for all ranks comm = MPI.COMM_WORLD comm.Barrier() with h5py.File(os.path.join(env.path_out, "data", "data_proc0.hdf5"), "r") as file: @@ -91,15 +98,16 @@ def _weibel_growth_rate(kappa: float, Np: int, Tend: float, t_fit: tuple, dt: fl def test_weibel_growth_rate_quasi_neutral(): """Weibel instability in the quasi-neutral limit (kappa = 0, no displacement current): the magnetic energy grows with the analytical growth rate of the Vlasov-Maxwell system, gamma = 0.02784, which the displacement current does - not change. With 1e5 full-f markers and dt = 1 the fitted rate is about 10 % low (marker noise and resolution).""" + not change. With 1e5 full-f markers (antithetic Sobol loading) and dt = 1 the fitted rate is within 0.5 % of the + analytical one, on 1, 2 and 4 MPI ranks.""" gamma = _weibel_growth_rate(kappa=0.0, Np=100_000, Tend=160.0, t_fit=(80.0, 150.0)) logger.info(f"Weibel growth rate (kappa = 0): {gamma = }, analytical {GAMMA_EXACT}") - assert abs(gamma / GAMMA_EXACT - 1) < 0.15, f"{gamma = } vs. {GAMMA_EXACT = }" + assert abs(gamma / GAMMA_EXACT - 1) < 0.05, f"{gamma = } vs. {GAMMA_EXACT = }" comm = MPI.COMM_WORLD comm.Barrier() if comm.Get_rank() == 0: - shutil.rmtree(os.path.join(os.getcwd(), "struphy_verification_tests")) + shutil.rmtree(os.path.join(os.getcwd(), "struphy_verification_tests"), ignore_errors=True) comm.Barrier() From e78f76608f17a32c90bb00a120e3bfc45d9fb3eb Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 12:04:05 +0200 Subject: [PATCH 17/23] Review nits: describe the antithetic Sobol loading, drop the unused seed, 7 % tolerance Co-Authored-By: Claude Opus 5.5 --- .../params_weibel_quasi_neutral.py | 10 +++++----- .../test_verif_VlasovMaxwellQuasiNeutral.py | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py index c059b66f8..1bcfcfcc8 100644 --- a/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py +++ b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py @@ -54,9 +54,10 @@ Setup. As in the VlasovMaxwellOneSpecies Weibel example: periodic box of length 2 pi / k with k = 1.25, 32 cells and cubic splines in x, Maxwellian electrons with n = 1 and vth1 = 0.02 / sqrt(2), vth2 = sqrt(12) vth1 (v3 = 0), the seed field B3 = -1e-4 cos(k x), 100000 markers with full-f weights (the quasi-neutral limit needs full-f -weights), loaded by an antithetic Sobol sequence (pairs of markers with opposite velocities), which removes most of -the noise of the current. Time step dt = {args.dt:g} (20 times the dt = 0.05 of the VlasovMaxwellOneSpecies example; the -implicit coupling step is stable for dt omega_p > 1) up to t = {args.tend:g}, Lie-Trotter splitting. +weights), loaded by an antithetic Sobol sequence (each Sobol point reflected in all phase-space directions, so that +the mean velocities of each group of markers vanish), which removes most of the noise of the current. Time step +dt = {args.dt:g} (20 times the dt = 0.05 of the VlasovMaxwellOneSpecies example; the implicit coupling step is stable +for dt omega_p > 1) up to t = {args.tend:g}, Lie-Trotter splitting. Expected result. For k = 1.25 the dispersion relation gives gamma = 0.02784 for both kappa = 1 and kappa = 0 (the growth rate of the amplitude of B3; the magnetic energy grows as exp(2 gamma t)). The energy en_H conserved by the @@ -153,11 +154,10 @@ model.kinetic_ions.set_markers( loading_params=LoadingParameters( Np=2000 if args.test_run else 100000, - # markers in pairs with opposite velocities (quasi-random): much less noise in the current + # quasi-random markers reflected in all phase-space directions (no seed): much less noise in the current loading="sobol_antithetic", set_zero_velocity=(False, False, True), moments=(0.0, 0.0, 0.0, vth1_background_val, vth2_background_val, 1.0), - seed=1234, ), weights_params=WeightsParameters(control_variate=False), boundary_params=BoundaryParameters(), diff --git a/src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py b/src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py index 1c6292145..44a970d47 100644 --- a/src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py +++ b/src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py @@ -37,8 +37,9 @@ def _weibel_growth_rate( """Runs the Weibel instability of the example weibel_quasi_neutral (1D-3V, k = 1.25, full-f markers) and returns the growth rate fitted to the magnetic energy, en_B ~ exp(2 gamma t), for t_fit[0] < t < t_fit[1]. - The antithetic Sobol loading (markers in pairs with opposite velocities) removes most of the noise of the - current, which limits the fit: with pseudo-random markers the fitted rate scatters by about 7 % between seeds + The antithetic Sobol loading (each Sobol point reflected u -> 1 - u in all phase-space directions, 64 markers + per point, so that the mean velocities of each group vanish) removes most of the noise of the current, which + limits the fit: with pseudo-random markers the fitted rate scatters by about 7 % between seeds (1e5 markers).""" model = VlasovMaxwellQuasiNeutral(alpha=1.0, epsilon=-1.0) @@ -56,7 +57,6 @@ def _weibel_growth_rate( loading=loading, set_zero_velocity=(False, False, True), moments=(0.0, 0.0, 0.0, vth1, vth2, 1.0), - seed=1234, ), weights_params=WeightsParameters(control_variate=False), boundary_params=BoundaryParameters(), @@ -98,11 +98,11 @@ def _weibel_growth_rate( def test_weibel_growth_rate_quasi_neutral(): """Weibel instability in the quasi-neutral limit (kappa = 0, no displacement current): the magnetic energy grows with the analytical growth rate of the Vlasov-Maxwell system, gamma = 0.02784, which the displacement current does - not change. With 1e5 full-f markers (antithetic Sobol loading) and dt = 1 the fitted rate is within 0.5 % of the - analytical one, on 1, 2 and 4 MPI ranks.""" + not change. With 1e5 full-f markers (antithetic Sobol loading) and dt = 1 the fitted rate is close to the + analytical one (about 0.5 %), on 1, 2 and 4 MPI ranks.""" gamma = _weibel_growth_rate(kappa=0.0, Np=100_000, Tend=160.0, t_fit=(80.0, 150.0)) logger.info(f"Weibel growth rate (kappa = 0): {gamma = }, analytical {GAMMA_EXACT}") - assert abs(gamma / GAMMA_EXACT - 1) < 0.05, f"{gamma = } vs. {GAMMA_EXACT = }" + assert abs(gamma / GAMMA_EXACT - 1) < 0.07, f"{gamma = } vs. {GAMMA_EXACT = }" comm = MPI.COMM_WORLD comm.Barrier() From 1bd28be519e83a337d2e1b6adb1bf29e09058d92 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 13:13:04 +0200 Subject: [PATCH 18/23] DriftKineticMaxwellCoupling and model DriftKineticMaxwell: implicit energy-conserving drift-kinetic Maxwell coupling with quasi-neutral limit - New propagator DriftKineticMaxwellCoupling: Maxwell's equations, the parallel acceleration of the guiding centers and (option exb_current) the E x B current in one Crank-Nicolson step at frozen positions; Schur system for e_mid with M_E = kappa M1 + chi M1gyro (linearized polarization current, chi = alpha^2 by default), parallel response A_par (rank one per marker) and antisymmetric E x B operator S. Exact energy conservation, well-posed for kappa = 0 (requires kappa + chi > 0). Assembled (dk_maxwell) and matrix-free (dk_maxwell_mf with marker_operator_rank1_dot / marker_operator_asym_dot) particle matrices, push_vpar_with_efield kernel, StiffnessPreconditioner, initial projection for kappa = 0. - PushGuidingCenterBxEstar / PushGuidingCenterParallel accept either phi (unchanged) or a new e_field (Hcurl): E* = E - eps mu grad|B| (gauge-invariant formulation). - New model DriftKineticMaxwell: coupling -> push_para -> push_bxe(e_field). - Tests: energy conservation, assembled = matrix-free, operator symmetries, discrete equations, initial projection, options; e_field = -grad phi reproduces the phi path; verification against the cold-slab dispersion relation (inertial Alfven wave, with and without E x B current, kappa = 0 and 1). - Example inertial_alfven_wave. Co-Authored-By: Claude Opus 5.5 --- doc/sections/subsections/models-all.rst | 5 + .../subsections/propagators-avail.rst | 5 + .../params_inertial_alfven_wave.py | 181 +++++ .../pproc_inertial_alfven_wave.py | 95 +++ src/struphy/api/models/__init__.py | 2 + src/struphy/models/__init__.py | 3 + src/struphy/models/drift_kinetic_maxwell.py | 278 +++++++ .../test_verif_DriftKineticMaxwell.py | 141 ++++ .../kernels/dk_maxwell/__init__.py | 10 + .../kernels/dk_maxwell/dk_maxwell_kernels.py | 156 ++++ .../kernels/dk_maxwell_mf/__init__.py | 10 + .../dk_maxwell_mf/dk_maxwell_mf_kernels.py | 117 +++ .../kernels/push_vpar_with_efield/__init__.py | 10 + .../push_vpar_with_efield_kernels.py | 90 +++ src/struphy/pic/tests/test_kernel_backends.py | 4 +- src/struphy/propagators/__init__.py | 3 + .../drift_kinetic_maxwell_coupling.py | 680 ++++++++++++++++++ .../push_guiding_center_bx_estar.py | 19 +- .../push_guiding_center_parallel.py | 19 +- .../test_drift_kinetic_maxwell_coupling.py | 231 ++++++ .../tests/test_push_guiding_center_e_field.py | 95 +++ 21 files changed, 2148 insertions(+), 6 deletions(-) create mode 100644 examples/DriftKineticMaxwell/inertial_alfven_wave/params_inertial_alfven_wave.py create mode 100644 examples/DriftKineticMaxwell/inertial_alfven_wave/pproc_inertial_alfven_wave.py create mode 100644 src/struphy/models/drift_kinetic_maxwell.py create mode 100644 src/struphy/models/tests/verification/test_verif_DriftKineticMaxwell.py create mode 100644 src/struphy/pic/accumulation/kernels/dk_maxwell/__init__.py create mode 100644 src/struphy/pic/accumulation/kernels/dk_maxwell/dk_maxwell_kernels.py create mode 100644 src/struphy/pic/accumulation/kernels/dk_maxwell_mf/__init__.py create mode 100644 src/struphy/pic/accumulation/kernels/dk_maxwell_mf/dk_maxwell_mf_kernels.py create mode 100644 src/struphy/pic/pushing/kernels/push_vpar_with_efield/__init__.py create mode 100644 src/struphy/pic/pushing/kernels/push_vpar_with_efield/push_vpar_with_efield_kernels.py create mode 100644 src/struphy/propagators/drift_kinetic_maxwell_coupling.py create mode 100644 src/struphy/propagators/tests/test_drift_kinetic_maxwell_coupling.py create mode 100644 src/struphy/propagators/tests/test_push_guiding_center_e_field.py diff --git a/doc/sections/subsections/models-all.rst b/doc/sections/subsections/models-all.rst index b896f7082..454465b9c 100644 --- a/doc/sections/subsections/models-all.rst +++ b/doc/sections/subsections/models-all.rst @@ -49,6 +49,11 @@ Gyrokinetic :undoc-members: :show-inheritance: +.. autoclass:: struphy.models.DriftKineticMaxwell + :members: doc_pde, doc_scalar_quantities, doc_normalization, doc_discretization, doc_long_description, doc_examples, doc_use_cases, doc_cannot_be_used_for + :undoc-members: + :show-inheritance: + .. autoclass:: struphy.models.ToyDrift :members: doc_pde, doc_scalar_quantities, doc_normalization, doc_discretization, doc_long_description, doc_examples, doc_use_cases, doc_cannot_be_used_for :undoc-members: diff --git a/doc/sections/subsections/propagators-avail.rst b/doc/sections/subsections/propagators-avail.rst index ac971567a..37c30958c 100644 --- a/doc/sections/subsections/propagators-avail.rst +++ b/doc/sections/subsections/propagators-avail.rst @@ -202,6 +202,11 @@ Vlasov-Maxwell coupling :exclude-members: options, allocate :show-inheritance: +.. automodule:: struphy.propagators.drift_kinetic_maxwell_coupling + :members: + :exclude-members: options, allocate + :show-inheritance: + MHD-kinetic coupling ^^^^^^^^^^^^^^^^^^^^ diff --git a/examples/DriftKineticMaxwell/inertial_alfven_wave/params_inertial_alfven_wave.py b/examples/DriftKineticMaxwell/inertial_alfven_wave/params_inertial_alfven_wave.py new file mode 100644 index 000000000..27d6a17bf --- /dev/null +++ b/examples/DriftKineticMaxwell/inertial_alfven_wave/params_inertial_alfven_wave.py @@ -0,0 +1,181 @@ +"""Inertial Alfvén wave with the DriftKineticMaxwell model, with and without displacement current (`--kappa 1` or +`--kappa 0`) and with or without the E x B current (`--exb-current`).""" + +import argparse +from pathlib import Path + +# Command line options +# no -h here when imported by the pproc script, which has its own help +parser = argparse.ArgumentParser(add_help=__name__ == "__main__") +parser.add_argument( + "--out-folder", + default=str(Path(__file__).resolve().parent), + help="Folder in which the output folder is created (default: beside this file).", +) +parser.add_argument("--sim-folder", default=None, help="Name of the output folder (default: sim_kappa[_exb]).") +parser.add_argument( + "--kappa", type=float, default=0.0, help="Coefficient of the displacement current (1: Maxwell, 0: quasi-neutral)." +) +parser.add_argument( + "--exb-current", action="store_true", help="Include the E x B current of the guiding centers in Ampere's law." +) +parser.add_argument("--dt", type=float, default=0.5, help="Time step.") +parser.add_argument("--tend", type=float, default=80.0, help="End time.") +parser.add_argument( + "--test-run", + action="store_true", + help="Few markers and three saved time steps, for the tests (test_examples.py).", +) +args, _ = parser.parse_known_args() +if args.sim_folder is None: + args.sim_folder = f"sim_kappa{args.kappa:g}" + ("_exb" if args.exb_current else "") +if args.test_run: + args.tend = 2 * args.dt + +name = "Inertial Alfven wave in the drift-kinetic Maxwell model" + +description = f""" +Shear (inertial) Alfven wave in a cold guiding-center plasma with the DriftKineticMaxwell model: drift-kinetic +particles (PIC, Particles5D) coupled to Maxwell's equations through the implicit, energy conserving +DriftKineticMaxwellCoupling step, in a periodic slab with the uniform field B0 = e_z. Here +kappa = {args.kappa:g}, E x B current {"on" if args.exb_current else "off"}. + +Physics. In the units of the model (velocities in units of c, alpha = Omega_p / Omega_c, epsilon = 1 / (Omega_c t)) +Ampere's law reads + kappa dE/dt + chi n0 / |B0|^2 dE_perp/dt = curl B - (alpha^2 / epsilon) J_gc, +with the guiding-center current J_gc and the polarization coefficient chi = alpha^2 (c^2 / v_A^2 of the species). +The drift-kinetic ordering removes the polarization drift from the guiding-center motion; the chi term adds it back. +Without it, the quasi-neutral limit kappa = 0 would be singular for flute modes, and for kappa = 1 the shear Alfven +wave would travel at the speed of light. For a cold plasma and k = (kx, 0, kz), the shear mode (E_x, E_z, B_y) +obeys, without the E x B current and for kappa = 0, + omega^2 = (c1 / chi) kz^2 / (1 + kx^2 d^2), d^2 = c1 / P, P = c2 c3 n0 = alpha^2 / epsilon^2, +the inertial Alfven wave with v_A^2 = 1 / chi and the inertial length d. For kappa > 0, and with the E x B current +(which couples the shear and the fast wave through the Hall term of the single species), the frequencies are the +roots of the determinant of the 3x3 cold-plasma dispersion matrix (computed in the pproc script). + +Setup. alpha = 3, epsilon = 0.5 (chi = 9, v_A = 1/3, d = 1/6); box 1 x 1 x 4 with 8 x 1 x 8 cells and cubic +splines in x and z, kx = 2 pi, kz = pi / 2 (kx^2 d^2 = 1.1); cold guiding centers (vth = 1e-6, full-f weights, +50 markers per cell, seed 1234), initial B_y = 1e-2 sin(kx x + kz z) (2-form component). Time step +dt = {args.dt:g} (omega dt = 0.18) up to t = {args.tend:g}, Lie-Trotter splitting of the coupling step with the +parallel and the E* x b0 guiding-center pushers. For kappa = 0 the coupling step first projects the initial state +onto the constraint of the quasi-neutral limit (option initial_projection, default). + +Expected result. Analytical frequencies of the shear mode: omega = 0.3616 (kappa = 0), 0.2643 (kappa = 0 with the +E x B current) and 0.3428 (kappa = 1; the B_y perturbation then also excites the upper branch, omega = 8.70, with a +small amplitude). The pproc fits omega to the magnetic energy en_B ~ cos^2(omega t) and compares. Measured with +this example (serial, 2026-10-23): -1.0 % for kappa = 0; with the verification test of the same setup -0.25 % with +the E x B current and -0.7 % for kappa = 1 (dt = 0.1). The deviation decreases with the number of markers (-0.35 % +with 200 markers per cell). The total energy en_tot is conserved by the whole splitting up to the solver tolerance +(cold plasma, uniform B0): relative change 5e-6 with the default tolerance 1e-8, 1e-9 with 1e-12. + +References: +- A. J. Brizard and T. S. Hahm, "Foundations of nonlinear gyrokinetic theory", Rev. Mod. Phys. 79, 421 (2007). +- C. K. Birdsall and A. B. Langdon, "Plasma Physics via Computer Simulation" (IOP, 1991). +""" + +# ------------------ +# Import Struphy API +# ------------------ + +import logging + +from struphy import ( + BaseUnits, + DerhamOptions, + EnvironmentOptions, + LoadingParameters, + SavingParameters, + Simulation, + Time, + WeightsParameters, + domains, + equils, + grids, + maxwellians, + perturbations, + set_logging_level, +) + +set_logging_level(logging.WARNING) + +# --------------------- +# Instance of the model +# --------------------- + +from struphy.models import DriftKineticMaxwell + +ALPHA, EPSILON = 3.0, 0.5 +LX, LZ = 1.0, 4.0 +VTH = 1e-6 + +model = DriftKineticMaxwell(base_units=BaseUnits(), alpha=ALPHA, epsilon=EPSILON) + +model.em_fields.e_field.save_data = True +model.em_fields.b_field.save_data = True +model.kinetic_ions.var.save_data = False + +# -------------------------- +# Instance of the simulation +# -------------------------- + +env = EnvironmentOptions(out_folders=args.out_folder, sim_folder=args.sim_folder) +time_opts = Time(dt=args.dt, Tend=args.tend, split_algo="LieTrotter") +domain = domains.Cuboid(r1=LX, r2=1.0, r3=LZ) +equil = equils.HomogenSlab(B0z=1.0) +grid = grids.TensorProductGrid(num_elements=(8, 1, 8)) +derham_opts = DerhamOptions(degree=(3, 1, 3)) + +sim = Simulation( + model=model, + name=name, + description=description, + params_path=__file__, + env=env, + time_opts=time_opts, + domain=domain, + equil=equil, + grid=grid, + derham_opts=derham_opts, +) + +# ------------------- +# Particle parameters +# ------------------- + +model.kinetic_ions.set_markers( + loading_params=LoadingParameters( + ppc=5 if args.test_run else 50, + moments=(0.0, 0.0, VTH, VTH), + B0=1.0, + seed=1234, + ), + weights_params=WeightsParameters(control_variate=False), + saving_params=SavingParameters(n_markers=0), +) + +# ------------------ +# Propagator options +# ------------------ + +prop = model.propagators.coupling +if args.exb_current: + prop.options = prop.Options(kappa=args.kappa) +else: + # without the E x B current the Schur system is symmetric + prop.options = prop.Options(kappa=args.kappa, exb_current=False, solver="pcg") +model.propagators.push_para.options = model.propagators.push_para.Options() +model.propagators.push_bxe.options = model.propagators.push_bxe.Options() + +# ------------------ +# Initial conditions +# ------------------ + +cold = dict(n=(1.0, None), vth_para=(VTH, None), vth_perp=(VTH, None), B0=1.0) +model.kinetic_ions.var.add_background(maxwellians.GyroMaxwellian2D(**cold)) +model.em_fields.b_field.add_perturbation( + perturbations.ModesSin(ls=(1,), ns=(1,), given_in_basis="2", comp=1, amps=(1e-2,)) +) + +if __name__ == "__main__": + # post-processing and plots: pproc_inertial_alfven_wave.py + sim.run() diff --git a/examples/DriftKineticMaxwell/inertial_alfven_wave/pproc_inertial_alfven_wave.py b/examples/DriftKineticMaxwell/inertial_alfven_wave/pproc_inertial_alfven_wave.py new file mode 100644 index 000000000..61e715f3a --- /dev/null +++ b/examples/DriftKineticMaxwell/inertial_alfven_wave/pproc_inertial_alfven_wave.py @@ -0,0 +1,95 @@ +"""Post-process the inertial Alfvén wave example: wave frequency from the magnetic energy and energy conservation. + +Run as ``python pproc_inertial_alfven_wave.py [--kappa K] [--exb-current]`` (same flags as the params file) or with +the path of the output folder. Writes ``energies.png`` into ``/results`` and prints the fitted frequency. +""" + +import argparse +import sys +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np +from scipy.optimize import curve_fit + +from struphy import Output + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) +from params_inertial_alfven_wave import ALPHA, EPSILON, LX, LZ, args # noqa: E402 + +DEFAULT_OUTPUT = Path(args.out_folder) / args.sim_folder + + +def dispersion_roots(kappa, exb_current): + """Positive real roots omega of the determinant of the cold-slab dispersion matrix + omega^2 (kappa E + chi E_perp) - c1 (k^2 E - k k.E) - P E_z e_z + i omega H E x e_z + (polynomial of degree 6 in omega, interpolated exactly from 7 values).""" + c1, c2, c3 = 1.0, ALPHA**2 / EPSILON, 1.0 / EPSILON + chi, P, H = ALPHA**2, c2 * c3, c2 if exb_current else 0.0 + k = np.array([2 * np.pi / LX, 0.0, 2 * np.pi / LZ]) + cross_z = np.array([[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) + + def det(w): + mat = ( + w**2 * (kappa * np.eye(3) + chi * np.diag([1.0, 1.0, 0.0])) + - c1 * (k @ k * np.eye(3) - np.outer(k, k)) + - P * np.diag([0.0, 0.0, 1.0]) + + 1j * w * H * cross_z + ) + return np.linalg.det(mat).real + + nodes = np.linspace(-1.0, 1.0, 7) + roots = np.roots(np.polyfit(nodes, [det(w) for w in nodes], 6)) + return np.sort(roots[(abs(roots.imag) < 1e-8) & (roots.real > 1e-8)].real) + + +def main(path_out=DEFAULT_OUTPUT): + out = Output(path_out) + scalars = out.scalars + t = scalars["en_B"].t.values + en_B = scalars["en_B"].values + en_tot = scalars["en_tot"].values + + results_dir = Path(path_out) / "results" + results_dir.mkdir(exist_ok=True) + + omega_exact = dispersion_roots(args.kappa, args.exb_current)[0] + # en_B ~ cos^2(omega t) = (1 + cos(2 omega t)) / 2 (plus the upper branch for kappa > 0, filtered by the fit) + omega = None + if len(t) > 10: + popt, _ = curve_fit( + lambda t, a, b, c, w: a * np.cos(2 * w * t) + b * np.sin(2 * w * t) + c, + t, + en_B / en_B[0], + p0=(0.5, 0.0, 0.5, omega_exact), + ) + omega = abs(popt[3]) + print( + f"fitted omega = {omega:.5f}, analytical omega = {omega_exact:.5f} (rel. error {omega / omega_exact - 1:.2%})" + ) + else: + print("Too few saved time steps for a frequency fit.") + print(f"max relative change of en_tot: {np.max(np.abs(en_tot - en_tot[0])) / abs(en_tot[0]):.2e}") + + fig, axs = plt.subplots(1, 2, figsize=(11, 4)) + axs[0].plot(t, en_B, label="en_B") + axs[0].plot(t, scalars["en_E"].values, label="en_E") + if omega is not None: + axs[0].plot(t, en_B[0] * np.cos(omega_exact * t) ** 2, "r:", label=f"cos^2, omega = {omega_exact:.4f}") + axs[0].set_xlabel("t") + axs[0].legend() + axs[1].plot(t, (en_tot - en_tot[0]) / abs(en_tot[0])) + axs[1].set_xlabel("t") + axs[1].set_ylabel("(en_tot - en_tot(0)) / en_tot(0)") + fig.suptitle(f"Inertial Alfven wave, {out.path_out.name}") + fig.tight_layout() + fig.savefig(results_dir / "energies.png") + plt.close(fig) + + +if __name__ == "__main__": + pproc_parser = argparse.ArgumentParser(description="Post-process a saved simulation run.") + pproc_parser.add_argument("path_out", nargs="?", default=DEFAULT_OUTPUT, help="Output folder of the run.") + pproc_args, _ = pproc_parser.parse_known_args() + main(pproc_args.path_out) diff --git a/src/struphy/api/models/__init__.py b/src/struphy/api/models/__init__.py index 3a390184c..a2a51717f 100644 --- a/src/struphy/api/models/__init__.py +++ b/src/struphy/api/models/__init__.py @@ -7,6 +7,7 @@ from struphy.models.drift_kinetic_electrostatic_adiabatic import ( DriftKineticElectrostaticAdiabatic, ) +from struphy.models.drift_kinetic_maxwell import DriftKineticMaxwell from struphy.models.guiding_center import GuidingCenter from struphy.models.gyrokinetic_poisson import GyrokineticPoisson from struphy.models.hasegawa_wakatani import HasegawaWakatani @@ -56,6 +57,7 @@ "CurlCurl", "DeterministicParticleDiffusion", "DriftKineticElectrostaticAdiabatic", + "DriftKineticMaxwell", "GuidingCenter", "GyrokineticPoisson", "HasegawaWakatani", diff --git a/src/struphy/models/__init__.py b/src/struphy/models/__init__.py index ba12868c5..24a3c319f 100644 --- a/src/struphy/models/__init__.py +++ b/src/struphy/models/__init__.py @@ -14,6 +14,7 @@ "CurlCurl": "struphy.models.curl_curl", "DeterministicParticleDiffusion": "struphy.models.deterministic_particle_diffusion", "DriftKineticElectrostaticAdiabatic": "struphy.models.drift_kinetic_electrostatic_adiabatic", + "DriftKineticMaxwell": "struphy.models.drift_kinetic_maxwell", "GuidingCenter": "struphy.models.guiding_center", "GyrokineticPoisson": "struphy.models.gyrokinetic_poisson", "HasegawaWakatani": "struphy.models.hasegawa_wakatani", @@ -60,6 +61,7 @@ from struphy.models.curl_curl import CurlCurl from struphy.models.deterministic_particle_diffusion import DeterministicParticleDiffusion from struphy.models.drift_kinetic_electrostatic_adiabatic import DriftKineticElectrostaticAdiabatic + from struphy.models.drift_kinetic_maxwell import DriftKineticMaxwell from struphy.models.guiding_center import GuidingCenter from struphy.models.gyrokinetic_poisson import GyrokineticPoisson from struphy.models.hasegawa_wakatani import HasegawaWakatani @@ -105,6 +107,7 @@ "CurlCurl", "DeterministicParticleDiffusion", "DriftKineticElectrostaticAdiabatic", + "DriftKineticMaxwell", "GuidingCenter", "GyrokineticPoisson", "HasegawaWakatani", diff --git a/src/struphy/models/drift_kinetic_maxwell.py b/src/struphy/models/drift_kinetic_maxwell.py new file mode 100644 index 000000000..93371ed15 --- /dev/null +++ b/src/struphy/models/drift_kinetic_maxwell.py @@ -0,0 +1,278 @@ +import copy +import logging + +from struphy import BaseUnits +from struphy.io.options import LiteralOptions +from struphy.models.base import StruphyModel +from struphy.models.scalars import BilinearEnergyFEEC, FunctionScalarFEEC, KineticEnergyPIC, Scalars +from struphy.models.species import ( + FieldSpecies, + ParticleSpecies, +) +from struphy.models.variables import FEECVariable, PICVariable +from struphy.propagators.drift_kinetic_maxwell_coupling import DriftKineticMaxwellCoupling +from struphy.propagators.push_guiding_center_bx_estar import PushGuidingCenterBxEstar +from struphy.propagators.push_guiding_center_parallel import PushGuidingCenterParallel + +logger = logging.getLogger("struphy") + + +class DriftKineticMaxwell(StruphyModel): + """Electromagnetic drift-kinetic equation for one guiding-center species coupled to Maxwell's equations, with an + implicit, energy conserving field-particle coupling that also holds in the quasi-neutral limit. + + The electric field enters the guiding-center equations through the gauge-invariant + :math:`\\mathbf E^* = \\mathbf E - \\varepsilon \\mu \\nabla |B_0|`. Ampère's law contains the guiding-center + current and the (linearized) polarization current; option ``kappa`` of the coupling propagator + :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling` multiplies the + displacement current, ``kappa=0.0`` is the quasi-neutral limit. + + Parameters + ---------- + base_units: BaseUnits + Base units for normalization (default: BaseUnits()) + charge_number: int + Charge number (in units of the positive elementary charge) of the species (default: 1) + mass_number: float + Mass number (in units of Proton mass) of the species (default: 1.0) + alpha: float, optional + Dimensionless parameter: plasma frequency / cyclotron frequency. If None, computed from units and charge/mass numbers. + epsilon: float, optional + Normalized cyclotron period: 1 / (cyclotron frequency × time unit). If None, computed from units and charge/mass numbers. + """ + + @classmethod + def model_type(cls) -> LiteralOptions.ModelTypes: + return "Kinetic" + + ## species + + class EMFields(FieldSpecies): + def __init__(self): + self.e_field = FEECVariable(space="Hcurl") + self.b_field = FEECVariable(space="Hdiv") + self.init_variables() + + class KineticIons(ParticleSpecies): + def __init__( + self, + charge_number: int = 1, + mass_number: float = 1.0, + alpha: float = None, + epsilon: float = None, + ): + self.var = PICVariable(space="Particles5D") + self.init_variables( + charge_number=charge_number, + mass_number=mass_number, + alpha=alpha, + epsilon=epsilon, + ) + + ## propagators + + class Propagators: + def __init__( + self, + e_field: FEECVariable = None, + b_field: FEECVariable = None, + ): + self.coupling = DriftKineticMaxwellCoupling() + self.push_para = PushGuidingCenterParallel(b_tilde=b_field) + self.push_bxe = PushGuidingCenterBxEstar(b_tilde=b_field, e_field=e_field) + + ## abstract methods + + def __init__( + self, + base_units: BaseUnits = BaseUnits(), + charge_number: int = 1, + mass_number: float = 1.0, + alpha: float = None, + epsilon: float = None, + ): + # 0. store input parameters + self.params = copy.deepcopy(locals()) + + # 1. instantiate all species + self.em_fields = self.EMFields() + self.kinetic_ions = self.KineticIons( + charge_number, + mass_number, + alpha, + epsilon, + ) + + # 2. derive units (must be done after instantiating species to access charge and mass numbers) + self.setup_equation_params(base_units=base_units) + + # 3. instantiate all propagators + self.propagators = self.Propagators(e_field=self.em_fields.e_field, b_field=self.em_fields.b_field) + + # 4. assign variables to propagators + self.propagators.coupling.variables.e = self.em_fields.e_field + self.propagators.coupling.variables.b = self.em_fields.b_field + self.propagators.coupling.variables.ions = self.kinetic_ions.var + self.propagators.push_para.variables.ions = self.kinetic_ions.var + self.propagators.push_bxe.variables.ions = self.kinetic_ions.var + + # 5. define scalars to be tracked during simulation + self.scalars = Scalars( + en_E=BilinearEnergyFEEC(self.em_fields.e_field), + en_B=BilinearEnergyFEEC(self.em_fields.b_field), + en_f=KineticEnergyPIC( + self.kinetic_ions.var, + normalization=self.kinetic_ions.equation_params.alpha**2, + ), + en_tot=FunctionScalarFEEC(self.propagators.coupling.energy), + ) + + @property + def bulk_species(self): + return self.kinetic_ions + + @property + def velocity_scale(self): + return "light" + + def post_allocate(self): + """No initial field solve: the electric field is initialized from the parameter file. + + :meta private: + """ + + @classmethod + def doc_pde(cls): + r"""**PDEs solved by model:** + + Drift-kinetic equation for :math:`f(\mathbf X, v_\parallel, \mu, t)`: + + .. math:: + + \frac{\partial f}{\partial t} + \frac{v_\parallel \mathbf B^* + \mathbf E^* \times \mathbf b_0}{B^*_\parallel} + \cdot \nabla f + \frac{1}{\varepsilon} \frac{\mathbf B^* \cdot \mathbf E^*}{B^*_\parallel} + \frac{\partial f}{\partial v_\parallel} = 0\,, + + with + + .. math:: + + \mathbf E^* = \mathbf E - \varepsilon \mu \nabla |\mathbf B|\,,\qquad + \mathbf B^* = \mathbf B_0 + \varepsilon v_\parallel \nabla \times \mathbf b_0\,,\qquad + B^*_\parallel = \mathbf b_0 \cdot (\mathbf B_0 + \tilde{\mathbf B}) + \varepsilon v_\parallel + (\nabla \times \mathbf b_0) \cdot \mathbf b_0\,, + + where :math:`|\mathbf B| = \mathbf b_0 \cdot (\mathbf B_0 + \tilde{\mathbf B})`. + + Ampère's law with the displacement current (coefficient :math:`\kappa`) and the polarization current + (coefficient :math:`\chi`, default :math:`\alpha^2`), options of + :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling`: + + .. math:: + + \kappa \frac{\partial \mathbf E}{\partial t} + + \chi \frac{n_0}{|B_0|^2} \frac{\partial \mathbf E_\perp}{\partial t} + = \nabla \times \tilde{\mathbf B} - \frac{\alpha^2}{\varepsilon} \iint f B^*_\parallel + \left( v_\parallel \frac{\mathbf B^*}{B^*_\parallel} + \frac{\mathbf E \times \mathbf b_0}{B^*_\parallel} + \right) \textrm d v_\parallel \textrm d \mu\,, + + Faraday's law: + + .. math:: + + \frac{\partial \tilde{\mathbf B}}{\partial t} + \nabla \times \mathbf E = 0\,. + + :math:`\mathbf B_0` is a static equilibrium field, :math:`n_0` the equilibrium density. The current in + Ampère's law is the guiding-center current without the magnetic (grad-:math:`B`) drift, whose work on + :math:`\mathbf E` is therefore missing where :math:`\nabla |B| \neq 0`. For :math:`\kappa = 0` (quasi-neutral + limit) the parallel electric field is determined by the parallel current (Ohm's law with inertia), the + perpendicular one by the polarization current. + """ + + @classmethod + def doc_normalization(cls): + r"""The model uses the light speed as reference velocity: + + .. math:: + + \hat v = c,\qquad \hat E = \hat B \hat v\,. + + The species parameters are :math:`\alpha=\hat\Omega_p/\hat\Omega_c` and + :math:`\varepsilon=1/(\hat\Omega_c\hat t)`. The polarization coefficient :math:`\chi = \alpha^2` times + :math:`n_0/|B_0|^2` is :math:`c^2/v_A^2` of the species.""" + + @classmethod + def doc_scalar_quantities(cls): + r"""**The following scalars are tracked during simulation:** + + - Electric field energy :math:`\frac 12 \|\mathbf E\|^2`: ``en_E`` + - Magnetic field energy :math:`\frac 12 \|\tilde{\mathbf B}\|^2`: ``en_B`` + - Parallel kinetic energy :math:`\alpha^2 \sum_p w_p v_{\parallel,p}^2/2`: ``en_f`` + - Total energy conserved by the coupling step, + :math:`\frac{\kappa}{2} \| \mathbf E \|^2 + \frac{\chi}{2} \| \mathbf E_\perp \|^2_{n_0/|B_0|^2} + + \frac{1}{2} \| \tilde{\mathbf B} \|^2 + \alpha^2 \sum_p w_p (v_{\parallel,p}^2/2 + \mu_p |B_0|)`: + ``en_tot``""" + + @classmethod + def doc_discretization(cls): + """Time integration is performed by the following propagators (in sequence): + + 1. :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling` + 2. :class:`~struphy.propagators.push_guiding_center_parallel.PushGuidingCenterParallel` + 3. :class:`~struphy.propagators.push_guiding_center_bx_estar.PushGuidingCenterBxEstar` + """ + doc = rf"""**1. DriftKineticMaxwellCoupling:** + +{DriftKineticMaxwellCoupling.__doc__} + +**2. PushGuidingCenterParallel:** + +{PushGuidingCenterParallel.__doc__} + +**3. PushGuidingCenterBxEstar:** + +{PushGuidingCenterBxEstar.__doc__} +""" + return doc + + @classmethod + def doc_long_description(cls): + r"""DriftKineticMaxwell couples one drift-kinetic (guiding-center) species to Maxwell's equations in the + gauge-invariant :math:`v_\parallel` formulation (the electric field, not a potential, enters + :math:`\mathbf E^*`). Maxwell's equations and the parallel acceleration are solved together in one implicit, + energy conserving step, which allows time steps above the plasma period and has no cancellation problem. + Since the drift-kinetic ordering removes the polarization drift, its (linearized) polarization current is + added to Ampère's law; with it, the quasi-neutral limit without displacement current is well-posed and gives + the inertial Alfvén wave. The guiding centers then stream along the field and drift with + :math:`\mathbf E^* \times \mathbf b_0`. The total energy is conserved exactly by the splitting if + :math:`\nabla |B_0| = 0` and either :math:`\mu = 0` or :math:`\mathbf b_0 \cdot \tilde{\mathbf B} = 0`.""" + + @classmethod + def doc_examples(cls): + r"""Create a drift-kinetic Maxwell model in the quasi-neutral limit: + + .. code-block:: python + + from struphy.models import DriftKineticMaxwell + + model = DriftKineticMaxwell() + prop = model.propagators.coupling + prop.options = prop.Options(kappa=0.0) + """ + + @classmethod + def doc_use_cases(cls): + r"""This model is appropriate for: + + - electromagnetic drift-kinetic simulations with time steps above the plasma period + - shear (inertial) Alfvén waves with kinetic parallel dynamics + - the quasi-neutral limit (``kappa=0.0``) with full-f weights""" + + @classmethod + def doc_cannot_be_used_for(cls): + r"""This model is not suitable for: + + - polar splines (not supported yet by the coupling propagator) + - the quasi-neutral limit with control-variate (delta-f) weights or with vacuum regions + - finite Larmor radius effects beyond the linearized long-wavelength polarization + - exact energy conservation in inhomogeneous :math:`|B_0|` (the grad-:math:`B` drift current is missing)""" diff --git a/src/struphy/models/tests/verification/test_verif_DriftKineticMaxwell.py b/src/struphy/models/tests/verification/test_verif_DriftKineticMaxwell.py new file mode 100644 index 000000000..f4e56bb36 --- /dev/null +++ b/src/struphy/models/tests/verification/test_verif_DriftKineticMaxwell.py @@ -0,0 +1,141 @@ +"""Verification of DriftKineticMaxwell: the shear (inertial) Alfvén wave of a cold guiding-center plasma in a slab +with uniform B0 = e_z, against the roots of the dispersion relation + +.. math:: + + \\omega^2 (\\kappa \\mathbf E + \\chi \\mathbf E_\\perp) = c_1 (k^2 \\mathbf E - \\mathbf k\\, \\mathbf k \\cdot \\mathbf E) + + P E_z \\mathbf e_z - \\mathrm i \\omega H\\, \\mathbf E \\times \\mathbf e_z\\,, + +with :math:`P = c_2 c_3 n_0` (parallel response) and :math:`H = c_2 n_0` (E x B current, zero if switched off). +For :math:`\\kappa = 0` and :math:`H = 0` the shear branch is the inertial Alfvén wave +:math:`\\omega^2 = (c_1/\\chi)\\, k_z^2 / (1 + k_x^2 c_1/P)`. The energy is conserved exactly by the splitting +(cold plasma, uniform B0). + +Run it on several processes with:: + + mpirun -n 4 pytest -p no:xdist --with-mpi src/struphy/models/tests/verification/test_verif_DriftKineticMaxwell.py +""" + +import logging + +import numpy as np +import pytest + +from struphy import set_logging_level + +set_logging_level(logging.WARNING) + +ALPHA, EPSILON = 3.0, 0.5 +LX, LZ = 1.0, 4.0 +VTH = 1e-6 # cold + + +def _dispersion_roots(kappa, exb_current): + """Positive real roots omega of the determinant of the cold-slab dispersion matrix (polynomial of degree 6, + interpolated exactly from 7 values).""" + c1, c2, c3 = 1.0, ALPHA**2 / EPSILON, 1.0 / EPSILON + chi, P, H = ALPHA**2, c2 * c3, c2 if exb_current else 0.0 + k = np.array([2 * np.pi / LX, 0.0, 2 * np.pi / LZ]) + proj_perp = np.diag([1.0, 1.0, 0.0]) + cross_z = np.array([[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) # E -> E x e_z + + def det(w): + mat = ( + w**2 * (kappa * np.eye(3) + chi * proj_perp) + - c1 * (k @ k * np.eye(3) - np.outer(k, k)) + - P * np.diag([0.0, 0.0, 1.0]) + + 1j * w * H * cross_z + ) + return np.linalg.det(mat) + + nodes = np.linspace(-1.0, 1.0, 7) + coeffs = np.polyfit(nodes, [det(w).real for w in nodes], 6) + roots = np.roots(coeffs) + return np.sort(roots[(abs(roots.imag) < 1e-8) & (roots.real > 1e-8)].real) + + +def _alfven_signal(kappa, exb_current, dt, n_steps): + """Time series s(t) = b(t)^T M2 b(0) / |b(0)|^2 of the shear Alfvén wave, excited by a B_y perturbation, and the + relative change of the energy.""" + from struphy import ( + DerhamOptions, + LoadingParameters, + Simulation, + domains, + equils, + grids, + maxwellians, + models, + perturbations, + ) + from struphy.linear_algebra.solver import SolverParameters + + model = models.DriftKineticMaxwell(alpha=ALPHA, epsilon=EPSILON) + model.kinetic_ions.set_markers( + loading_params=LoadingParameters(ppc=50, seed=1234, moments=(0.0, 0.0, VTH, VTH), B0=1.0) + ) + cold = dict(n=(1.0, None), vth_para=(VTH, None), vth_perp=(VTH, None), B0=1.0) + model.kinetic_ions.var.add_background(maxwellians.GyroMaxwellian2D(**cold)) + model.kinetic_ions.var.add_initial_condition(maxwellians.GyroMaxwellian2D(**cold)) + model.em_fields.b_field.add_perturbation( + perturbations.ModesSin(ls=(1,), ns=(1,), given_in_basis="2", comp=1, amps=(1e-2,)) + ) + prop = model.propagators.coupling + solver = {} if exb_current else {"solver": "pcg"} + prop.options = prop.Options( + kappa=kappa, exb_current=exb_current, solver_params=SolverParameters(tol=1e-12, maxiter=3000), **solver + ) + Simulation( + model=model, + domain=domains.Cuboid(r1=LX, r2=1.0, r3=LZ), + equil=equils.HomogenSlab(B0z=1.0), + grid=grids.TensorProductGrid(num_elements=(8, 1, 8)), + derham_opts=DerhamOptions(degree=(3, 1, 3)), + ).allocate() + + props = model.propagators + prop.prepare_time_step(dt) # the initial projection for kappa = 0 + energy_0 = prop.energy() + b = model.em_fields.b_field.spline.vector + b_init = b.copy() + m2_b_init = prop._M2.dot(b_init) + signal = [b.inner(m2_b_init)] + for _ in range(n_steps): + props.coupling(dt) + props.push_para(dt) + props.push_bxe(dt) + signal.append(b.inner(m2_b_init)) + return dt * np.arange(n_steps + 1), np.array(signal) / signal[0], prop.energy() / energy_0 - 1.0 + + +def _fit(t, s, omegas): + """Least-squares fit of s(t) by a constant plus one harmonic per initial guess in omegas.""" + from scipy.optimize import curve_fit + + def model(t, c, *p): + out = c + 0.0 * t + for i in range(len(p) // 3): + out = out + p[3 * i] * np.cos(p[3 * i + 2] * t) + p[3 * i + 1] * np.sin(p[3 * i + 2] * t) + return out + + p0 = [0.0] + for w in omegas: + p0 += [0.5, 0.0, w] + popt, _ = curve_fit(model, t, s, p0=p0) + return np.abs(popt[3::3]) + + +@pytest.mark.parametrize( + "kappa, exb_current, dt, n_steps", + [(0.0, False, 0.5, 160), (0.0, True, 0.5, 160), (1.0, False, 0.1, 400)], +) +def test_shear_alfven_wave(kappa, exb_current, dt, n_steps): + roots = _dispersion_roots(kappa, exb_current) + t, s, energy_change = _alfven_signal(kappa, exb_current, dt, n_steps) + if kappa > 0.0: + # the B_y perturbation also excites the fast (light-wave like) upper branch of the shear mode + omegas = _fit(t, s, (roots[0], roots[-1])) + else: + omegas = _fit(t, s, (roots[0],)) + assert abs(omegas[0] / roots[0] - 1.0) < 0.02, (omegas, roots) + assert abs(energy_change) < 1e-7 diff --git a/src/struphy/pic/accumulation/kernels/dk_maxwell/__init__.py b/src/struphy/pic/accumulation/kernels/dk_maxwell/__init__.py new file mode 100644 index 000000000..9039bc198 --- /dev/null +++ b/src/struphy/pic/accumulation/kernels/dk_maxwell/__init__.py @@ -0,0 +1,10 @@ +"""The kernel of this folder: pyccel in ``_kernels.py``, CUDA in ``_cuda.cu`` once ported.""" + +from cunumpy.kernels import Kernel + +from struphy.utils.cuda_arguments import CUDA_STRUCTS + +# the arguments the kernel writes to, by position: mat11 to vec3 +OUTPUTS = tuple(range(3, 15)) + +dk_maxwell = Kernel.from_folder(__name__, structs=CUDA_STRUCTS, host_options={"outputs": OUTPUTS}) diff --git a/src/struphy/pic/accumulation/kernels/dk_maxwell/dk_maxwell_kernels.py b/src/struphy/pic/accumulation/kernels/dk_maxwell/dk_maxwell_kernels.py new file mode 100644 index 000000000..1bd4a1dbe --- /dev/null +++ b/src/struphy/pic/accumulation/kernels/dk_maxwell/dk_maxwell_kernels.py @@ -0,0 +1,156 @@ +"Accumulation kernel for gyro-center (5D) particles." + +from numpy import empty +from pyccel.decorators import stack_array + +import struphy.geometry.evaluation_kernels as evaluation_kernels +import struphy.kernel_arguments.pusher_args_kernels as pusher_args_kernels # do not remove; needed to identify dependencies +import struphy.linear_algebra.linalg_kernels as linalg_kernels +import struphy.pic.accumulation.particle_to_mat_kernels as particle_to_mat_kernels +from struphy.bsplines.evaluation_kernels_3d import ( + eval_0form_spline_mpi, + eval_1form_spline_mpi, + eval_2form_spline_mpi, + get_spans, +) +from struphy.kernel_arguments.pusher_args_kernels import DerhamArguments, DomainArguments, MarkerArguments + + +@stack_array("dfm", "b2", "b_star", "unit_b1", "a", "filling_m") +def dk_maxwell( + args_markers: "MarkerArguments", + args_derham: "DerhamArguments", + args_domain: "DomainArguments", + mat11: "float[:,:,:,:,:,:]", + mat12: "float[:,:,:,:,:,:]", + mat13: "float[:,:,:,:,:,:]", + mat21: "float[:,:,:,:,:,:]", + mat22: "float[:,:,:,:,:,:]", + mat23: "float[:,:,:,:,:,:]", + mat31: "float[:,:,:,:,:,:]", + mat32: "float[:,:,:,:,:,:]", + mat33: "float[:,:,:,:,:,:]", + vec1: "float[:,:,:]", + vec2: "float[:,:,:]", + vec3: "float[:,:,:]", + epsilon: float, + lam: float, + b2_1: "float[:,:,:]", + b2_2: "float[:,:,:]", + b2_3: "float[:,:,:]", + curl_unit_b2_1: "float[:,:,:]", + curl_unit_b2_2: "float[:,:,:]", + curl_unit_b2_3: "float[:,:,:]", + unit_b1_1: "float[:,:,:]", + unit_b1_2: "float[:,:,:]", + unit_b1_3: "float[:,:,:]", + B_dot_b_coeffs: "float[:,:,:]", + curl_unit_b_dot_b0: "float[:,:,:]", +): + r"""Accumulation kernel for the propagator + :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling`. Accumulates into + :math:`V^1_h` with the (non-symmetric) filling functions + + .. math:: + + A_p^{\mu\nu} = w_p \left( a_{p,\mu} a_{p,\nu} + \lambda\, S_{p,\mu\nu} \right)\,, + \qquad + B_p^\mu = w_p\, v_{\parallel,p}\, a_{p,\mu}\,, + \qquad + \mathbf a_p = \frac{\hat{\mathbf B}^{*2}}{\hat B^{*3}_\parallel}(\boldsymbol \eta_p)\,, + \qquad + S_p \mathbf x = \frac{\mathbf x \times \hat{\mathbf b}^1_0}{\hat B^{*3}_\parallel}(\boldsymbol \eta_p)\,, + + with :math:`\hat{\mathbf B}^{*2} = \hat{\mathbf B}^2 + \varepsilon v_\parallel \hat \nabla \times + \hat{\mathbf b}^1_0` and :math:`\hat B^{*3}_\parallel = \sqrt g\, (\hat B_\parallel + \varepsilon v_\parallel + \widehat{(\nabla \times \mathbf b_0) \cdot \mathbf b_0})`, as in + :class:`~struphy.propagators.push_guiding_center_parallel.PushGuidingCenterParallel` and + :class:`~struphy.propagators.push_guiding_center_bx_estar.PushGuidingCenterBxEstar`. The rank-one part is the + parallel response, the antisymmetric part :math:`S_p` the :math:`E \times B` current, scaled by ``lam`` + (:math:`\lambda`). + """ + + markers = args_markers.markers + + dfm = empty((3, 3), dtype=float) + b2 = empty(3, dtype=float) + b_star = empty(3, dtype=float) + unit_b1 = empty(3, dtype=float) + a = empty(3, dtype=float) + filling_m = empty((3, 3), dtype=float) + + for ip in range(args_markers.n_markers): + # only do something if particle is a "true" particle (i.e. not a hole) + if markers[0, ip] == -1.0: + continue + + eta1 = markers[0, ip] + eta2 = markers[1, ip] + eta3 = markers[2, ip] + v = markers[3, ip] + weight = markers[5, ip] + + evaluation_kernels.df(eta1, eta2, eta3, args_domain, dfm) + det_df = linalg_kernels.det(dfm) + + span1, span2, span3 = get_spans(eta1, eta2, eta3, args_derham) + + # B* (2-form) and B*_parallel (3-form) + eval_2form_spline_mpi(span1, span2, span3, args_derham, b2_1, b2_2, b2_3, b2) + eval_2form_spline_mpi(span1, span2, span3, args_derham, curl_unit_b2_1, curl_unit_b2_2, curl_unit_b2_3, b_star) + b_star *= epsilon * v + b_star += b2 + + b_star_parallel = eval_0form_spline_mpi(span1, span2, span3, args_derham, curl_unit_b_dot_b0) + b_star_parallel *= epsilon * v + b_star_parallel += eval_0form_spline_mpi(span1, span2, span3, args_derham, B_dot_b_coeffs) + b_star_parallel *= det_df + + a[:] = b_star / b_star_parallel + + # unit vector b0 (1-form) + eval_1form_spline_mpi(span1, span2, span3, args_derham, unit_b1_1, unit_b1_2, unit_b1_3, unit_b1) + + # filling: w (a a^T + lam S), with S x = x cross b0 / B*_parallel + for i in range(3): + for j in range(3): + filling_m[i, j] = weight * a[i] * a[j] + + s = weight * lam / b_star_parallel + filling_m[0, 1] += s * unit_b1[2] + filling_m[0, 2] -= s * unit_b1[1] + filling_m[1, 0] -= s * unit_b1[2] + filling_m[1, 2] += s * unit_b1[0] + filling_m[2, 0] += s * unit_b1[1] + filling_m[2, 1] -= s * unit_b1[0] + + particle_to_mat_kernels.m_v_fill_b_v1_full( + args_derham, + eta1, + eta2, + eta3, + mat11, + mat12, + mat13, + mat21, + mat22, + mat23, + mat31, + mat32, + mat33, + filling_m[0, 0], + filling_m[0, 1], + filling_m[0, 2], + filling_m[1, 0], + filling_m[1, 1], + filling_m[1, 2], + filling_m[2, 0], + filling_m[2, 1], + filling_m[2, 2], + vec1, + vec2, + vec3, + weight * v * a[0], + weight * v * a[1], + weight * v * a[2], + ) diff --git a/src/struphy/pic/accumulation/kernels/dk_maxwell_mf/__init__.py b/src/struphy/pic/accumulation/kernels/dk_maxwell_mf/__init__.py new file mode 100644 index 000000000..f1daa91ad --- /dev/null +++ b/src/struphy/pic/accumulation/kernels/dk_maxwell_mf/__init__.py @@ -0,0 +1,10 @@ +"""The kernel of this folder: pyccel in ``_kernels.py``, CUDA in ``_cuda.cu`` once ported.""" + +from cunumpy.kernels import Kernel + +from struphy.utils.cuda_arguments import CUDA_STRUCTS + +# the arguments the kernel writes to, by position: args_markers, vec1, vec2, vec3 +OUTPUTS = (0, 3, 4, 5) + +dk_maxwell_mf = Kernel.from_folder(__name__, structs=CUDA_STRUCTS, host_options={"outputs": OUTPUTS}) diff --git a/src/struphy/pic/accumulation/kernels/dk_maxwell_mf/dk_maxwell_mf_kernels.py b/src/struphy/pic/accumulation/kernels/dk_maxwell_mf/dk_maxwell_mf_kernels.py new file mode 100644 index 000000000..f869e9e42 --- /dev/null +++ b/src/struphy/pic/accumulation/kernels/dk_maxwell_mf/dk_maxwell_mf_kernels.py @@ -0,0 +1,117 @@ +"Accumulation kernel for gyro-center (5D) particles, matrix-free variant of dk_maxwell." + +from numpy import empty +from pyccel.decorators import stack_array + +import struphy.geometry.evaluation_kernels as evaluation_kernels +import struphy.kernel_arguments.pusher_args_kernels as pusher_args_kernels # do not remove; needed to identify dependencies +import struphy.linear_algebra.linalg_kernels as linalg_kernels +import struphy.pic.accumulation.particle_to_mat_kernels as particle_to_mat_kernels +from struphy.bsplines.evaluation_kernels_3d import ( + eval_0form_spline_mpi, + eval_1form_spline_mpi, + eval_2form_spline_mpi, + get_spans, +) +from struphy.kernel_arguments.pusher_args_kernels import DerhamArguments, DomainArguments, MarkerArguments + + +@stack_array("dfm", "b2", "b_star", "unit_b1", "a") +def dk_maxwell_mf( + args_markers: "MarkerArguments", + args_derham: "DerhamArguments", + args_domain: "DomainArguments", + vec1: "float[:,:,:]", + vec2: "float[:,:,:]", + vec3: "float[:,:,:]", + epsilon: float, + b2_1: "float[:,:,:]", + b2_2: "float[:,:,:]", + b2_3: "float[:,:,:]", + curl_unit_b2_1: "float[:,:,:]", + curl_unit_b2_2: "float[:,:,:]", + curl_unit_b2_3: "float[:,:,:]", + unit_b1_1: "float[:,:,:]", + unit_b1_2: "float[:,:,:]", + unit_b1_3: "float[:,:,:]", + B_dot_b_coeffs: "float[:,:,:]", + curl_unit_b_dot_b0: "float[:,:,:]", + column: "int", +): + r"""Accumulation kernel for the propagator + :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling` (matrix-free). + Accumulates the vector :math:`B_p^\mu = w_p v_{\parallel,p} a_{p,\mu}` into :math:`V^1_h` and stores, per marker, + the data of the two particle matrices of + :func:`~struphy.pic.accumulation.kernels.dk_maxwell.dk_maxwell_kernels.dk_maxwell` + (see :class:`~struphy.pic.accumulation.particles_to_grid.MarkerOperator`): + + - the rank-one filling :math:`w_p\, \mathbf a_p \mathbf a_p^\top` in the columns ``column`` to ``column + 3`` + (:math:`\mathbf a_p` and :math:`w_p`), applied by + :func:`~struphy.pic.accumulation.kernels.marker_operator_rank1_dot.marker_operator_rank1_dot_kernels.marker_operator_rank1_dot`; + - the antisymmetric filling :math:`w_p S_p`, :math:`S_p \mathbf x = \mathbf x \times \hat{\mathbf b}^1_0 / + \hat B^{*3}_\parallel`, entries 12, 13, 23 in the columns ``column + 4`` to ``column + 6``, applied by + :func:`~struphy.pic.accumulation.kernels.marker_operator_asym_dot.marker_operator_asym_dot_kernels.marker_operator_asym_dot`. + """ + + markers = args_markers.markers + + dfm = empty((3, 3), dtype=float) + b2 = empty(3, dtype=float) + b_star = empty(3, dtype=float) + unit_b1 = empty(3, dtype=float) + a = empty(3, dtype=float) + + for ip in range(args_markers.n_markers): + # only do something if particle is a "true" particle (i.e. not a hole) + if markers[0, ip] == -1.0: + continue + + eta1 = markers[0, ip] + eta2 = markers[1, ip] + eta3 = markers[2, ip] + v = markers[3, ip] + weight = markers[5, ip] + + evaluation_kernels.df(eta1, eta2, eta3, args_domain, dfm) + det_df = linalg_kernels.det(dfm) + + span1, span2, span3 = get_spans(eta1, eta2, eta3, args_derham) + + # B* (2-form) and B*_parallel (3-form) + eval_2form_spline_mpi(span1, span2, span3, args_derham, b2_1, b2_2, b2_3, b2) + eval_2form_spline_mpi(span1, span2, span3, args_derham, curl_unit_b2_1, curl_unit_b2_2, curl_unit_b2_3, b_star) + b_star *= epsilon * v + b_star += b2 + + b_star_parallel = eval_0form_spline_mpi(span1, span2, span3, args_derham, curl_unit_b_dot_b0) + b_star_parallel *= epsilon * v + b_star_parallel += eval_0form_spline_mpi(span1, span2, span3, args_derham, B_dot_b_coeffs) + b_star_parallel *= det_df + + a[:] = b_star / b_star_parallel + + # unit vector b0 (1-form) + eval_1form_spline_mpi(span1, span2, span3, args_derham, unit_b1_1, unit_b1_2, unit_b1_3, unit_b1) + + s = weight / b_star_parallel + + markers[column, ip] = a[0] + markers[column + 1, ip] = a[1] + markers[column + 2, ip] = a[2] + markers[column + 3, ip] = weight + markers[column + 4, ip] = s * unit_b1[2] + markers[column + 5, ip] = -s * unit_b1[1] + markers[column + 6, ip] = s * unit_b1[0] + + particle_to_mat_kernels.vec_fill_v1( + args_derham, + span1, + span2, + span3, + vec1, + vec2, + vec3, + weight * v * a[0], + weight * v * a[1], + weight * v * a[2], + ) diff --git a/src/struphy/pic/pushing/kernels/push_vpar_with_efield/__init__.py b/src/struphy/pic/pushing/kernels/push_vpar_with_efield/__init__.py new file mode 100644 index 000000000..e6dd0abaf --- /dev/null +++ b/src/struphy/pic/pushing/kernels/push_vpar_with_efield/__init__.py @@ -0,0 +1,10 @@ +"""The kernel of this folder: pyccel in ``_kernels.py``, CUDA in ``_cuda.cu`` once ported.""" + +from cunumpy.kernels import Kernel + +from struphy.utils.cuda_arguments import CUDA_STRUCTS + +# the arguments the kernel writes to, by position: args_markers +OUTPUTS = (2,) + +push_vpar_with_efield = Kernel.from_folder(__name__, structs=CUDA_STRUCTS, host_options={"outputs": OUTPUTS}) diff --git a/src/struphy/pic/pushing/kernels/push_vpar_with_efield/push_vpar_with_efield_kernels.py b/src/struphy/pic/pushing/kernels/push_vpar_with_efield/push_vpar_with_efield_kernels.py new file mode 100644 index 000000000..e5a5e7b11 --- /dev/null +++ b/src/struphy/pic/pushing/kernels/push_vpar_with_efield/push_vpar_with_efield_kernels.py @@ -0,0 +1,90 @@ +"Pusher kernel for gyro-center (5D) particles." + +from numpy import empty +from pyccel.decorators import stack_array + +import struphy.geometry.evaluation_kernels as evaluation_kernels +import struphy.kernel_arguments.pusher_args_kernels as pusher_args_kernels # do not remove; needed to identify dependencies +import struphy.linear_algebra.linalg_kernels as linalg_kernels +from struphy.bsplines.evaluation_kernels_3d import ( + eval_0form_spline_mpi, + eval_1form_spline_mpi, + eval_2form_spline_mpi, + get_spans, +) +from struphy.kernel_arguments.pusher_args_kernels import DerhamArguments, DomainArguments, MarkerArguments + + +@stack_array("dfm", "b2", "b_star", "e_form") +def push_vpar_with_efield( + dt: float, + stage: int, + args_markers: "MarkerArguments", + args_domain: "DomainArguments", + args_derham: "DerhamArguments", + epsilon: float, + e1_1: "float[:,:,:]", + e1_2: "float[:,:,:]", + e1_3: "float[:,:,:]", + b2_1: "float[:,:,:]", + b2_2: "float[:,:,:]", + b2_3: "float[:,:,:]", + curl_unit_b2_1: "float[:,:,:]", + curl_unit_b2_2: "float[:,:,:]", + curl_unit_b2_3: "float[:,:,:]", + B_dot_b_coeffs: "float[:,:,:]", + curl_unit_b_dot_b0: "float[:,:,:]", + const_factor: float, +): + r"""Updates the parallel velocities of guiding centers at fixed positions as + + .. math:: + + \frac{v_{\parallel,p}^{n+1} - v_{\parallel,p}^n}{\Delta t} + = c\, \frac{\hat{\mathbf B}^{*2}}{\hat B^{*3}_\parallel} \cdot \hat{\mathbf E}^1 (\boldsymbol \eta_p)\,, + + where :math:`\hat{\mathbf E}^1` is the 1-form spline field of the given FE coefficients, :math:`c` + (``const_factor``) a constant, and :math:`\hat{\mathbf B}^{*2}`, :math:`\hat B^{*3}_\parallel` are evaluated with + :math:`v_{\parallel,p}^n` as in + :func:`~struphy.pic.accumulation.kernels.dk_maxwell.dk_maxwell_kernels.dk_maxwell`. + """ + + dfm = empty((3, 3), dtype=float) + b2 = empty(3, dtype=float) + b_star = empty(3, dtype=float) + e_form = empty(3, dtype=float) + + markers = args_markers.markers + n_markers = args_markers.n_markers + valid_mks = args_markers.valid_mks + + for ip in range(n_markers): + # only do something if particle is valid (i.e. not a hole or ghost) + if not valid_mks[ip]: + continue + + eta1 = markers[0, ip] + eta2 = markers[1, ip] + eta3 = markers[2, ip] + v = markers[3, ip] + + evaluation_kernels.df(eta1, eta2, eta3, args_domain, dfm) + det_df = linalg_kernels.det(dfm) + + span1, span2, span3 = get_spans(eta1, eta2, eta3, args_derham) + + # B* (2-form) and B*_parallel (3-form) + eval_2form_spline_mpi(span1, span2, span3, args_derham, b2_1, b2_2, b2_3, b2) + eval_2form_spline_mpi(span1, span2, span3, args_derham, curl_unit_b2_1, curl_unit_b2_2, curl_unit_b2_3, b_star) + b_star *= epsilon * v + b_star += b2 + + b_star_parallel = eval_0form_spline_mpi(span1, span2, span3, args_derham, curl_unit_b_dot_b0) + b_star_parallel *= epsilon * v + b_star_parallel += eval_0form_spline_mpi(span1, span2, span3, args_derham, B_dot_b_coeffs) + b_star_parallel *= det_df + + # electric field (1-form) + eval_1form_spline_mpi(span1, span2, span3, args_derham, e1_1, e1_2, e1_3, e_form) + + markers[3, ip] += dt * const_factor * linalg_kernels.scalar_dot(b_star, e_form) / b_star_parallel diff --git a/src/struphy/pic/tests/test_kernel_backends.py b/src/struphy/pic/tests/test_kernel_backends.py index eae9d2adc..b7e56cb47 100644 --- a/src/struphy/pic/tests/test_kernel_backends.py +++ b/src/struphy/pic/tests/test_kernel_backends.py @@ -194,8 +194,8 @@ def test_catalog_signatures(): for catalog in CATALOGS.values(): catalog.check_signatures() assert {package.split(".", 1)[1]: len(catalog) for package, catalog in CATALOGS.items()} == { - "pic.pushing.kernels": 45, - "pic.accumulation.kernels": 27, + "pic.pushing.kernels": 46, + "pic.accumulation.kernels": 29, "pic.diagnostics.kernels": 10, "pic.sph.kernels": 4, "pic.kernels": 4, diff --git a/src/struphy/propagators/__init__.py b/src/struphy/propagators/__init__.py index 770e51e8b..c9ba267c1 100644 --- a/src/struphy/propagators/__init__.py +++ b/src/struphy/propagators/__init__.py @@ -11,6 +11,7 @@ "CurrentCoupling5DGradB": "struphy.propagators.current_coupling_5d_gradb", "CurrentCoupling6DCurrent": "struphy.propagators.current_coupling_6d_current", "CurrentCoupling6DDensity": "struphy.propagators.current_coupling_6d_density", + "DriftKineticMaxwellCoupling": "struphy.propagators.drift_kinetic_maxwell_coupling", "EfieldWeightsCoupling": "struphy.propagators.efield_weights_coupling", "FaradayExtended": "struphy.propagators.faraday_extended", "GyrokineticPoissonSolve": "struphy.propagators.gyrokinetic_poisson_solve", @@ -62,6 +63,7 @@ from struphy.propagators.current_coupling_5d_gradb import CurrentCoupling5DGradB from struphy.propagators.current_coupling_6d_current import CurrentCoupling6DCurrent from struphy.propagators.current_coupling_6d_density import CurrentCoupling6DDensity + from struphy.propagators.drift_kinetic_maxwell_coupling import DriftKineticMaxwellCoupling from struphy.propagators.efield_weights_coupling import EfieldWeightsCoupling from struphy.propagators.faraday_extended import FaradayExtended from struphy.propagators.gyrokinetic_poisson_solve import GyrokineticPoissonSolve @@ -114,6 +116,7 @@ "CurrentCoupling5DGradB", "CurrentCoupling6DCurrent", "CurrentCoupling6DDensity", + "DriftKineticMaxwellCoupling", "EfieldWeightsCoupling", "FaradayExtended", "GyrokineticPoissonSolve", diff --git a/src/struphy/propagators/drift_kinetic_maxwell_coupling.py b/src/struphy/propagators/drift_kinetic_maxwell_coupling.py new file mode 100644 index 000000000..9c831d110 --- /dev/null +++ b/src/struphy/propagators/drift_kinetic_maxwell_coupling.py @@ -0,0 +1,680 @@ +"Particle and FEEC variables are updated." + +import logging +from dataclasses import dataclass +from typing import Literal + +import cunumpy as xp +from feectools.linalg.solvers import inverse +from line_profiler import profile +from maybempi import MPI + +from struphy.feec import preconditioner +from struphy.io.options import LiteralOptions, OptionsBase +from struphy.linear_algebra.solver import SolverParameters +from struphy.models.variables import FEECVariable, PICVariable +from struphy.pic.accumulation.kernels.dk_maxwell import dk_maxwell +from struphy.pic.accumulation.kernels.dk_maxwell_mf import dk_maxwell_mf +from struphy.pic.accumulation.kernels.marker_operator_asym_dot import marker_operator_asym_dot +from struphy.pic.accumulation.kernels.marker_operator_rank1_dot import marker_operator_rank1_dot +from struphy.pic.accumulation.particles_to_grid import Accumulator, AccumulatorVector, MarkerOperator +from struphy.pic.pushing.kernels.push_vpar_with_efield import push_vpar_with_efield +from struphy.pic.pushing.pusher import Pusher +from struphy.propagators.base import Propagator +from struphy.utils.utils import check_option + +logger = logging.getLogger("struphy") + + +class DriftKineticMaxwellCoupling(Propagator): + r"""PIC-FEEC discretization of the following equations: + find :math:`\mathbf E \in H(\textnormal{curl})`, :math:`\tilde{\mathbf B} \in H(\textnormal{div})` and the + guiding-center distribution :math:`f(\mathbf X, v_\parallel, \mu)` (positions fixed in this step) such that + + .. math:: + + \kappa \int_{\Omega} \frac{\partial \mathbf E}{\partial t} \cdot \mathbf F \,\textrm d \mathbf x + + \chi \int_{\Omega} \frac{n_0}{|B_0|^2} \frac{\partial \mathbf E_\perp}{\partial t} \cdot \mathbf F_\perp + \,\textrm d \mathbf x + &= c_1 \int_{\Omega} \tilde{\mathbf B} \cdot \nabla \times \mathbf F \,\textrm d \mathbf x + - c_2 \int_{\Omega} \iint f^\textnormal{vol} \left( v_\parallel \frac{\mathbf B^*}{B^*_\parallel} + + \frac{\mathbf E \times \mathbf b_0}{B^*_\parallel} \right) \cdot \mathbf F + \,\textrm d v_\parallel \textrm d \mu \,\textrm d \mathbf x + \qquad \forall \, \mathbf F \in H(\textnormal{curl})\,, + \\[2mm] + \frac{\partial \tilde{\mathbf B}}{\partial t} + \nabla\times\mathbf E &= 0\,, + \\[2mm] + \frac{\partial f}{\partial t} + c_3\, \frac{\mathbf B^*}{B^*_\parallel} \cdot \mathbf E\, + \frac{\partial f}{\partial v_\parallel} &= 0\,, + + with :math:`\mathbf E_\perp = \mathbf E - (\mathbf b_0 \cdot \mathbf E) \mathbf b_0`, the phase-space + density :math:`f^\textnormal{vol} = B^*_\parallel f` (the marker weights) and, as in + :class:`~struphy.propagators.push_guiding_center_parallel.PushGuidingCenterParallel`, + + .. math:: + + \mathbf B^* = \mathbf B_0 + \varepsilon v_\parallel \nabla \times \mathbf b_0\,,\qquad + B^*_\parallel = \mathbf b_0 \cdot (\mathbf B_0 + \tilde{\mathbf B}) + \varepsilon v_\parallel + (\nabla \times \mathbf b_0) \cdot \mathbf b_0\,. + + The current is the guiding-center current :math:`\iint f^\textnormal{vol} \dot{\mathbf X}` without the + magnetic drifts (which move the positions): parallel streaming, including the curvature drift in + :math:`\mathbf B^*`, and (option ``exb_current``) the :math:`E \times B` drift. The coefficients are + :math:`\kappa \geq 0`, :math:`\chi \geq 0` with :math:`\kappa + \chi > 0`, :math:`c_1 > 0` and + :math:`c_2 c_3 > 0`; the defaults :math:`\kappa = 1`, :math:`\chi = \alpha^2`, :math:`c_1 = 1`, + :math:`c_2 = \alpha^2/\varepsilon`, :math:`c_3 = 1/\varepsilon` are the units of + :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies`. The :math:`\chi` term is the + (linearized, long-wavelength) polarization current of the guiding centers, which the drift-kinetic ordering + removes from :math:`\dot{\mathbf X}`: without it, the perpendicular electrostatic field is undetermined for + :math:`\kappa = 0` (flute modes), and for :math:`\kappa = 1` the shear Alfvén wave travels at the speed of light. + + With the FE coefficients :math:`\mathbf e \in \mathbb R^{N_1}` of :math:`\mathbf E \in V^1_h` and + :math:`\mathbf b \in \mathbb R^{N_2}` of :math:`\tilde{\mathbf B} \in V^2_h`, and :math:`N` markers with logical + positions :math:`\boldsymbol \eta_p` (fixed), parallel velocities :math:`v_{\parallel,p}` and weights :math:`w_p`, + the semi-discrete system reads + + .. math:: + + \mathbb M_E \dot{\mathbf e} = c_1\, \mathbb C^\top \mathbb M_2 \mathbf b + - c_2 \left( \mathbf j(\mathbf V) + \mathbb S\, \mathbf e \right) \,, + \qquad + \dot{\mathbf b} = - \mathbb C \mathbf e\,, + \qquad + \dot v_{\parallel,p} = c_3\, \mathbf a_p \cdot \mathbf \Lambda^1_p \mathbf e\,, + + with :math:`\mathbb M_E = \kappa\, \mathbb M_1 + \chi\, \mathbb M_1^\perp` (:math:`\mathbb M_1^\perp` = + :attr:`~struphy.feec.mass.WeightedMassOperators.M1gyro`), :math:`\mathbf a_p = + \hat{\mathbf B}^{*2} / \hat B^{*3}_\parallel (\boldsymbol \eta_p)`, :math:`\mathbf \Lambda^1_p` the basis of + :math:`V^1_h` at :math:`\boldsymbol \eta_p`, and + + .. math:: + + \mathbf j(\mathbf V) = \sum_p w_p v_{\parallel,p}\, (\mathbf \Lambda^1_p)^\top \mathbf a_p\,, + \qquad + \mathbb A_\parallel = \sum_p w_p\, (\mathbf \Lambda^1_p)^\top \mathbf a_p \mathbf a_p^\top \mathbf \Lambda^1_p\,, + \qquad + \mathbf f^\top \mathbb S\, \mathbf e = \sum_p \frac{w_p}{\hat B^{*3}_\parallel(\boldsymbol \eta_p)}\, + (\mathbf \Lambda^1_p \mathbf f) \cdot \big( (\mathbf \Lambda^1_p \mathbf e) \times \hat{\mathbf b}^1_0 + (\boldsymbol \eta_p) \big)\,. + + :math:`\mathbb A_\parallel` is symmetric positive semi-definite (the parallel response), :math:`\mathbb S` is + antisymmetric (the :math:`E \times B` current does no work). The system conserves the energy + + .. math:: + + H = \frac{1}{2}\, \mathbf e^\top \mathbb M_E \mathbf e + \frac{c_1}{2}\, \mathbf b^\top \mathbb M_2 \mathbf b + + \frac{c_2}{c_3} \sum_p w_p \left( \frac{v_{\parallel,p}^2}{2} + \mu_p |B_0|(\boldsymbol \eta_p) \right) + \qquad (:meth:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling.energy`)\,. + + :ref:`time_discret`: Crank-Nicolson (implicit mid-point), with :math:`\mathbf a_p` and :math:`\mathbb S` + evaluated at the beginning of the step, which conserves :math:`H` exactly (up to the solver tolerance; for + constant weights). Eliminating :math:`\mathbf b^\textnormal{mid}` and :math:`\mathbf V^\textnormal{mid}` gives the + Schur system for the mid-point field + + .. math:: + + \left( \mathbb M_E + \frac{\Delta t^2}{4} \left( c_1\, \mathbb C^\top \mathbb M_2 \mathbb C + + c_2 c_3\, \mathbb A_\parallel \right) + \frac{\Delta t}{2}\, c_2\, \mathbb S \right) \mathbf e^\textnormal{mid} + = \mathbb M_E \mathbf e^n + \frac{\Delta t}{2} \left( c_1\, \mathbb C^\top \mathbb M_2 \mathbf b^n + - c_2\, \mathbf j(\mathbf V^n) \right)\,, + + accumulated by :func:`~struphy.pic.accumulation.kernels.dk_maxwell.dk_maxwell_kernels.dk_maxwell` (or + matrix-free) and solved with ``solver`` and ``precond`` (non-symmetric with the :math:`E \times B` current). + Then + + .. math:: + + \mathbf b^{n+1} = \mathbf b^n - \Delta t\, \mathbb C \mathbf e^\textnormal{mid}\,, + \qquad + v_{\parallel,p}^{n+1} = v_{\parallel,p}^n + \Delta t\, c_3\, \mathbf a_p \cdot \mathbf \Lambda^1_p + \mathbf e^\textnormal{mid}\,, + \qquad + \mathbf e^{n+1} = 2 \mathbf e^\textnormal{mid} - \mathbf e^n\,, + + with the velocity update by + :func:`~struphy.pic.pushing.kernels.push_vpar_with_efield.push_vpar_with_efield_kernels.push_vpar_with_efield`. + + **Quasi-neutral limit** :math:`\kappa = 0`. The parallel part of :math:`\mathbf e` is then determined by + :math:`\mathbb A_\parallel` alone (which needs positive weights, i.e. full-f, and markers everywhere), the + perpendicular part by :math:`\chi\, \mathbb M_1^\perp` and the curl-curl operator. The perpendicular field + still carries the energy :math:`\frac 12 \mathbf e^\top \mathbb M_E \mathbf e`, so the Crank-Nicolson update is + kept (storing :math:`\mathbf e^\textnormal{mid}` would dissipate it). The parallel part of the stored + :math:`\mathbf e^{n+1}` is not determined by the scheme and may oscillate from step to step; it does not feed + back, since the steps see :math:`\mathbf e^n` only through :math:`\mathbb M_E \mathbf e^n` and the + :math:`E \times B` push only through :math:`\mathbf E_\perp`. With ``initial_projection`` + (default), the setup replaces :math:`(\mathbf b^0, \mathbf V^0)` by the mid-point state of one step, which + removes the undamped period-2 oscillation of the constraint residual. + + **Preconditioner.** With :math:`\mathbb A_\parallel \approx \bar n\, \mathbb M_1^\parallel` + (:attr:`~struphy.feec.mass.WeightedMassOperators.M1para`), the symmetric part of the Schur operator is + approximately :math:`\mathbb C^\top \left(\frac{\Delta t^2 c_1}{4} \mathbb M_2\right) \mathbb C + \mathbb M_E + + \frac{\Delta t^2 c_2 c_3 \bar n}{4} \mathbb M_1^\parallel`, preconditioned (default) by + :class:`~struphy.feec.preconditioner.StiffnessPreconditioner`, built for the current time step + (:meth:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling.prepare_time_step`). + """ + + class Variables: + """Container for variables advanced by :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling`. + + Attributes + ---------- + e : FEECVariable + Electric-field variable in ``"Hcurl"`` space. + b : FEECVariable + Magnetic-field perturbation in ``"Hdiv"`` space. + ions : PICVariable + Guiding-center particle variable in ``"Particles5D"`` space. + """ + + def __init__(self): + self._e: FEECVariable = None + self._b: FEECVariable = None + self._ions: PICVariable = None + + @property + def e(self) -> FEECVariable: + return self._e + + @e.setter + def e(self, new): + assert isinstance(new, FEECVariable) + assert new.space == "Hcurl" + self._e = new + + @property + def b(self) -> FEECVariable: + return self._b + + @b.setter + def b(self, new): + assert isinstance(new, FEECVariable) + assert new.space == "Hdiv" + self._b = new + + @property + def ions(self) -> PICVariable: + return self._ions + + @ions.setter + def ions(self, new): + assert isinstance(new, PICVariable) + assert new.space == "Particles5D" + self._ions = new + + def __init__(self): + self.variables = self.Variables() + + @dataclass(repr=False) + class Options(OptionsBase): + r"""Configuration options for :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling`. + + Parameters + ---------- + kappa : float, default=1.0 + Coefficient :math:`\kappa \geq 0` of the displacement current :math:`\partial_t \mathbf E`; + ``kappa=0.0`` is the quasi-neutral limit (needs ``chi > 0`` and full-f weights, no control variate). + + chi : float, default=None + Coefficient :math:`\chi \geq 0` of the polarization current + :math:`\frac{n_0}{|B_0|^2} \partial_t \mathbf E_\perp`. If ``None``, :math:`\alpha^2` of the species + (the polarization of the guiding centers themselves); a larger value mimics the polarization of a heavier + species. ``kappa + chi`` must be positive. + + c1 : float, default=1.0 + Coefficient :math:`c_1 > 0` of :math:`\nabla \times \tilde{\mathbf B}` in Ampère's law. + + c2 : float, default=None + Coefficient of the current density in Ampère's law. If ``None``, :math:`\alpha^2/\varepsilon` of the + species. + + c3 : float, default=None + Coefficient of the parallel electric force (:math:`c_2 c_3 > 0`). If ``None``, :math:`1/\varepsilon` of + the species. + + exb_current : bool, default=True + Include the :math:`E \times B` current (antisymmetric operator :math:`\mathbb S`). The Schur system is then + non-symmetric and needs a general solver. Switch it off if the :math:`E \times B` current is compensated + (e.g. by other species). + + solver : OptsSolver, default="pbicgstab" + Iterative solver of the Schur system, passed to ``feectools.linalg.solvers.inverse``. The symmetric + solvers ``"pcg"`` and ``"cg"`` need ``exb_current=False``. + + precond : LiteralOptions.OptsMaxwellPrecond, default="StiffnessPreconditioner" + Preconditioner of the Schur system (only for ``"pcg"`` and ``"pbicgstab"``; set to ``None`` otherwise). + + - ``"StiffnessPreconditioner"``: :class:`~struphy.feec.preconditioner.StiffnessPreconditioner` with + ``derivative="curl"`` for the stabilized curl-curl approximation of the Schur operator (see the class + docstring), rebuilt when the time step changes. + - ``"MassMatrixPreconditioner"``, ``"PolarMassMatrixPreconditioner"``: preconditioner of + :math:`\mathbb M_1`. + - ``None``: no preconditioner. + + solver_params : SolverParameters, default=None + Solver controls. If ``None``, defaults to ``SolverParameters()``. + + n_ref : float, default=None + Reference density :math:`\bar n` in the stiffness preconditioner. If ``None``, the Rayleigh quotient + :math:`\mathbf 1^\top \mathbb A_\parallel \mathbf 1 / \mathbf 1^\top \mathbb M_1^\parallel \mathbf 1` at + the setup (a density average). + + matrix_free : bool, default=False + Apply the particle matrices :math:`\mathbb A_\parallel` and :math:`\mathbb S` matrix-free, with their + fillings stored per marker (:class:`~struphy.pic.accumulation.particles_to_grid.MarkerOperator`), instead + of assembling them. Needs 7 auxiliary marker columns. Ignored on the CuPy backend. + + initial_projection : bool, default=True + Only for ``kappa=0``: in the setup (first :meth:`prepare_time_step`), replace + :math:`(\mathbf b, \mathbf V)` by the mid-point state of one step, which satisfies the constraint of the + quasi-neutral limit exactly. Not applied to a restored (restart) state. + """ + + # specific literals + OptsSolver = Literal["pbicgstab", "bicgstab", "gmres", "pcg", "cg"] + + kappa: float = 1.0 + chi: float = None + c1: float = 1.0 + c2: float = None + c3: float = None + exb_current: bool = True + solver: OptsSolver = "pbicgstab" + precond: LiteralOptions.OptsMaxwellPrecond = "StiffnessPreconditioner" + solver_params: SolverParameters = None + n_ref: float = None + matrix_free: bool = False + initial_projection: bool = True + + def __post_init__(self): + # checks + check_option(self.solver, self.OptsSolver) + check_option(self.precond, LiteralOptions.OptsMaxwellPrecond) + assert self.kappa >= 0.0, f"kappa must be >= 0, got {self.kappa}." + if self.chi is not None: + assert self.chi >= 0.0, f"chi must be >= 0, got {self.chi}." + assert self.kappa + self.chi > 0.0, ( + "kappa + chi must be > 0: without displacement and polarization current, the perpendicular " + "electric field is undetermined." + ) + assert self.c1 > 0.0, f"c1 must be > 0, got {self.c1}." + if self.c2 is not None and self.c3 is not None: + assert self.c2 * self.c3 > 0.0, f"c2 * c3 must be > 0, got c2={self.c2}, c3={self.c3}." + if self.n_ref is not None: + assert self.n_ref > 0.0, f"n_ref must be > 0, got {self.n_ref}." + assert isinstance(self.exb_current, bool) + assert isinstance(self.matrix_free, bool) + assert isinstance(self.initial_projection, bool) + if self.exb_current: + assert self.solver not in ("pcg", "cg"), ( + f'solver="{self.solver}" needs a symmetric system: use a general solver or exb_current=False.' + ) + if self.solver not in ("pcg", "pbicgstab") and self.precond is not None: + logger.warning(f'solver="{self.solver}" takes no preconditioner; ignoring precond="{self.precond}".') + self.precond = None + + # defaults + if self.solver_params is None: + self.solver_params = SolverParameters() + + @property + def options(self) -> Options: + if not hasattr(self, "_options"): + self._options = self.Options() + return self._options + + @options.setter + def options(self, new): + assert isinstance(new, self.Options) + self._options = new + logger.info(f"\nNew options for propagator '{self.__class__.__name__}':\n{self._options}") + + @profile + def allocate(self): + # coefficients + equation_params = self.variables.ions.species.equation_params + alpha = equation_params.alpha + epsilon = equation_params.epsilon + self._epsilon = epsilon + self._kappa = self.options.kappa + self._chi = alpha**2 if self.options.chi is None else self.options.chi + self._c1 = self.options.c1 + self._c2 = alpha**2 / epsilon if self.options.c2 is None else self.options.c2 + self._c3 = 1.0 / epsilon if self.options.c3 is None else self.options.c3 + self._exb = self.options.exb_current + assert self._c2 * self._c3 > 0.0, f"c2 * c3 must be > 0, got c2={self._c2}, c3={self._c3}." + assert self._kappa + self._chi > 0.0, f"kappa + chi must be > 0, got kappa={self._kappa}, chi={self._chi}." + + assert not self.derham.polar_splines, "DriftKineticMaxwellCoupling does not support polar splines yet." + + particles = self.variables.ions.particles + if particles.control_variate: + assert self._kappa > 0.0, ( + "The quasi-neutral limit kappa=0 needs full-f weights: with the control variate the accumulated " + "matrix is indefinite and the Schur system is not well-posed." + ) + logger.warning( + "DriftKineticMaxwellCoupling with control variate: the energy is not conserved exactly " + "(the weights change)." + ) + + self._info = self.options.solver_params.info + + # magnetic background: B* (2-form, equilibrium) and B*_parallel with the perturbation b (frozen in a step) + self._b2 = self.projected_equil.b2 + self._curl_unit_b2 = self.projected_equil.curl_unit_b2 + self._unit_b1 = self.projected_equil.unit_b1 + self._absB0 = self.projected_equil.absB0 + self._curl_unit_b_dot_b0 = self.projected_equil.curl_unit_b_dot_b0 + + self._E0T = self.derham.extraction_ops["0"].transpose() + self._PB = getattr(self.basis_ops, "PB") + self._B_dot_b = self.derham.V0.zeros() + self._B_dot_b_pol = self._PB.codomain.zeros() + self._update_b_dot_b() + + args_fields = ( + self._b2[0]._data, + self._b2[1]._data, + self._b2[2]._data, + self._curl_unit_b2[0]._data, + self._curl_unit_b2[1]._data, + self._curl_unit_b2[2]._data, + ) + args_unit_b = ( + self._unit_b1[0]._data, + self._unit_b1[1]._data, + self._unit_b1[2]._data, + ) + args_b_par = ( + self._B_dot_b._data, + self._curl_unit_b_dot_b0._data, + ) + + # accumulation of A_par and S (assembled, or matrix-free) and the vector j; + # the matrix-free kernels have no CUDA version + self._matrix_free = self.options.matrix_free and xp.get_backend() != "cupy" + if self._matrix_free: + # the vector, and the fillings of the matrices per marker (in the first free columns of the marker array) + column = particles.args_markers.first_free_idx + assert particles.n_cols_aux >= 7, "matrix_free needs 7 free (auxiliary) marker columns." + self._args_accum_pre = (epsilon,) + self._args_accum_post = args_fields + args_unit_b + args_b_par + (column,) + self._accum = AccumulatorVector( + particles, + "Hcurl", + dk_maxwell_mf, + self.mass_ops, + self.domain.args_domain, + ) + self._A_par = MarkerOperator( + particles, + "Hcurl", + marker_operator_rank1_dot, + self.mass_ops, + self.domain.args_domain, + (1, column), + symmetry="symm", + ) + self._S = MarkerOperator( + particles, + "Hcurl", + marker_operator_asym_dot, + self.mass_ops, + self.domain.args_domain, + (1, column + 4), + symmetry="asym", + ) + else: + # one matrix A_par + lam * S, lam = 2 / (dt * c3) (set in prepare_time_step) + self._args_accum_pre = (epsilon,) + self._args_accum_post = args_fields + args_unit_b + args_b_par + self._accum = Accumulator( + particles, + "Hcurl", + dk_maxwell, + self.mass_ops, + self.domain.args_domain, + add_vector=True, + symmetry=None, + ) + self._A_lam = self._accum.operators[0] + self._lam = 0.0 + + # operators + self._M1 = self.mass_ops.M1 + self._M2 = self.mass_ops.M2 + self._curl = self.derham.curl + self._curl_curl = self._curl.T @ self._M2 @ self._curl + if self._kappa > 0.0 and self._chi > 0.0: + self._ME = self._kappa * self._M1 + self._chi * self.mass_ops.M1gyro + elif self._kappa > 0.0: + self._ME = self._kappa * self._M1 + else: + self._ME = self._chi * self.mass_ops.M1gyro + + # reference density for the stiffness preconditioner: density average of the parallel particle matrix + if self.options.n_ref is None: + self._accumulate() + ones = self.derham.V1.zeros() + for block in ones.blocks: + block._data[:] = 1.0 + A_par = self._A_par if self._matrix_free else self._A_lam + ones_A = ones.inner(A_par.dot(ones)) + ones_M = ones.inner(self.mass_ops.M1para.dot(ones)) + if ones_A > 0.0 and ones_M > 0.0: + self._n_ref = ones_A / ones_M + else: + self._n_ref = 1.0 + logger.warning( + "DriftKineticMaxwellCoupling: no positive density average of the markers, using n_ref=1." + ) + else: + self._n_ref = self.options.n_ref + + # preconditioner (the stiffness preconditioner depends on dt and is built in prepare_time_step) + self._precond = self.options.precond + if self._precond in (None, "StiffnessPreconditioner"): + self._mass_pc = None + else: + pc_class = getattr(preconditioner, self._precond) + self._mass_pc = pc_class(self._M1) + + # solver kwargs (the Schur operator and its solver are built in prepare_time_step) + kwargs = self.options.solver_params.__dict__.copy() + kwargs.pop("info") + self._solver_kwargs = kwargs + self._solver = None + self._schur_dt = None + self._projected = False + + # buffers (the pusher reads e_mid) + self._e_mid = self.derham.V1.zeros() + self._e_new = self.derham.V1.zeros() + self._e_rhs = self.derham.V1.zeros() + self._e_tmp = self.derham.V1.zeros() + self._b_new = self.derham.V2.zeros() + self._b_tmp = self.derham.V2.zeros() + + # particle pusher: v^{n+1} = v^n + dt * c3 * a_p . Lambda^1 e_mid + args_kernel = ( + ( + self.derham.args_derham, + epsilon, + self._e_mid.blocks[0]._data, + self._e_mid.blocks[1]._data, + self._e_mid.blocks[2]._data, + ) + + args_fields + + args_b_par + + (self._c3,) + ) + + self._pusher = Pusher( + particles, + push_vpar_with_efield, + args_kernel, + self.domain.args_domain, + alpha_in_kernel=1.0, + pushes_eta=False, + ) + + def _update_b_dot_b(self): + """Parallel magnetic field b0 . (B0 + b) in the tensor-product space V0 (used in B*_parallel).""" + B_dot_b_pol = self._PB.dot(self.variables.b.spline.vector, out=self._B_dot_b_pol) + B_dot_b = self._E0T.dot(B_dot_b_pol, out=self._B_dot_b) + B_dot_b += self._absB0 + B_dot_b.update_ghost_regions() + + def _accumulate(self): + """Accumulate the particle matrices (or their fillings per marker) and the vector j at the current markers.""" + if self._matrix_free: + self._accum(*self._args_accum_pre, *self._args_accum_post) + else: + self._accum(*self._args_accum_pre, self._lam, *self._args_accum_post) + + def prepare_time_step(self, dt: float): + """Schur operator, its preconditioner and solver for the time step ``dt``: built in the setup, rebuilt when dt + changes. The particle matrices enter by reference (assembled, or matrix-free reading the fillings stored in + the markers), so they are updated by the accumulation in every step. For ``kappa=0`` (and + ``initial_projection``), the first call also projects the state onto the constraint.""" + if dt == self._schur_dt: + return + + schur = self._ME + (dt**2 * self._c1 / 4) * self._curl_curl + if self._matrix_free: + schur = schur + (dt**2 * self._c2 * self._c3 / 4) * self._A_par + if self._exb: + schur = schur + (dt * self._c2 / 2) * self._S + else: + # A_lam = A_par + lam S with lam = 2 / (dt c3): dt^2 c2 c3 / 4 A_lam = dt^2 c2 c3 / 4 A_par + dt c2 / 2 S + self._lam = 2.0 / (dt * self._c3) if self._exb else 0.0 + schur = schur + (dt**2 * self._c2 * self._c3 / 4) * self._A_lam + + if self._precond == "StiffnessPreconditioner": + stab = self._ME + (dt**2 * self._c2 * self._c3 * self._n_ref / 4) * self.mass_ops.M1para + pc = preconditioner.StiffnessPreconditioner( + self.mass_ops, + "curl", + sigma=1.0, + mid=(dt**2 * self._c1 / 4) * self._M2, + stab=stab, + ) + else: + pc = self._mass_pc + + kwargs = dict(self._solver_kwargs) + if pc is not None: + kwargs["pc"] = pc + self._solver = inverse(schur, self.options.solver, **kwargs) + self._schur_dt = dt + + # kappa = 0: consistent initial state, once (in the setup, before the initial data are saved) + if self._kappa == 0.0 and self.options.initial_projection and not self._projected: + self._project_onto_constraint(dt) + self._projected = True + + def energy(self) -> float: + r"""The energy :math:`H = \frac{1}{2} \mathbf e^\top \mathbb M_E \mathbf e + \frac{c_1}{2} \mathbf b^\top + \mathbb M_2 \mathbf b + \frac{c_2}{c_3} \sum_p w_p (v_{\parallel,p}^2/2 + \mu_p |B_0|(\boldsymbol \eta_p))` + conserved by the propagator (summed over all MPI ranks; a collective operation).""" + e = self.variables.e.spline.vector + b = self.variables.b.spline.vector + particles = self.variables.ions.particles + + particles.save_magnetic_background_energy() + valid = particles.valid_mks + markers = particles.markers + kinetic = xp.array( + [ + xp.sum( + markers[5, valid] * (0.5 * markers[3, valid] ** 2 + markers[particles.first_diagnostics_idx, valid]) + ) + ] + ) + if self.derham.comm is not None: + self.derham.comm.Allreduce(MPI.IN_PLACE, kinetic, op=MPI.SUM) + if particles.clone_config is not None: + particles.clone_config.inter_comm.Allreduce(MPI.IN_PLACE, kinetic, op=MPI.SUM) + + energy = ( + 0.5 * e.inner(self._ME.dot(e, out=self._e_tmp)) + + self._c1 / 2 * b.inner(self._M2.dot(b, out=self._b_tmp)) + + self._c2 / self._c3 * float(kinetic[0]) + ) + return energy + + def _mid_point_field(self, dt: float): + """Accumulate at the current markers and solve the Schur system for the mid-point field (into ``_e_mid``, + with updated ghost regions, which the pusher kernel reads).""" + en = self.variables.e.spline.vector + bn = self.variables.b.spline.vector + + # B*_parallel with b^n, then A_par, S (or their fillings per marker) and j + self._update_b_dot_b() + self._accumulate() + + # rhs = M_E e^n + dt/2 (c1 C^T M2 b^n - c2 j) + rhs = self._curl.T.dot(self._M2.dot(bn, out=self._b_tmp), out=self._e_rhs) + rhs *= self._c1 + j = self._accum.vectors[0] + j.copy(out=self._e_tmp) + self._e_tmp *= self._c2 + rhs -= self._e_tmp + rhs *= dt / 2 + rhs += self._ME.dot(en, out=self._e_tmp) + + e_mid = self._solver.dot(rhs, out=self._e_mid) + e_mid.update_ghost_regions() + return e_mid, self._solver._info + + def _project_onto_constraint(self, dt: float): + """For kappa = 0: replace (b, V) by the mid-point state of one step, which satisfies the constraint of the + quasi-neutral limit exactly. The electric field is kept.""" + bn = self.variables.b.spline.vector + e_mid, _ = self._mid_point_field(dt) + + # b_mid = b^n - dt/2 C e_mid, v_mid = v^n + dt/2 c3 a_p . Lambda^1 e_mid + b_new = self._curl.dot(e_mid, out=self._b_new) + b_new *= -dt / 2 + b_new += bn + self._pusher(dt / 2) + self.update_feec_variables(b=b_new) + logger.info("DriftKineticMaxwellCoupling: projected the initial state onto the constraint (kappa = 0).") + + @profile + def __call__(self, dt): + en = self.variables.e.spline.vector + bn = self.variables.b.spline.vector + + # Schur operator for the current time step (no-op unless dt changed since the setup) + self.prepare_time_step(dt) + + # mid-point field + e_mid, info = self._mid_point_field(dt) + + # new b coeffs: b^{n+1} = b^n - dt C e_mid + b_new = self._curl.dot(e_mid, out=self._b_new) + b_new *= -dt + b_new += bn + + # new e coeffs: Crank-Nicolson (M_E = kappa M1 + chi M1gyro is nonzero, so e carries energy also for kappa = 0) + e_new = e_mid.copy(out=self._e_new) + e_new *= 2.0 + e_new -= en + + # update parallel velocities with e_mid + self._pusher(dt) + + # update weights + if self.variables.ions.species.weights_params.control_variate: + self.variables.ions.particles.update_weights() + + # write new coeffs into self.variables + diffs = self.update_feec_variables(e=e_new, b=b_new) + + if self._info and MPI.COMM_WORLD.Get_rank() == 0: + logger.info(f"Status for DriftKineticMaxwellCoupling: {info['success']}") + logger.info(f"Iterations for DriftKineticMaxwellCoupling: {info['niter']}") + logger.info(f"Maxdiff e for DriftKineticMaxwellCoupling: {diffs['e']}") + logger.info(f"Maxdiff b for DriftKineticMaxwellCoupling: {diffs['b']}") + logger.info("") diff --git a/src/struphy/propagators/push_guiding_center_bx_estar.py b/src/struphy/propagators/push_guiding_center_bx_estar.py index f6fc1da6f..c6346aea1 100644 --- a/src/struphy/propagators/push_guiding_center_bx_estar.py +++ b/src/struphy/propagators/push_guiding_center_bx_estar.py @@ -42,7 +42,7 @@ class PushGuidingCenterBxEstar(Propagator): \mathbf E^* = -\nabla \phi - \varepsilon \mu_p \nabla |\mathbf B|\,,\qquad \mathbf B^* = \mathbf B + \varepsilon v_\parallel \nabla \times \mathbf{b}_0\,,\qquad B^*_\parallel = \mathbf B^* \cdot \mathbf{b}_0\,, - Here, :math:`\mathbf B = \mathbf{B}_0 + \tilde{\mathbf B}` can be the full magnetic field (equilibrium + perturbation). The electric potential :math:`\phi` and/or the magnetic perturbation :math:`\tilde{\mathbf B}` can be ignored by passing ``None``. + Here, :math:`\mathbf B = \mathbf{B}_0 + \tilde{\mathbf B}` can be the full magnetic field (equilibrium + perturbation). The electric potential :math:`\phi` and/or the magnetic perturbation :math:`\tilde{\mathbf B}` can be ignored by passing ``None``. Instead of the potential, an electric field :math:`\mathbf E \in H(\textnormal{curl})` can be passed (``e_field``); then :math:`\mathbf E^* = \mathbf E - \varepsilon \mu_p \nabla |\mathbf B|` (gauge-invariant electromagnetic formulation). In logical space this is given by :math:`\mathbf X = F(\boldsymbol \eta)`: .. math:: @@ -83,6 +83,7 @@ def __init__( self, phi: FEECVariable = None, b_tilde: FEECVariable = None, + e_field: FEECVariable = None, ): """ Parameters @@ -93,7 +94,16 @@ def __init__( b_tilde : FEECVariable, default=None Magnetic perturbation in ``"Hcurl"`` space contributing to :math:`B^*`. If ``None``, the perturbation is ignored. + e_field : FEECVariable, default=None + Electric field in ``"Hcurl"`` space, used in :math:`E^* = E - \varepsilon \mu \nabla |B|` instead of + :math:`-\nabla \phi` (gauge-invariant electromagnetic formulation). Cannot be combined with ``phi``. The + discrete-gradient algorithms then apply the discrete gradient to :math:`\varepsilon \mu |B|` only and + evaluate :math:`E` (fixed in this step) like the other fields. """ + assert phi is None or e_field is None, "Pass either the potential phi or the electric field e_field, not both." + if e_field is not None: + assert isinstance(e_field, FEECVariable) and e_field.space == "Hcurl", "e_field must be in Hcurl." + self.e_field = e_field self.variables = self.Variables() self.phi = phi if phi is not None else FEECVariable(space="H1") if phi is None: @@ -203,6 +213,8 @@ def allocate(self): self._phi = self.derham.V0.zeros() self._update_phi() self._evaluate_e_field = self.options.evaluate_e_field + if self.e_field is not None: + self._evaluate_e_field = True self._e_field = self.derham.V1.zeros() self._e_field_pol = self.derham.grad.codomain.zeros() @@ -509,7 +521,10 @@ def __call__(self, dt): # electric field # TODO: add out to __neg__ of StencilVector - if self._evaluate_e_field: + if self.e_field is not None: + e_field = self._E1T.dot(self.e_field.spline.vector, out=self._e_field) + e_field.update_ghost_regions() + elif self._evaluate_e_field: e_field_pol = self.derham.grad.dot(-self.phi.spline.vector, out=self._e_field_pol) e_field = self._E1T.dot(e_field_pol, out=self._e_field) e_field.update_ghost_regions() diff --git a/src/struphy/propagators/push_guiding_center_parallel.py b/src/struphy/propagators/push_guiding_center_parallel.py index 7aa53f662..3a4ba05ea 100644 --- a/src/struphy/propagators/push_guiding_center_parallel.py +++ b/src/struphy/propagators/push_guiding_center_parallel.py @@ -52,7 +52,7 @@ class PushGuidingCenterParallel(Propagator): where :math:`\mathbf B = \mathbf B_0 + \tilde{\mathbf B}` can be the full magnetic field (equilibrium + perturbation). The electric potential ``phi`` and/or the magnetic perturbation ``b_tilde`` - can be ignored by passing ``None``. + can be ignored by passing ``None``. Instead of the potential, an electric field :math:`\mathbf E \in H(\textnormal{curl})` can be passed (``e_field``); then :math:`\mathbf E^* = \mathbf E - \varepsilon \mu_p \nabla |\mathbf B|` (gauge-invariant electromagnetic formulation). In logical space this is given by :math:`\mathbf X = F(\boldsymbol \eta)`: .. math:: @@ -99,6 +99,7 @@ def __init__( self, phi: FEECVariable = None, b_tilde: FEECVariable = None, + e_field: FEECVariable = None, ): """ Parameters @@ -109,7 +110,16 @@ def __init__( b_tilde : FEECVariable, default=None Magnetic perturbation in ``"Hcurl"`` space contributing to :math:`B^*`. If ``None``, the perturbation is ignored. + e_field : FEECVariable, default=None + Electric field in ``"Hcurl"`` space, used in :math:`E^* = E - \varepsilon \mu \nabla |B|` instead of + :math:`-\nabla \phi` (gauge-invariant electromagnetic formulation). Cannot be combined with ``phi``. The + discrete-gradient algorithms then apply the discrete gradient to :math:`\varepsilon \mu |B|` only and + evaluate :math:`E` (fixed in this step) like the other fields. """ + assert phi is None or e_field is None, "Pass either the potential phi or the electric field e_field, not both." + if e_field is not None: + assert isinstance(e_field, FEECVariable) and e_field.space == "Hcurl", "e_field must be in Hcurl." + self.e_field = e_field self.variables = self.Variables() self.phi = phi if phi is not None else FEECVariable(space="H1") if phi is None: @@ -220,6 +230,8 @@ def allocate(self): self._phi = self.derham.V0.zeros() self._update_phi() self._evaluate_e_field = self.options.evaluate_e_field + if self.e_field is not None: + self._evaluate_e_field = True self._e_field = self.derham.V1.zeros() self._e_field_pol = self.derham.grad.codomain.zeros() @@ -535,7 +547,10 @@ def __call__(self, dt): # electric field # TODO: add out to __neg__ of StencilVector - if self._evaluate_e_field: + if self.e_field is not None: + e_field = self._E1T.dot(self.e_field.spline.vector, out=self._e_field) + e_field.update_ghost_regions() + elif self._evaluate_e_field: e_field_pol = self.derham.grad.dot(-self.phi.spline.vector, out=self._e_field_pol) e_field = self._E1T.dot(e_field_pol, out=self._e_field) e_field.update_ghost_regions() diff --git a/src/struphy/propagators/tests/test_drift_kinetic_maxwell_coupling.py b/src/struphy/propagators/tests/test_drift_kinetic_maxwell_coupling.py new file mode 100644 index 000000000..50eca67d7 --- /dev/null +++ b/src/struphy/propagators/tests/test_drift_kinetic_maxwell_coupling.py @@ -0,0 +1,231 @@ +import logging + +import cunumpy as xp +import pytest + +from struphy import set_logging_level + +set_logging_level(logging.WARNING) + + +def _model(control_variate=False, domain=None, **options): + """Allocated DriftKineticMaxwell model (slab with a tilted homogeneous B0, full-f guiding centers with alpha=1, + epsilon=0.5, a parallel flow and a B perturbation) with the given options of its coupling propagator.""" + from struphy import ( + DerhamOptions, + LoadingParameters, + Simulation, + WeightsParameters, + domains, + equils, + grids, + maxwellians, + models, + perturbations, + ) + from struphy.linear_algebra.solver import SolverParameters + + model = models.DriftKineticMaxwell(alpha=1.0, epsilon=0.5) + model.kinetic_ions.set_markers( + loading_params=LoadingParameters(ppc=20, seed=1234), + weights_params=WeightsParameters(control_variate=control_variate), + ) + model.kinetic_ions.var.add_background(maxwellians.GyroMaxwellian2D(n=(1.0, None), B0=1.0)) + model.kinetic_ions.var.add_initial_condition( + maxwellians.GyroMaxwellian2D( + n=(1.0, None), u_para=(0.0, perturbations.ModesCos(ls=(1,), ns=(1,), amps=(1e-1,))), B0=1.0 + ) + ) + model.em_fields.b_field.add_perturbation( + perturbations.ModesSin(ls=(1,), ns=(1,), given_in_basis="2", comp=1, amps=(1e-1,)) + ) + + prop = model.propagators.coupling + prop.options = prop.Options(solver_params=SolverParameters(tol=1e-13, maxiter=3000), **options) + + Simulation( + model=model, + domain=domains.Cuboid(r1=4.0, r2=2.0, r3=4.0) if domain is None else domain, + equil=equils.HomogenSlab(B0x=0.3, B0z=1.0), + grid=grids.TensorProductGrid(num_elements=(6, 2, 6)), + derham_opts=DerhamOptions(degree=(2, 1, 2)), + ).allocate() + return model + + +def _state(model): + e = model.em_fields.e_field.spline.vector.copy() + b = model.em_fields.b_field.spline.vector.copy() + v = model.kinetic_ions.var.particles.markers[3, model.kinetic_ions.var.particles.valid_mks].copy() + return e, b, v + + +def _norm(x): + return x.inner(x) ** 0.5 + + +def _random_v1(derham, seed): + """A random V1 coefficient vector (different on each rank, which does not matter for the tests).""" + rng = xp.random.default_rng(seed) + x = derham.V1.zeros() + for block in x.blocks: + block._data[:] = rng.standard_normal(block._data.shape) + x.update_ghost_regions() + return x + + +@pytest.mark.parametrize("matrix_free", [False, True]) +@pytest.mark.parametrize("exb_current", [True, False]) +@pytest.mark.parametrize("kappa", [1.0, 0.0]) +def test_energy_conservation(kappa, exb_current, matrix_free): + """The coupling step conserves its energy H exactly (up to the solver tolerance), also for a large time step and + in the quasi-neutral limit kappa=0 (without the initial projection, which changes H once).""" + solver = {} if exb_current else {"solver": "pcg"} + model = _model(kappa=kappa, exb_current=exb_current, matrix_free=matrix_free, initial_projection=False, **solver) + prop = model.propagators.coupling + b = model.em_fields.b_field.spline.vector + energy_0 = prop.energy() + energy_b_0 = 0.5 * b.inner(prop._M2.dot(b)) + for _ in range(3): + prop(0.5) + assert prop._solver._info["success"] + # H is dominated by the thermal energy: compare its change with the energy exchanged with the B field + exchanged = abs(0.5 * b.inner(prop._M2.dot(b)) - energy_b_0) + assert exchanged > 0.1 * energy_b_0 + assert abs(prop.energy() - energy_0) < 1e-10 * exchanged + + +def test_energy_conservation_mapped(): + """Also in a non-trivial mapping (metric in the parallel response and in the E x B current).""" + from struphy import domains + + model = _model(domain=domains.Colella(Lx=4.0, Ly=2.0, alpha=0.05, Lz=4.0), initial_projection=False) + prop = model.propagators.coupling + energy_0 = prop.energy() + for _ in range(3): + prop(0.5) + assert prop._solver._info["success"] + assert abs(prop.energy() - energy_0) < 1e-11 * energy_0 + + +@pytest.mark.parametrize("kappa", [1.0, 0.0]) +def test_matrix_free_agrees(kappa): + """The matrix-free particle matrices give the same step as the assembled ones (with the E x B current).""" + states = [] + for matrix_free in (False, True): + model = _model(kappa=kappa, matrix_free=matrix_free, initial_projection=False) + model.propagators.coupling(0.5) + # the two simulations have different Derham objects: compare the arrays + e, b, v = _state(model) + states.append((e.toarray(), b.toarray(), v)) + for x0, x1 in zip(*states): + assert xp.max(xp.abs(x1 - x0)) < 1e-9 * xp.max(xp.abs(x0)) + + +def test_particle_matrices(): + """The parallel response A_par is symmetric positive semi-definite, the E x B operator S antisymmetric.""" + model = _model(matrix_free=True, initial_projection=False) + prop = model.propagators.coupling + prop._accumulate() + x = _random_v1(prop.derham, 1) + y = _random_v1(prop.derham, 2) + A, S = prop._A_par, prop._S + scale = x.inner(A.dot(x)) + y.inner(A.dot(y)) + assert x.inner(A.dot(x)) >= 0.0 + assert abs(x.inner(A.dot(y)) - y.inner(A.dot(x))) < 1e-12 * scale + scale_s = abs(x.inner(S.dot(y))) + assert scale_s > 0.0 + assert abs(x.inner(S.dot(x))) < 1e-12 * scale_s + assert abs(x.inner(S.dot(y)) + y.inner(S.dot(x))) < 1e-12 * scale_s + + +@pytest.mark.parametrize("matrix_free", [False, True]) +@pytest.mark.parametrize("kappa", [1.0, 0.0]) +def test_discrete_equations(kappa, matrix_free): + """Without the E x B current, the new state satisfies the Crank-Nicolson equations: Ampère's law + M_E (e^{n+1} - e^n) / dt = c1 C^T M2 b_mid - c2 j(V_mid) (j linear in v_par at fixed positions and fixed + a_p = B*/B*_par, which depends on v_par only through the curvature, zero here) and Faraday's law.""" + dt = 0.5 + model = _model(kappa=kappa, matrix_free=matrix_free, exb_current=False, solver="pcg", initial_projection=False) + prop = model.propagators.coupling + e0, b0, _ = _state(model) + prop._accumulate() + j0 = prop._accum.vectors[0].copy() + prop(dt) + e1, b1, _ = _state(model) + prop._accumulate() + j_mid = (j0 + prop._accum.vectors[0]) * 0.5 + b_mid = (b0 + b1) * 0.5 + e_mid = (e0 + e1) * 0.5 + + curl_b = prop._curl.T.dot(prop._M2.dot(b_mid)) * prop._c1 + current = j_mid * prop._c2 + residual = curl_b - current - prop._ME.dot(e1 - e0) * (1 / dt) + assert _norm(residual) < 1e-8 * (_norm(curl_b) + _norm(current)) + + faraday = (b1 - b0) * (1 / dt) + prop._curl.dot(e_mid) + assert _norm(faraday) < 1e-10 * _norm(b1 - b0) / dt + + +def test_initial_projection(): + """For kappa=0, without the initial projection the magnetic energy oscillates with period 2 (the constraint + residual changes sign in every step); the projection removes this.""" + + def en_b_series(projection): + model = _model(kappa=0.0, initial_projection=projection) + prop = model.propagators.coupling + b = model.em_fields.b_field.spline.vector + series = [] + for _ in range(6): + prop(0.5) + series.append(0.5 * b.inner(prop._M2.dot(b))) + return xp.array(series) + + def zigzag(series): + # second differences relative to the mean: large for a period-2 oscillation + return float(xp.max(xp.abs(series[2:] - 2 * series[1:-1] + series[:-2])) / xp.mean(series)) + + assert zigzag(en_b_series(False)) > 3 * zigzag(en_b_series(True)) + + +def test_dt_change(): + """The Schur solver and its preconditioner are rebuilt only when dt changes; the energy stays conserved.""" + model = _model(n_ref=1.0) + prop = model.propagators.coupling + energy_0 = prop.energy() + prop(0.5) + solver = prop._solver + prop(0.5) + assert prop._solver is solver + prop(1.0) + assert prop._solver is not solver + assert prop._schur_dt == 1.0 + assert abs(prop.energy() - energy_0) < 1e-11 * energy_0 + + +@pytest.mark.mpi_skip +def test_invalid_options(): + from struphy.propagators.drift_kinetic_maxwell_coupling import DriftKineticMaxwellCoupling + + Options = DriftKineticMaxwellCoupling.Options + with pytest.raises(AssertionError): + Options(kappa=-1.0) + with pytest.raises(AssertionError): + Options(chi=-1.0) + with pytest.raises(AssertionError): + # no displacement and no polarization current: the perpendicular field is undetermined + Options(kappa=0.0, chi=0.0) + with pytest.raises(AssertionError): + Options(c1=0.0) + with pytest.raises(AssertionError): + Options(c2=1.0, c3=-1.0) + with pytest.raises(AssertionError): + # the E x B current makes the system non-symmetric + Options(solver="pcg") + Options(solver="pcg", exb_current=False) + + +def test_control_variate_needs_kappa(): + """kappa=0 needs full-f weights (the particle matrix is indefinite with the control variate).""" + with pytest.raises(AssertionError): + _model(kappa=0.0, control_variate=True) diff --git a/src/struphy/propagators/tests/test_push_guiding_center_e_field.py b/src/struphy/propagators/tests/test_push_guiding_center_e_field.py new file mode 100644 index 000000000..3e1f414e0 --- /dev/null +++ b/src/struphy/propagators/tests/test_push_guiding_center_e_field.py @@ -0,0 +1,95 @@ +import logging + +import cunumpy as xp +import pytest + +from struphy import set_logging_level + +set_logging_level(logging.WARNING) + + +def _allocate(model, perturb_density): + """Same markers for all models (same loading, seed, domain and grid); a density perturbation makes the initial + potential of the electrostatic model non-trivial.""" + from struphy import DerhamOptions, LoadingParameters, Simulation, domains, equils, grids, maxwellians, perturbations + + model.kinetic_ions.set_markers(loading_params=LoadingParameters(ppc=10, seed=4321)) + model.kinetic_ions.var.add_background(maxwellians.GyroMaxwellian2D(n=(1.0, None), B0=1.0)) + pert = perturbations.ModesCos(ls=(1,), ms=(1,), amps=(0.3,)) if perturb_density else None + model.kinetic_ions.var.add_initial_condition(maxwellians.GyroMaxwellian2D(n=(1.0, pert), B0=1.0)) + Simulation( + model=model, + domain=domains.Cuboid(r1=4.0, r2=4.0, r3=2.0), + equil=equils.HomogenSlab(B0x=0.2, B0z=1.0), + grid=grids.TensorProductGrid(num_elements=(6, 6, 2)), + derham_opts=DerhamOptions(degree=(2, 2, 1)), + ).allocate() + + +def _all_markers(particles): + """Positions and parallel velocities of all valid markers on all processes, sorted lexicographically (markers + may change their process during a push).""" + from maybempi import MPI + + local = particles.markers[:4, particles.valid_mks].copy() + comm = MPI.COMM_WORLD + if comm.Get_size() > 1: + local = xp.concatenate(comm.allgather(local), axis=1) + return local[:, xp.lexsort(local[::-1])] + + +@pytest.mark.parametrize("name", ["PushGuidingCenterBxEstar", "PushGuidingCenterParallel"]) +def test_e_field_equals_minus_grad_phi(name): + """A guiding-center pusher with ``e_field = -grad phi`` moves the markers exactly as with the potential ``phi`` + (explicit algorithm, with the electric field evaluated from phi).""" + from struphy import models, propagators + + dt = 0.1 + attr = {"PushGuidingCenterBxEstar": "push_gc_bxe", "PushGuidingCenterParallel": "push_gc_para"}[name] + + # reference: the phi path in the electrostatic drift-kinetic model (phi from its initial Poisson solve); + # pushed before the second model is allocated (the Derham objects of propagators are class attributes) + es = models.DriftKineticElectrostaticAdiabatic(epsilon=0.5) + prop_phi = getattr(es.propagators, attr) + prop_phi.options = prop_phi.Options(algo="explicit", evaluate_e_field=True) + _allocate(es, perturb_density=True) + phi = es.em_fields.phi.spline.vector + assert phi.inner(phi) > 0.0 + e_data = [blk._data.copy() for blk in es.em_fields.phi.spline.derham.grad.dot(-phi).blocks] + p_es = es.kinetic_ions.var.particles + initial = _all_markers(p_es) + prop_phi(dt) + pushed_phi = _all_markers(p_es) + assert xp.max(xp.abs(pushed_phi - initial)) > 1e-6 + + # the e_field path, with e = -grad phi copied into the (identically distributed) Derham of the other model + em = models.DriftKineticMaxwell(epsilon=0.5) + _allocate(em, perturb_density=True) + e = em.em_fields.e_field.spline.vector + for blk, data in zip(e.blocks, e_data): + blk._data[:] = data + e.update_ghost_regions() + prop_e = getattr(propagators, name)(e_field=em.em_fields.e_field) + prop_e.variables.ions = em.kinetic_ions.var + prop_e.options = prop_e.Options(algo="explicit") + prop_e.allocate() + + p_em = em.kinetic_ions.var.particles + assert xp.array_equal(initial, _all_markers(p_em)) + prop_e(dt) + pushed_e = _all_markers(p_em) + assert pushed_e.shape == pushed_phi.shape + assert xp.max(xp.abs(pushed_e - pushed_phi)) < 1e-12 + + +@pytest.mark.mpi_skip +def test_phi_and_e_field_exclusive(): + from struphy.models.variables import FEECVariable + from struphy.propagators.push_guiding_center_bx_estar import PushGuidingCenterBxEstar + from struphy.propagators.push_guiding_center_parallel import PushGuidingCenterParallel + + for cls in (PushGuidingCenterBxEstar, PushGuidingCenterParallel): + with pytest.raises(AssertionError): + cls(phi=FEECVariable(space="H1"), e_field=FEECVariable(space="Hcurl")) + with pytest.raises(AssertionError): + cls(e_field=FEECVariable(space="H1")) From 9f409ebb664241bbcdf759606cb40f9243267daf Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 13:25:20 +0200 Subject: [PATCH 19/23] Review fixes: e_field in the guiding-center pushers needs the explicit algorithm; no CuPy; docs - With e_field (no potential), the discrete-gradient correction removes the work of E along the step (O(1) inconsistency, e.g. no E x B drift across grad|B|): a discrete-gradient algo now falls back to "explicit" with a warning (keeps the default options working); the phi path is unchanged. Test with the default algo added. - DriftKineticMaxwellCoupling asserts a non-CuPy backend (no CUDA kernels yet); document that chi uses the MHD-equilibrium density and the missing f0 response with the control variate. - Example: explicit E* x b0 push. Co-Authored-By: Claude Opus 5.5 --- .../params_inertial_alfven_wave.py | 3 ++- .../drift_kinetic_maxwell_coupling.py | 15 +++++++++------ .../push_guiding_center_bx_estar.py | 18 +++++++++++++----- .../push_guiding_center_parallel.py | 18 +++++++++++++----- .../tests/test_push_guiding_center_e_field.py | 9 ++++++--- 5 files changed, 43 insertions(+), 20 deletions(-) diff --git a/examples/DriftKineticMaxwell/inertial_alfven_wave/params_inertial_alfven_wave.py b/examples/DriftKineticMaxwell/inertial_alfven_wave/params_inertial_alfven_wave.py index 27d6a17bf..11911b954 100644 --- a/examples/DriftKineticMaxwell/inertial_alfven_wave/params_inertial_alfven_wave.py +++ b/examples/DriftKineticMaxwell/inertial_alfven_wave/params_inertial_alfven_wave.py @@ -164,7 +164,8 @@ # without the E x B current the Schur system is symmetric prop.options = prop.Options(kappa=args.kappa, exb_current=False, solver="pcg") model.propagators.push_para.options = model.propagators.push_para.Options() -model.propagators.push_bxe.options = model.propagators.push_bxe.Options() +# with the electric field (not a potential) only the explicit E* x b0 push is consistent +model.propagators.push_bxe.options = model.propagators.push_bxe.Options(algo="explicit") # ------------------ # Initial conditions diff --git a/src/struphy/propagators/drift_kinetic_maxwell_coupling.py b/src/struphy/propagators/drift_kinetic_maxwell_coupling.py index 9c831d110..7ee3d6a92 100644 --- a/src/struphy/propagators/drift_kinetic_maxwell_coupling.py +++ b/src/struphy/propagators/drift_kinetic_maxwell_coupling.py @@ -218,7 +218,9 @@ class Options(OptionsBase): Coefficient :math:`\chi \geq 0` of the polarization current :math:`\frac{n_0}{|B_0|^2} \partial_t \mathbf E_\perp`. If ``None``, :math:`\alpha^2` of the species (the polarization of the guiding centers themselves); a larger value mimics the polarization of a heavier - species. ``kappa + chi`` must be positive. + species. ``kappa + chi`` must be positive. The density :math:`n_0` is the one of the MHD equilibrium + (:attr:`~struphy.feec.mass.WeightedMassOperators.M1gyro`), not of the kinetic background: choose them + consistently. c1 : float, default=1.0 Coefficient :math:`c_1 > 0` of :math:`\nabla \times \tilde{\mathbf B}` in Ampère's law. @@ -261,7 +263,7 @@ class Options(OptionsBase): matrix_free : bool, default=False Apply the particle matrices :math:`\mathbb A_\parallel` and :math:`\mathbb S` matrix-free, with their fillings stored per marker (:class:`~struphy.pic.accumulation.particles_to_grid.MarkerOperator`), instead - of assembling them. Needs 7 auxiliary marker columns. Ignored on the CuPy backend. + of assembling them. Needs 7 auxiliary marker columns. initial_projection : bool, default=True Only for ``kappa=0``: in the setup (first :meth:`prepare_time_step`), replace @@ -354,7 +356,8 @@ def allocate(self): ) logger.warning( "DriftKineticMaxwellCoupling with control variate: the energy is not conserved exactly " - "(the weights change)." + "(the weights change), and the response of the background f0 (parallel response and E x B current) " + "is missing in the particle matrices." ) self._info = self.options.solver_params.info @@ -390,9 +393,9 @@ def allocate(self): self._curl_unit_b_dot_b0._data, ) - # accumulation of A_par and S (assembled, or matrix-free) and the vector j; - # the matrix-free kernels have no CUDA version - self._matrix_free = self.options.matrix_free and xp.get_backend() != "cupy" + # accumulation of A_par and S (assembled, or matrix-free) and the vector j + assert xp.get_backend() != "cupy", "DriftKineticMaxwellCoupling has no CUDA kernels yet." + self._matrix_free = self.options.matrix_free if self._matrix_free: # the vector, and the fillings of the matrices per marker (in the first free columns of the marker array) column = particles.args_markers.first_free_idx diff --git a/src/struphy/propagators/push_guiding_center_bx_estar.py b/src/struphy/propagators/push_guiding_center_bx_estar.py index c6346aea1..eb9836f51 100644 --- a/src/struphy/propagators/push_guiding_center_bx_estar.py +++ b/src/struphy/propagators/push_guiding_center_bx_estar.py @@ -1,7 +1,7 @@ "Only particle variables are updated." import logging -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Literal from line_profiler import profile @@ -42,7 +42,7 @@ class PushGuidingCenterBxEstar(Propagator): \mathbf E^* = -\nabla \phi - \varepsilon \mu_p \nabla |\mathbf B|\,,\qquad \mathbf B^* = \mathbf B + \varepsilon v_\parallel \nabla \times \mathbf{b}_0\,,\qquad B^*_\parallel = \mathbf B^* \cdot \mathbf{b}_0\,, - Here, :math:`\mathbf B = \mathbf{B}_0 + \tilde{\mathbf B}` can be the full magnetic field (equilibrium + perturbation). The electric potential :math:`\phi` and/or the magnetic perturbation :math:`\tilde{\mathbf B}` can be ignored by passing ``None``. Instead of the potential, an electric field :math:`\mathbf E \in H(\textnormal{curl})` can be passed (``e_field``); then :math:`\mathbf E^* = \mathbf E - \varepsilon \mu_p \nabla |\mathbf B|` (gauge-invariant electromagnetic formulation). + Here, :math:`\mathbf B = \mathbf{B}_0 + \tilde{\mathbf B}` can be the full magnetic field (equilibrium + perturbation). The electric potential :math:`\phi` and/or the magnetic perturbation :math:`\tilde{\mathbf B}` can be ignored by passing ``None``. Instead of the potential, an electric field :math:`\mathbf E \in H(\textnormal{curl})` can be passed (``e_field``, explicit algorithm); then :math:`\mathbf E^* = \mathbf E - \varepsilon \mu_p \nabla |\mathbf B|` (gauge-invariant electromagnetic formulation). In logical space this is given by :math:`\mathbf X = F(\boldsymbol \eta)`: .. math:: @@ -96,9 +96,9 @@ def __init__( If ``None``, the perturbation is ignored. e_field : FEECVariable, default=None Electric field in ``"Hcurl"`` space, used in :math:`E^* = E - \varepsilon \mu \nabla |B|` instead of - :math:`-\nabla \phi` (gauge-invariant electromagnetic formulation). Cannot be combined with ``phi``. The - discrete-gradient algorithms then apply the discrete gradient to :math:`\varepsilon \mu |B|` only and - evaluate :math:`E` (fixed in this step) like the other fields. + :math:`-\nabla \phi` (gauge-invariant electromagnetic formulation). Cannot be combined with ``phi``. Only the + explicit algorithm is consistent with a general :math:`E` (the discrete gradients conserve a Hamiltonian + with a potential); a discrete-gradient ``algo`` is replaced by ``"explicit"``, with a warning. """ assert phi is None or e_field is None, "Pass either the potential phi or the electric field e_field, not both." if e_field is not None: @@ -215,6 +215,14 @@ def allocate(self): self._evaluate_e_field = self.options.evaluate_e_field if self.e_field is not None: self._evaluate_e_field = True + # the discrete gradients conserve the Hamiltonian, which contains no potential for a general E: + # they would remove the work of E along the step (inconsistent scheme), hence the explicit algorithm + if "discrete_gradient" in self.options.algo: + logger.warning( + f'{self.__class__.__name__} with e_field: algo="{self.options.algo}" needs a potential, ' + 'using algo="explicit" (default Butcher tableau).' + ) + self._options = replace(self.options, algo="explicit", butcher=None) self._e_field = self.derham.V1.zeros() self._e_field_pol = self.derham.grad.codomain.zeros() diff --git a/src/struphy/propagators/push_guiding_center_parallel.py b/src/struphy/propagators/push_guiding_center_parallel.py index 3a4ba05ea..a0892fd04 100644 --- a/src/struphy/propagators/push_guiding_center_parallel.py +++ b/src/struphy/propagators/push_guiding_center_parallel.py @@ -1,7 +1,7 @@ "Only particle variables are updated." import logging -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Literal from line_profiler import profile @@ -52,7 +52,7 @@ class PushGuidingCenterParallel(Propagator): where :math:`\mathbf B = \mathbf B_0 + \tilde{\mathbf B}` can be the full magnetic field (equilibrium + perturbation). The electric potential ``phi`` and/or the magnetic perturbation ``b_tilde`` - can be ignored by passing ``None``. Instead of the potential, an electric field :math:`\mathbf E \in H(\textnormal{curl})` can be passed (``e_field``); then :math:`\mathbf E^* = \mathbf E - \varepsilon \mu_p \nabla |\mathbf B|` (gauge-invariant electromagnetic formulation). + can be ignored by passing ``None``. Instead of the potential, an electric field :math:`\mathbf E \in H(\textnormal{curl})` can be passed (``e_field``, explicit algorithm); then :math:`\mathbf E^* = \mathbf E - \varepsilon \mu_p \nabla |\mathbf B|` (gauge-invariant electromagnetic formulation). In logical space this is given by :math:`\mathbf X = F(\boldsymbol \eta)`: .. math:: @@ -112,9 +112,9 @@ def __init__( If ``None``, the perturbation is ignored. e_field : FEECVariable, default=None Electric field in ``"Hcurl"`` space, used in :math:`E^* = E - \varepsilon \mu \nabla |B|` instead of - :math:`-\nabla \phi` (gauge-invariant electromagnetic formulation). Cannot be combined with ``phi``. The - discrete-gradient algorithms then apply the discrete gradient to :math:`\varepsilon \mu |B|` only and - evaluate :math:`E` (fixed in this step) like the other fields. + :math:`-\nabla \phi` (gauge-invariant electromagnetic formulation). Cannot be combined with ``phi``. Only the + explicit algorithm is consistent with a general :math:`E` (the discrete gradients conserve a Hamiltonian + with a potential); a discrete-gradient ``algo`` is replaced by ``"explicit"``, with a warning. """ assert phi is None or e_field is None, "Pass either the potential phi or the electric field e_field, not both." if e_field is not None: @@ -232,6 +232,14 @@ def allocate(self): self._evaluate_e_field = self.options.evaluate_e_field if self.e_field is not None: self._evaluate_e_field = True + # the discrete gradients conserve the Hamiltonian, which contains no potential for a general E: + # they would remove the work of E along the step (inconsistent scheme), hence the explicit algorithm + if "discrete_gradient" in self.options.algo: + logger.warning( + f'{self.__class__.__name__} with e_field: algo="{self.options.algo}" needs a potential, ' + 'using algo="explicit" (default Butcher tableau).' + ) + self._options = replace(self.options, algo="explicit", butcher=None) self._e_field = self.derham.V1.zeros() self._e_field_pol = self.derham.grad.codomain.zeros() diff --git a/src/struphy/propagators/tests/test_push_guiding_center_e_field.py b/src/struphy/propagators/tests/test_push_guiding_center_e_field.py index 3e1f414e0..0389c476e 100644 --- a/src/struphy/propagators/tests/test_push_guiding_center_e_field.py +++ b/src/struphy/propagators/tests/test_push_guiding_center_e_field.py @@ -38,10 +38,12 @@ def _all_markers(particles): return local[:, xp.lexsort(local[::-1])] +@pytest.mark.parametrize("e_algo", ["explicit", "discrete_gradient_1st_order"]) @pytest.mark.parametrize("name", ["PushGuidingCenterBxEstar", "PushGuidingCenterParallel"]) -def test_e_field_equals_minus_grad_phi(name): +def test_e_field_equals_minus_grad_phi(name, e_algo): """A guiding-center pusher with ``e_field = -grad phi`` moves the markers exactly as with the potential ``phi`` - (explicit algorithm, with the electric field evaluated from phi).""" + (explicit algorithm, with the electric field evaluated from phi). With ``e_field`` a discrete-gradient algorithm + is replaced by the explicit one (it would remove the work of E along the step).""" from struphy import models, propagators dt = 0.1 @@ -71,8 +73,9 @@ def test_e_field_equals_minus_grad_phi(name): e.update_ghost_regions() prop_e = getattr(propagators, name)(e_field=em.em_fields.e_field) prop_e.variables.ions = em.kinetic_ions.var - prop_e.options = prop_e.Options(algo="explicit") + prop_e.options = prop_e.Options(algo=e_algo) prop_e.allocate() + assert prop_e.options.algo == "explicit" p_em = em.kinetic_ions.var.particles assert xp.array_equal(initial, _all_markers(p_em)) From 6b2305e52a69c69caf65a914d0ea3017f8bf84e7 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 14:43:22 +0200 Subject: [PATCH 20/23] VlasovMaxwellQuasiNeutral: cyclotron normalization with the equation parameters lam and kappa - species equation parameters lam = Omega_c x / c (new) and kappa = Omega_p x / c (redefined, was Omega_p t, unused); overridable in the model constructor - VlasovMaxwellQuasiNeutral: velocity scale cyclotron, constructor arguments lam, kappa; Ampere's law lam^2 dE/dt = curl B - kappa^2/eps J; doc_normalization directly below doc_pde; energies en_E, en_B, en_f and en_tot = conserved energy of the coupling step; Gauss' law lam^2 div E = c2 rho for the initial Poisson solve, which is skipped for lam = 0 (gauss_error NaN) - VlasovMaxwellCoupling: option kappa (displacement current) renamed to lam (with coefficient lam^2); defaults lam = eps lam_s, c2 = kappa_s^2 eps, c3 = 1/eps, i.e. lam_s, kappa_s^2, 1 for the cyclotron and 1, alpha^2/eps, 1/eps for the light velocity scale; energies() parts - docs: lam and kappa under Equation parameters; cyclotron velocity without the spurious 2 pi; models/AGENTS.md: doc_normalization directly below doc_pde - example, verification test (gamma unchanged, 0.02770) and tests with lam, kappa Co-Authored-By: Claude Opus 5.5 --- .../subsections/models-normalization.rst | 24 +- .../params_weibel_quasi_neutral.py | 50 ++-- .../pproc_weibel_quasi_neutral.py | 10 +- src/struphy/models/AGENTS.md | 2 + src/struphy/models/species.py | 42 ++- .../test_verif_VlasovMaxwellQuasiNeutral.py | 19 +- .../models/vlasov_maxwell_quasi_neutral.py | 246 ++++++++++++++---- .../propagators/tests/test_matrix_free_6d.py | 12 +- .../tests/test_vlasov_maxwell_coupling.py | 109 ++++++-- .../propagators/vlasov_maxwell_coupling.py | 112 ++++---- 10 files changed, 451 insertions(+), 175 deletions(-) diff --git a/doc/sections/subsections/models-normalization.rst b/doc/sections/subsections/models-normalization.rst index 058d5d801..f8926413f 100644 --- a/doc/sections/subsections/models-normalization.rst +++ b/doc/sections/subsections/models-normalization.rst @@ -49,7 +49,9 @@ There are four possibilities: .. math:: - \hat v = v_\textnormal{c, bulk} := \hat x \Omega_\textnormal{c, bulk}/(2\pi) = \hat x\, q_\textnormal{bulk} \hat B /(m_\textnormal{bulk}2\pi)\,. + \hat v = v_\textnormal{c, bulk} := \hat x\, \Omega_\textnormal{c, bulk} = \hat x\, q_\textnormal{bulk} \hat B /m_\textnormal{bulk}\,, + +so that :math:`\hat t = 1/\Omega_\textnormal{c, bulk}`. 4. ``thermal`` velocity of the bulk species, @@ -106,7 +108,8 @@ In Struphy models, the following equation parameters appear: .. math:: - \alpha_\textrm{s} = \frac{\hat \Omega_\textnormal{ps}}{\hat \Omega_\textnormal{cs}}\,,\qquad \varepsilon_\textrm{s} = \frac{1}{\hat \Omega_\textnormal{cs} \hat t} \,, + \alpha_\textrm{s} = \frac{\hat \Omega_\textnormal{ps}}{\hat \Omega_\textnormal{cs}}\,,\qquad \varepsilon_\textrm{s} = \frac{1}{\hat \Omega_\textnormal{cs} \hat t} \,,\qquad + \lambda_\textrm{s} = \frac{\hat \Omega_\textnormal{cs} \hat x}{c}\,,\qquad \kappa_\textrm{s} = \frac{\hat \Omega_\textnormal{ps} \hat x}{c} = \alpha_\textrm{s} \lambda_\textrm{s}\,, featuring the plasma- and cyclotron frequency of species :math:`\textrm{s}`, respectively, @@ -115,6 +118,23 @@ featuring the plasma- and cyclotron frequency of species :math:`\textrm{s}`, res \hat\Omega_\textnormal{ps} = \sqrt{\frac{\hat n (Z_\textrm{s}e)^2}{\epsilon_0 (A_\textrm{s} m_\textnormal{H})}} \,,\qquad \hat \Omega_{\textnormal{cs}} = \frac{(Z_\textrm{s}e) \hat B}{(A_\textrm{s} m_\textnormal{H})}\,, where :math:`Z_\textrm{s}` and :math:`A_\textrm{s}` stand for the species' charge and mass number, respectively. + +The parameters :math:`\lambda_\textrm{s}` and :math:`\kappa_\textrm{s}` (``lam`` and ``kappa`` in the code) measure the +length unit in units of the inertial lengths :math:`c/\hat \Omega_\textnormal{cs}` and :math:`c/\hat \Omega_\textnormal{ps}`. +With the ``cyclotron`` velocity scale of species :math:`\textrm{s}` (hence :math:`\varepsilon_\textrm{s} = 1`), Ampère's law +:math:`\frac{1}{c^2} \partial_t \mathbf E = \nabla \times \mathbf B - \mu_0 \mathbf J` becomes + +.. math:: + + \lambda_\textrm{s}^2 \frac{\partial \mathbf E'}{\partial t'} = \nabla' \times \mathbf B' - \kappa_\textrm{s}^2 \mathbf J'\,, + +with :math:`\hat E = \hat B \hat v` and :math:`\hat \jmath = q_\textrm{s} \hat n \hat v`, while the Vlasov equation and +Faraday's law have no coefficients. :math:`\lambda_\textrm{s} \to 0` is the limit without displacement current +(quasi-neutral limit). For a general velocity scale the coefficients are :math:`\varepsilon_\textrm{s}^2 \lambda_\textrm{s}^2` +and :math:`\kappa_\textrm{s}^2 \varepsilon_\textrm{s}`, and :math:`1/\varepsilon_\textrm{s}` in the Vlasov equation; for the +``light`` velocity scale :math:`\varepsilon_\textrm{s} \lambda_\textrm{s} = 1` and +:math:`\kappa_\textrm{s}^2 \varepsilon_\textrm{s} = \alpha_\textrm{s}^2 / \varepsilon_\textrm{s}`. + These equation parameters are defined in :class:`~struphy.models.species.Species.EquationParameters` and can be overridden in the model constructor. diff --git a/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py index 1bcfcfcc8..5b339fb28 100644 --- a/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py +++ b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/params_weibel_quasi_neutral.py @@ -1,5 +1,5 @@ """Weibel instability with the VlasovMaxwellQuasiNeutral model, with and without displacement current -(`--kappa 1` or `--kappa 0`), at a time step 20 times larger than in the VlasovMaxwellOneSpecies example.""" +(`--lam 1` or `--lam 0`), at a time step 20 times larger than in the VlasovMaxwellOneSpecies example.""" import argparse from pathlib import Path @@ -12,9 +12,12 @@ default=str(Path(__file__).resolve().parent), help="Folder in which the output folder is created (default: beside this file).", ) -parser.add_argument("--sim-folder", default=None, help="Name of the output folder (default: sim_kappa).") +parser.add_argument("--sim-folder", default=None, help="Name of the output folder (default: sim_lam).") parser.add_argument( - "--kappa", type=float, default=0.0, help="Coefficient of the displacement current (1: Maxwell, 0: quasi-neutral)." + "--lam", + type=float, + default=0.0, + help="lam = Omega_c x / c, lam^2 multiplies the displacement current (0: quasi-neutral).", ) parser.add_argument("--dt", type=float, default=1.0, help="Time step.") parser.add_argument("--tend", type=float, default=400.0, help="End time.") @@ -25,7 +28,7 @@ ) args, _ = parser.parse_known_args() if args.sim_folder is None: - args.sim_folder = f"sim_kappa{args.kappa:g}" + args.sim_folder = f"sim_lam{args.lam:g}" if args.test_run: args.tend = 2 * args.dt @@ -36,19 +39,22 @@ equations in a periodic box with a neutralizing ion background, solved with the implicit, energy conserving VlasovMaxwellCoupling step. The electrons are hotter in v2 than in v1 (along the wave vector); this temperature anisotropy makes a small transverse magnetic field B3 grow exponentially until magnetic trapping saturates it. -Here kappa = {args.kappa:g}. +Here lam = {args.lam:g}. -Physics. With alpha = 1 and epsilon = -1 (electrons, time in units of 1/omega_p, velocities in units of c, lengths -in units of the skin depth c/omega_p) the model solves +Physics. The model uses the cyclotron normalization (time in units of 1/Omega_c, velocities in units of Omega_c x), +with the equation parameters lam = Omega_c x / c and kappa = Omega_p x / c. With kappa = 1 lengths are in units of +the skin depth c/omega_p; with lam = 1 also Omega_c = omega_p, so time is in units of 1/omega_p and velocities in +units of c, as in the VlasovMaxwellOneSpecies Weibel example (alpha = 1). With epsilon = -1 (electrons) the model +solves df/dt + v . grad_x f - (E + v x B) . grad_v f = 0, - kappa dE/dt = curl B + int v f d^3v, + lam^2 dE/dt = curl B + kappa^2 int v f d^3v, dB/dt = -curl E. -kappa = 1 is the Vlasov-Maxwell system; kappa = 0 drops the displacement current (quasi-neutral, Darwin-type -limit), where E is the Lagrange multiplier of the constraint curl B = J. For a bi-Maxwellian with thermal speeds -vth1 (along k) and vth2, the transverse mode (E2, B3) obeys - kappa omega^2 - k^2 - 1 + (vth2^2 / vth1^2) [1 + xi Z(xi)] = 0, xi = omega / (sqrt(2) k vth1) -(the 1 is the plasma term, which stays for kappa = 0). The mode is purely growing, omega = i gamma, and -kappa gamma^2 ~ 8e-4 is negligible against k^2 + 1 = 2.56: the Weibel instability is a low-frequency, transverse +lam = 1 is the Vlasov-Maxwell system; lam = 0 drops the displacement current (quasi-neutral, Darwin-type limit), +where E is the Lagrange multiplier of the constraint curl B = J. For a bi-Maxwellian with thermal speeds vth1 (along +k) and vth2, the transverse mode (E2, B3) obeys (kappa = 1) + lam^2 omega^2 - k^2 - 1 + (vth2^2 / vth1^2) [1 + xi Z(xi)] = 0, xi = omega / (sqrt(2) k vth1) +(the 1 is the plasma term, which stays for lam = 0). The mode is purely growing, omega = i gamma, and +lam^2 gamma^2 ~ 8e-4 is negligible against k^2 + 1 = 2.56: the Weibel instability is a low-frequency, transverse (div J = 0) mode that the quasi-neutral limit keeps. Setup. As in the VlasovMaxwellOneSpecies Weibel example: periodic box of length 2 pi / k with k = 1.25, 32 cells @@ -59,17 +65,17 @@ dt = {args.dt:g} (20 times the dt = 0.05 of the VlasovMaxwellOneSpecies example; the implicit coupling step is stable for dt omega_p > 1) up to t = {args.tend:g}, Lie-Trotter splitting. -Expected result. For k = 1.25 the dispersion relation gives gamma = 0.02784 for both kappa = 1 and kappa = 0 (the -growth rate of the amplitude of B3; the magnetic energy grows as exp(2 gamma t)). The energy en_H conserved by the +Expected result. For k = 1.25 the dispersion relation gives gamma = 0.02784 for both lam = 1 and lam = 0 (the +growth rate of the amplitude of B3; the magnetic energy grows as exp(2 gamma t)). The total energy en_tot conserved by the coupling step changes only by the splitting with the particle pushers. The pproc fits gamma to the magnetic energy (between t = Tend/5 and 2 Tend/5), compares with the analytical value and plots the energies. Measured with this -setup (serial, 2026-10-23): gamma = 0.02805 for kappa = 1 and gamma = 0.02758 for kappa = 0, within 1 % of the -analytical value; the relative change of en_H stays below 6e-9. With pseudo-random markers instead, the fitted rate +setup (serial, 2026-10-23): gamma = 0.02805 for lam = 1 and gamma = 0.02758 for lam = 0, within 1 % of the +analytical value; the relative change of en_tot stays below 6e-9. With pseudo-random markers instead, the fitted rate is about 10 % low and scatters by about 7 % between seeds (marker noise); the large time step changes it only by -about 1 %. The verification test test_verif_VlasovMaxwellQuasiNeutral.py runs this setup for kappa = 0 up to +about 1 %. The verification test test_verif_VlasovMaxwellQuasiNeutral.py runs this setup for lam = 0 up to t = 160. -For kappa = 0 the coupling step projects the initial state onto curl B = J (option initial_projection, default): +For lam = 0 the coupling step projects the initial state onto curl B = J (option initial_projection, default): the constraint holds only at the mid-point of each step, so initial data that violate it (here by the marker noise of J) would oscillate with period 2. @@ -113,7 +119,7 @@ from struphy.models import VlasovMaxwellQuasiNeutral -model = VlasovMaxwellQuasiNeutral(base_units=BaseUnits(), alpha=1.0, epsilon=-1.0) +model = VlasovMaxwellQuasiNeutral(base_units=BaseUnits(), epsilon=-1.0, lam=args.lam, kappa=1.0) k = 1.25 B_pert_amp = -1e-4 @@ -170,7 +176,7 @@ # Propagator options # ------------------ -model.propagators.coupling.options = model.propagators.coupling.Options(kappa=args.kappa) +model.propagators.coupling.options = model.propagators.coupling.Options() model.propagators.push_eta.options = model.propagators.push_eta.Options() model.propagators.push_vxb.options = model.propagators.push_vxb.Options() model.initial_poisson.options = model.initial_poisson.Options(stab_mat="M0") diff --git a/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/pproc_weibel_quasi_neutral.py b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/pproc_weibel_quasi_neutral.py index 5101bcd31..aff17997c 100644 --- a/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/pproc_weibel_quasi_neutral.py +++ b/examples/VlasovMaxwellQuasiNeutral/weibel_quasi_neutral/pproc_weibel_quasi_neutral.py @@ -1,6 +1,6 @@ """Post-process the quasi-neutral Weibel example: growth rate of the magnetic energy and energy conservation. -Run as ``python pproc_weibel_quasi_neutral.py [--kappa K]`` (same flags as the params file) or with the path of the +Run as ``python pproc_weibel_quasi_neutral.py [--lam L]`` (same flags as the params file) or with the path of the output folder. Writes ``energies.png`` into ``/results`` and prints the fitted growth rate. """ @@ -26,7 +26,7 @@ def main(path_out=DEFAULT_OUTPUT): scalars = out.scalars t = scalars["en_B"].t.values en_B = scalars["en_B"].values - en_H = scalars["en_H"].values + en_tot = scalars["en_tot"].values results_dir = Path(path_out) / "results" results_dir.mkdir(exist_ok=True) @@ -42,7 +42,7 @@ def main(path_out=DEFAULT_OUTPUT): else: gamma = None print("Too few saved time steps for a growth-rate fit.") - print(f"max relative change of en_H: {np.max(np.abs(en_H - en_H[0])) / abs(en_H[0]):.2e}") + print(f"max relative change of en_tot: {np.max(np.abs(en_tot - en_tot[0])) / abs(en_tot[0]):.2e}") fig, axs = plt.subplots(1, 2, figsize=(11, 4)) axs[0].semilogy(t, en_B, label="en_B") @@ -55,9 +55,9 @@ def main(path_out=DEFAULT_OUTPUT): axs[0].set_ylim(en_B.min() / 10, en_B.max() * 10) axs[0].set_xlabel("t") axs[0].legend() - axs[1].plot(t, (en_H - en_H[0]) / abs(en_H[0])) + axs[1].plot(t, (en_tot - en_tot[0]) / abs(en_tot[0])) axs[1].set_xlabel("t") - axs[1].set_ylabel("(en_H - en_H(0)) / en_H(0)") + axs[1].set_ylabel("(en_tot - en_tot(0)) / en_tot(0)") fig.suptitle(f"Weibel instability, {out.path_out.name}") fig.tight_layout() fig.savefig(results_dir / "energies.png") diff --git a/src/struphy/models/AGENTS.md b/src/struphy/models/AGENTS.md index cb7a15b22..4eac2f7b5 100644 --- a/src/struphy/models/AGENTS.md +++ b/src/struphy/models/AGENTS.md @@ -44,6 +44,8 @@ Read this before creating or renaming a model in `src/struphy/models/`. The gene - All 8 doc classmethods, written as reST docstrings with math in `.. math::` / `:math:`: `doc_pde`, `doc_normalization`, `doc_scalar_quantities`, `doc_long_description`, `doc_examples`, `doc_use_cases`, `doc_cannot_be_used_for`, and `doc_discretization`. + Put `doc_normalization` directly below `doc_pde` (also in subclasses that change the normalization), since the + normalization defines the parameters appearing in the PDEs. `doc_discretization` must **return** a string: list the propagators in splitting order with `:class:` links and embed each propagator's `__doc__` (see `maxwell.py`). `test_all_models_expose_new_doc_api` fails if any is missing or empty. - Add an `.. autoclass:: struphy.models.` block (same options as its neighbours) under the matching category in diff --git a/src/struphy/models/species.py b/src/struphy/models/species.py index 3c8b47657..b43464713 100644 --- a/src/struphy/models/species.py +++ b/src/struphy/models/species.py @@ -56,11 +56,14 @@ class Species(metaclass=ABCMeta): epsilon : float, optional Normalized cyclotron period: 1 / (cyclotron frequency × time unit). If None, computed from units and charge/mass numbers. + lam : float, optional + Cyclotron frequency × length unit / speed of light. + If None, computed from units and charge/mass numbers. kappa : float, optional - Normalized plasma frequency: plasma frequency × time unit. + Plasma frequency × length unit / speed of light. If None, computed from units and charge/mass numbers. equation_params : EquationParameters - Object containing normalization parameters (alpha, epsilon, kappa) for scaled equations. + Object containing normalization parameters (alpha, epsilon, lam, kappa) for scaled equations. Methods ------- @@ -94,6 +97,7 @@ def to_dict(self) -> dict: "mass_number": self.mass_number, "alpha": self.alpha, "epsilon": self.epsilon, + "lam": self.lam, "kappa": self.kappa, "variables": {name: variable.to_dict() for name, variable in self.variables.items()}, } @@ -105,11 +109,12 @@ def init_variables( alpha: float = None, epsilon: float = None, kappa: float = None, + lam: float = None, ): """Create variables dict and set physical and equation parameters for a plasma species. Sets the charge and mass numbers, and optionally overrides normalized equation parameters - (alpha, epsilon, kappa) that would otherwise be computed from physical units. + (alpha, epsilon, lam, kappa) that would otherwise be computed from physical units. Parameters ---------- @@ -124,7 +129,10 @@ def init_variables( Normalized cyclotron period: 1 / (cyclotron frequency × time unit). If None, computed from units and charge/mass numbers (default = None). kappa : float, optional - Normalized plasma frequency: plasma frequency × time unit. + Plasma frequency × length unit / speed of light. + If None, computed from units and charge/mass numbers (default = None). + lam : float, optional + Cyclotron frequency × length unit / speed of light. If None, computed from units and charge/mass numbers (default = None).""" # create variables dict @@ -141,6 +149,7 @@ def init_variables( self._alpha = alpha self._epsilon = epsilon self._kappa = kappa + self._lam = lam @property def variables(self) -> dict: @@ -174,9 +183,16 @@ def epsilon(self) -> float: self._epsilon = None return self._epsilon + @property + def lam(self) -> float: + """Cyclotron frequency times length unit over speed of light, Omega_c * x_unit / c (default = None).""" + if not hasattr(self, "_lam"): + self._lam = None + return self._lam + @property def kappa(self) -> float: - """The normalized plasma frequency, Omega_p * time_unit (default = None).""" + """Plasma frequency times length unit over speed of light, Omega_p * x_unit / c (default = None).""" if not hasattr(self, "_kappa"): self._kappa = None return self._kappa @@ -191,6 +207,7 @@ def __init__( alpha: float = None, epsilon: float = None, kappa: float = None, + lam: float = None, ): self.species = species @@ -221,8 +238,17 @@ def __init__( if MPI.COMM_WORLD.Get_rank() == 0: warnings.warn(f"Override equation parameter {self.epsilon =}") + # lam and kappa: the coefficients lam^2 of the displacement current and kappa^2 of the plasma current in + # Ampere's law for the cyclotron velocity scale (kappa = alpha * lam) + if lam is None: + self.lam = om_c * units.x / con.c + else: + self.lam = lam + if MPI.COMM_WORLD.Get_rank() == 0: + warnings.warn(f"Override equation parameter {self.lam =}") + if kappa is None: - self.kappa = om_p * units.t + self.kappa = om_p * units.x / con.c else: self.kappa = kappa if MPI.COMM_WORLD.Get_rank() == 0: @@ -243,7 +269,8 @@ def setup_equation_params(self, units: Units): * alpha = plasma-frequenca / cyclotron frequency * epsilon = 1 / (cyclotron frequency * time unit) - * kappa = plasma frequency * time unit + * lam = cyclotron frequency * length unit / speed of light + * kappa = plasma frequency * length unit / speed of light """ self._equation_params = self.EquationParameters( species=self, @@ -251,6 +278,7 @@ def setup_equation_params(self, units: Units): alpha=self.alpha, epsilon=self.epsilon, kappa=self.kappa, + lam=self.lam, ) diff --git a/src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py b/src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py index 44a970d47..c41e86b9c 100644 --- a/src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py +++ b/src/struphy/models/tests/verification/test_verif_VlasovMaxwellQuasiNeutral.py @@ -26,13 +26,13 @@ logger = logging.getLogger("struphy") -# growth rate of the amplitude of B3 for k = 1.25, vth2^2 / vth1^2 = 12 (Kraus et al. 2017); the same for kappa = 0, -# since the displacement-current term gamma^2 ~ 8e-4 of the kappa = 1 dispersion relation is << k^2 + 1 +# growth rate of the amplitude of B3 for k = 1.25, vth2^2 / vth1^2 = 12 (Kraus et al. 2017); the same for lam = 0, +# since the displacement-current term lam^2 gamma^2 ~ 8e-4 of the lam = 1 dispersion relation is << k^2 + 1 GAMMA_EXACT = 0.02784 def _weibel_growth_rate( - kappa: float, Np: int, Tend: float, t_fit: tuple, dt: float = 1.0, loading: str = "sobol_antithetic" + lam: float, Np: int, Tend: float, t_fit: tuple, dt: float = 1.0, loading: str = "sobol_antithetic" ) -> float: """Runs the Weibel instability of the example weibel_quasi_neutral (1D-3V, k = 1.25, full-f markers) and returns the growth rate fitted to the magnetic energy, en_B ~ exp(2 gamma t), for t_fit[0] < t < t_fit[1]. @@ -41,11 +41,12 @@ def _weibel_growth_rate( per point, so that the mean velocities of each group vanish) removes most of the noise of the current, which limits the fit: with pseudo-random markers the fitted rate scatters by about 7 % between seeds (1e5 markers).""" - model = VlasovMaxwellQuasiNeutral(alpha=1.0, epsilon=-1.0) + # electrons, cyclotron normalization with Omega_c = Omega_p (kappa = 1): lengths in units of the skin depth + model = VlasovMaxwellQuasiNeutral(epsilon=-1.0, lam=lam, kappa=1.0) test_folder = os.path.join(os.getcwd(), "struphy_verification_tests") out_folders = os.path.join(test_folder, "VlasovMaxwellQuasiNeutral") - env = EnvironmentOptions(out_folders=out_folders, sim_folder=f"weibel_kappa{kappa:g}_Np{Np}_{loading}") + env = EnvironmentOptions(out_folders=out_folders, sim_folder=f"weibel_lam{lam:g}_Np{Np}_{loading}") k = 1.25 vth1 = 0.02 / math.sqrt(2) @@ -64,7 +65,7 @@ def _weibel_growth_rate( saving_params=SavingParameters(n_markers=0), bufsize=2.0, ) - model.propagators.coupling.options = model.propagators.coupling.Options(kappa=kappa) + model.propagators.coupling.options = model.propagators.coupling.Options() model.propagators.push_eta.options = model.propagators.push_eta.Options() model.propagators.push_vxb.options = model.propagators.push_vxb.Options() model.initial_poisson.options = model.initial_poisson.Options(stab_mat="M0") @@ -96,12 +97,12 @@ def _weibel_growth_rate( def test_weibel_growth_rate_quasi_neutral(): - """Weibel instability in the quasi-neutral limit (kappa = 0, no displacement current): the magnetic energy grows + """Weibel instability in the quasi-neutral limit (lam = 0, no displacement current): the magnetic energy grows with the analytical growth rate of the Vlasov-Maxwell system, gamma = 0.02784, which the displacement current does not change. With 1e5 full-f markers (antithetic Sobol loading) and dt = 1 the fitted rate is close to the analytical one (about 0.5 %), on 1, 2 and 4 MPI ranks.""" - gamma = _weibel_growth_rate(kappa=0.0, Np=100_000, Tend=160.0, t_fit=(80.0, 150.0)) - logger.info(f"Weibel growth rate (kappa = 0): {gamma = }, analytical {GAMMA_EXACT}") + gamma = _weibel_growth_rate(lam=0.0, Np=100_000, Tend=160.0, t_fit=(80.0, 150.0)) + logger.info(f"Weibel growth rate (lam = 0): {gamma = }, analytical {GAMMA_EXACT}") assert abs(gamma / GAMMA_EXACT - 1) < 0.07, f"{gamma = } vs. {GAMMA_EXACT = }" comm = MPI.COMM_WORLD diff --git a/src/struphy/models/vlasov_maxwell_quasi_neutral.py b/src/struphy/models/vlasov_maxwell_quasi_neutral.py index 300d9febb..42ffaa8ce 100644 --- a/src/struphy/models/vlasov_maxwell_quasi_neutral.py +++ b/src/struphy/models/vlasov_maxwell_quasi_neutral.py @@ -1,19 +1,31 @@ +import copy +import logging + from struphy import BaseUnits from struphy.models.scalars import FunctionScalarFEEC, Scalars +from struphy.models.species import ParticleSpecies +from struphy.models.variables import PICVariable from struphy.models.vlasov_maxwell_one_species import VlasovMaxwellOneSpecies +from struphy.pic.accumulation.kernels.charge_density_0form import charge_density_0form +from struphy.pic.accumulation.particles_to_grid import ParticlesToGrid +from struphy.propagators.poisson_solve import PoissonSolve from struphy.propagators.push_eta import PushEta from struphy.propagators.push_vxb import PushVxB from struphy.propagators.vlasov_maxwell_coupling import VlasovMaxwellCoupling +logger = logging.getLogger("struphy") + class VlasovMaxwellQuasiNeutral(VlasovMaxwellOneSpecies): - """Vlasov-Maxwell equations for one kinetic species with an implicit, energy conserving field-particle coupling - that also holds in the quasi-neutral limit (no displacement current). + """Vlasov-Maxwell equations for one kinetic species in the cyclotron normalization, with an implicit, energy + conserving field-particle coupling that also holds in the quasi-neutral limit (no displacement current). - Same as :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies`, but Ampère's law, Faraday's + Same equations as :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies`, normalized with the + cyclotron frequency (velocity scale ``"cyclotron"``), so that the coefficients :math:`\\lambda^2` of the + displacement current and :math:`\\kappa^2` of the plasma current appear in Ampère's law. Ampère's law, Faraday's law and the electric force on the particles are solved together in one Crank-Nicolson step - (:class:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling`). Its option ``kappa`` multiplies the - displacement current; ``kappa=0.0`` is the quasi-neutral limit. + (:class:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling`); ``lam=0.0`` is the quasi-neutral + limit. Parameters ---------- @@ -26,11 +38,43 @@ class VlasovMaxwellQuasiNeutral(VlasovMaxwellOneSpecies): alpha: float, optional Dimensionless parameter: plasma frequency / cyclotron frequency. If None, computed from units and charge/mass numbers. epsilon: float, optional - Normalized cyclotron period: 1 / (cyclotron frequency × time unit). If None, computed from units and charge/mass numbers. + Normalized cyclotron period: 1 / (cyclotron frequency × time unit). If None, computed from units and charge/mass + numbers (equal to 1 for this model's cyclotron velocity scale; set -1 for electrons with the default charge + number). + lam: float, optional + Cyclotron frequency × length unit / speed of light (coefficient lam^2 of the displacement current; 0 is the + quasi-neutral limit). If None, computed from units and charge/mass numbers. + kappa: float, optional + Plasma frequency × length unit / speed of light (coefficient kappa^2 of the plasma current). If None, computed + from units and charge/mass numbers. measure_gauss_law: bool - Whether to track the Gauss-law error as a scalar quantity (default: False) + Whether to track the Gauss-law error as a scalar quantity (default: False; not available for lam = 0) """ + ## species + + class KineticIons(ParticleSpecies): + def __init__( + self, + charge_number: int = 1, + mass_number: float = 1.0, + alpha: float = None, + epsilon: float = None, + lam: float = None, + kappa: float = None, + ): + self.var = PICVariable(space="Particles6D") + self.init_variables( + charge_number=charge_number, + mass_number=mass_number, + alpha=alpha, + epsilon=epsilon, + lam=lam, + kappa=kappa, + ) + + ## propagators + class Propagators: def __init__( self, @@ -40,6 +84,8 @@ def __init__( self.push_eta = PushEta() self.push_vxb = PushVxB(b2_var=b2_var) + ## abstract methods + def __init__( self, base_units: BaseUnits = BaseUnits(), @@ -47,19 +93,63 @@ def __init__( mass_number: float = 1.0, alpha: float = None, epsilon: float = None, + lam: float = None, + kappa: float = None, measure_gauss_law: bool = False, ): - super().__init__( - base_units=base_units, - charge_number=charge_number, - mass_number=mass_number, - alpha=alpha, - epsilon=epsilon, - measure_gauss_law=measure_gauss_law, + # 0. store input parameters + self.params = copy.deepcopy(locals()) + + # 1. instantiate all species + self.em_fields = self.EMFields() + self.kinetic_ions = self.KineticIons(charge_number, mass_number, alpha, epsilon, lam, kappa) + + # 2. derive units (must be done after instantiating species to access charge and mass numbers) + self.setup_equation_params(base_units=base_units) + + # 3. instantiate all propagators + self.propagators = self.Propagators(b2_var=self.em_fields.b_field) + + # 4. assign variables to propagators + self._link_propagator_variables() + + # 5. define scalars to be tracked during simulation: the parts of the energy conserved by the coupling step + # (with the coefficients lam^2, c1, c2, c3 of the propagator, known after its allocation) + coupling = self.propagators.coupling + electric_energy = FunctionScalarFEEC(lambda: coupling.energies()[0]) + magnetic_energy = FunctionScalarFEEC(lambda: coupling.energies()[1]) + particle_energy = FunctionScalarFEEC(lambda: coupling.energies()[2]) + scalars_dict = { + "en_E": electric_energy, + "en_B": magnetic_energy, + "en_f": particle_energy, + "en_tot": electric_energy + magnetic_energy + particle_energy, + } + if measure_gauss_law: + # the MPI reduction (max over ranks) is done in calculate_gauss_error, not by summing the scalar + scalars_dict["gauss_error"] = FunctionScalarFEEC(self.calculate_gauss_error) + self.scalars = Scalars(**scalars_dict) + + # initial Poisson (not a propagator used in time stepping); its charge coefficient c2 / lam^2 is set in + # post_allocate, from the coefficients of the coupling propagator + particles_to_grid = ParticlesToGrid( + self.kinetic_ions.var, + "H1", + charge_density_0form, + ) + self.initial_poisson = PoissonSolve( + rho=particles_to_grid, + rho_coeffs=None, + delta_f_source=True, ) + self.initial_poisson.variables.phi = self.em_fields.phi - # the energy conserved by the coupling step (with its coefficients kappa, c1, c2, c3) - self.scalars = Scalars(**self.scalars.dct, en_H=FunctionScalarFEEC(self.propagators.coupling.energy)) + # property to measure violation of gauss law from control variate + self.measure_gauss_law = measure_gauss_law + + @property + def velocity_scale(self): + return "cyclotron" def _link_propagator_variables(self): """Assign the species variables to the propagators. @@ -72,6 +162,34 @@ def _link_propagator_variables(self): self.propagators.push_eta.variables.var = self.kinetic_ions.var self.propagators.push_vxb.variables.ions = self.kinetic_ions.var + def post_allocate(self): + """Solve the initial Poisson equation lam^2 div E = c2 rho (Gauss' law consistent with the coupling step), + except in the quasi-neutral limit lam = 0, where the initial electric field (zero) does not enter the steps. + + :meta private: + """ + coupling = self.propagators.coupling + self._quasi_neutral = coupling._lam2 == 0.0 + if self._quasi_neutral: + logger.info( + "\nlam = 0 (quasi-neutral limit): no initial Poisson solve, the Gauss-law error is not measured." + ) + if self.measure_gauss_law: + logger.warning("measure_gauss_law: Gauss' law is not defined for lam = 0; gauss_error is NaN.") + return + + self.initial_poisson.rho_coeffs = coupling._c2 / coupling._lam2 + super().post_allocate() + + def calculate_gauss_error(self): + r"""Maximum norm of the weak Gauss-law residual + :math:`\mathbb G^\top \mathbb M^1 \mathbf e + \frac{c_2}{\lambda^2} \boldsymbol \rho`, see + :meth:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies.calculate_gauss_error` + (NaN for :math:`\lambda = 0`).""" + if self._quasi_neutral: + return float("nan") + return super().calculate_gauss_error() + @classmethod def doc_pde(cls): r"""**PDEs solved by model:** @@ -82,12 +200,11 @@ def doc_pde(cls): \frac{\partial f}{\partial t} + \mathbf{v} \cdot \nabla f + \frac{1}{\varepsilon} \left( \mathbf{E} + \mathbf{v} \times \left( \mathbf{B} + \mathbf{B}_0 \right) \right) \cdot \frac{\partial f}{\partial \mathbf{v}} = 0 - Ampère's law, with the coefficient :math:`\kappa \geq 0` of the displacement current (option ``kappa`` of - :class:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling`, not the species parameter): + Ampère's law: .. math:: - -\kappa \frac{\partial \mathbf{E}}{\partial t} + \nabla \times \mathbf{B} = \frac{\alpha^2}{\varepsilon} \int_{\mathbb{R}^3} \mathbf{v} f \, \text{d}^3 \mathbf{v} + -\lambda^2 \frac{\partial \mathbf{E}}{\partial t} + \nabla \times \mathbf{B} = \frac{\kappa^2}{\varepsilon} \int_{\mathbb{R}^3} \mathbf{v} f \, \text{d}^3 \mathbf{v} Faraday's law: @@ -95,30 +212,64 @@ def doc_pde(cls): \frac{\partial \mathbf{B}}{\partial t} + \nabla \times \mathbf{E} = 0 - For :math:`\kappa = 1` these are the equations of - :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies`. For :math:`\kappa = 0` - (quasi-neutral limit) Ampère's law becomes :math:`\nabla \times \mathbf B = \frac{\alpha^2}{\varepsilon} - \mathbf J`, which implies :math:`\nabla \cdot \mathbf J = 0`; the electric field is then determined by this - constraint, not by its own evolution. The coefficients of the three coupling terms can be changed by the - options ``c1``, ``c2``, ``c3`` of the propagator. + with :math:`\varepsilon = 1` in the cyclotron normalization (set :math:`\varepsilon = -1` for electrons with the + default charge number; then :math:`\kappa^2/\varepsilon` carries the sign of the charge). For + :math:`\lambda = 0` (quasi-neutral limit) Ampère's law becomes :math:`\nabla \times \mathbf B = + \frac{\kappa^2}{\varepsilon} \int \mathbf v f \, \textrm d^3 \mathbf v`, which implies :math:`\nabla \cdot \mathbf J = 0`; the electric field is then determined by this + constraint, not by its own evolution. The coefficients of the coupling terms can be changed by the options + ``lam``, ``c1``, ``c2``, ``c3`` of the propagator + :class:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling`. - At initial time the weak Poisson equation is solved once to weakly satisfy Gauss' law, as in - :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies` (for :math:`\kappa = 0` the - initial electric field does not enter the time stepping). + At initial time the weak Poisson equation (Gauss' law :math:`\lambda^2 \nabla \cdot \mathbf E = \kappa^2 \rho`) + (with the charge density :math:`\rho = \frac{1}{\varepsilon} \int (f - f_0) \, \textrm d^3 \mathbf v`) is + solved once for :math:`\lambda > 0`, + + .. math:: + + \lambda^2 \int_{\Omega} \nabla \psi^{\top} \cdot \nabla \phi \, \textrm{d} \mathbf{x} = \frac{\kappa^2}{\varepsilon} \int_{\Omega} \int_{\mathbb{R}^3} \psi \, (f - f_0) \, \text{d}^3 \mathbf{v} \, \textrm{d} \mathbf{x} \qquad \forall \ \psi \in H^1 + \\[2mm] + \mathbf{E}(t=0) = -\nabla \phi(t=0) + + (for :math:`\lambda = 0` the initial electric field does not enter the time stepping). Moreover, it is assumed + that :math:`\nabla \times \mathbf{B}_0 = \frac{\kappa^2}{\varepsilon} \int_{\mathbb{R}^3} \mathbf{v} f_0 \, \text{d}^3 \mathbf{v}`, + where :math:`\mathbf{B}_0` is the static equilibrium magnetic field. """ + @classmethod + def doc_normalization(cls): + r"""The model uses the cyclotron velocity of the species as reference velocity: + + .. math:: + + \hat v = \hat\Omega_c \hat x,\qquad \hat t = \frac{1}{\hat\Omega_c},\qquad \hat E = \hat B \hat v,\qquad \hat\phi = \hat E \hat x. + + The species parameters (see :ref:`equation_params`) are + + .. math:: + + \lambda = \frac{\hat\Omega_c \hat x}{c}\,,\qquad \kappa = \frac{\hat\Omega_p \hat x}{c} = \alpha \lambda\,, + \qquad \varepsilon = \frac{1}{\hat\Omega_c \hat t} = 1\,, + + where :math:`c/\hat\Omega_c` and :math:`c/\hat\Omega_p` are the inertial lengths. The Vlasov equation and + Faraday's law then have no coefficients, and Ampère's law + :math:`\frac{1}{c^2} \partial_t \mathbf E = \nabla \times \mathbf B - \mu_0 \mathbf J` becomes + :math:`\lambda^2 \partial_t \mathbf E = \nabla \times \mathbf B - \kappa^2 \mathbf J`. The quasi-neutral limit is + :math:`\lambda \to 0` at fixed :math:`\kappa`. With :math:`\lambda = \kappa = 1` (and :math:`\varepsilon = -1` for + electrons) the equations are those of :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies` + with :math:`\alpha = 1`, i.e. lengths in units of :math:`c/\hat\Omega_p` and :math:`\hat\Omega_c = \hat\Omega_p`.""" + @classmethod def doc_scalar_quantities(cls): - r"""**The following scalars are tracked during simulation:** + r"""**The following scalars are tracked during simulation** (the parts of the energy conserved by the coupling + step, with the coefficients :math:`\lambda^2, c_1, c_2, c_3` of + :class:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling`): - - Electric field energy: ``en_E`` - - Magnetic field energy: ``en_B`` - - Particle kinetic energy: ``en_f`` - - Total energy: ``en_tot`` - - Energy conserved by the coupling step, - :math:`\frac{\kappa}{2} \| \mathbf E \|^2 + \frac{c_1}{2} \| \mathbf B \|^2 + \frac{c_2}{2 c_3} \sum_p w_p - |\mathbf v_p|^2`: ``en_H`` (equal to ``en_tot`` for the default coefficients and :math:`\kappa = 1`) - - Optional Gauss-law diagnostic: ``gauss_error``""" + - Electric field energy :math:`\frac{\lambda^2}{2} \int |\mathbf E|^2 \textrm d \mathbf x`: ``en_E`` + - Magnetic field energy :math:`\frac{c_1}{2} \int |\mathbf B|^2 \textrm d \mathbf x`: ``en_B`` + - Particle kinetic energy :math:`\frac{c_2}{2 c_3} \sum_p w_p |\mathbf v_p|^2`: ``en_f`` + (:math:`\frac{\kappa^2}{2} \sum_p w_p |\mathbf v_p|^2` for the default coefficients) + - Total energy: ``en_tot`` (conserved by the coupling step up to the solver tolerance) + - Optional Gauss-law diagnostic: ``gauss_error`` (NaN for :math:`\lambda = 0`)""" @classmethod def doc_discretization(cls): @@ -144,24 +295,23 @@ def doc_discretization(cls): @classmethod def doc_long_description(cls): - r"""VlasovMaxwellQuasiNeutral solves the Vlasov-Maxwell system of - :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies` with one implicit step for - Maxwell's equations and the electric force, which conserves the energy exactly and allows time steps - larger than the inverse plasma frequency and the light-wave CFL limit. Setting the coefficient of the - displacement current to zero gives the quasi-neutral (Darwin-type) limit, in which the electric field is the - Lagrange multiplier of a divergence-free current.""" + r"""VlasovMaxwellQuasiNeutral solves the Vlasov-Maxwell system in the cyclotron normalization, with one + implicit step for Maxwell's equations and the electric force, which conserves the energy exactly and allows time + steps larger than the inverse plasma frequency and the light-wave CFL limit. The coefficient :math:`\lambda^2` + of the displacement current can be set to zero (``lam=0``), which gives the quasi-neutral (Darwin-type) limit, + in which the electric field is the Lagrange multiplier of a divergence-free current.""" @classmethod def doc_examples(cls): - r"""Create a quasi-neutral Vlasov-Maxwell model: + r"""Create a quasi-neutral Vlasov-Maxwell model (electrons, lengths in units of the electron skin depth): .. code-block:: python from struphy.models import VlasovMaxwellQuasiNeutral - model = VlasovMaxwellQuasiNeutral() - prop = model.propagators.coupling - prop.options = prop.Options(kappa=0.0) + model = VlasovMaxwellQuasiNeutral(epsilon=-1.0, lam=0.0, kappa=1.0) + model.em_fields.b_field + model.kinetic_ions.var """ @classmethod @@ -169,7 +319,7 @@ def doc_use_cases(cls): r"""This model is appropriate for: - electromagnetic kinetic simulations with time steps above the plasma period - - the quasi-neutral limit of the Vlasov-Maxwell system (``kappa=0.0``) + - the quasi-neutral limit of the Vlasov-Maxwell system (``lam=0.0``) - comparison with :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies`""" @classmethod diff --git a/src/struphy/propagators/tests/test_matrix_free_6d.py b/src/struphy/propagators/tests/test_matrix_free_6d.py index a0cbbb26d..51669e726 100644 --- a/src/struphy/propagators/tests/test_matrix_free_6d.py +++ b/src/struphy/propagators/tests/test_matrix_free_6d.py @@ -64,8 +64,8 @@ def run(matrix_free: bool): _compare(run) -@pytest.mark.parametrize("kappa", [1.0, 0.0]) -def test_vlasov_maxwell_coupling_matrix_free(kappa: float): +@pytest.mark.parametrize("lam", [1.0, 0.0]) +def test_vlasov_maxwell_coupling_matrix_free(lam: float): """VlasovMaxwellCoupling: e-field, b-field and velocities after two steps (in a curved domain), with and without displacement current.""" import numpy as np @@ -74,14 +74,14 @@ def test_vlasov_maxwell_coupling_matrix_free(kappa: float): from struphy.models import VlasovMaxwellQuasiNeutral def run(matrix_free: bool): - model = VlasovMaxwellQuasiNeutral(alpha=1.0, epsilon=-1.0) + model = VlasovMaxwellQuasiNeutral(epsilon=-1.0, lam=lam, kappa=1.0) model.kinetic_ions.set_markers(loading_params=LoadingParameters(Np=1000, seed=1234)) model.kinetic_ions.var.add_background(maxwellians.Maxwellian3D()) model.em_fields.b_field.add_perturbation( perturbations.ModesSin(ls=(1,), ms=(1,), amps=(1e-2,), given_in_basis="2", comp=2) ) prop = model.propagators.coupling - prop.options = prop.Options(kappa=kappa, matrix_free=matrix_free, solver_params=_solver_params()) + prop.options = prop.Options(matrix_free=matrix_free, solver_params=_solver_params()) Simulation( model=model, domain=domains.Colella(alpha=0.1), @@ -91,7 +91,7 @@ def run(matrix_free: bool): particles = model.kinetic_ions.var.particles # the reference density of the preconditioner, from the assembled or the matrix-free particle matrix n_ref = np.array([prop._n_ref]) - # for kappa=0 the change of b and V is the initial projection onto curl B = J; the steps then leave them + # for lam=0 the change of b and V is the initial projection onto curl B = J; the steps then leave them # unchanged and give e = e_mid = 0 (the initial e here), so e is not compared changes = _steps( prop, @@ -101,7 +101,7 @@ def run(matrix_free: bool): particles.markers[3:6, particles.valid_mks], ), ) - return [n_ref] + (changes if kappa > 0.0 else changes[1:]) + return [n_ref] + (changes if lam > 0.0 else changes[1:]) _compare(run) diff --git a/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py b/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py index f36b31e92..989b962cf 100644 --- a/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py +++ b/src/struphy/propagators/tests/test_vlasov_maxwell_coupling.py @@ -8,7 +8,9 @@ set_logging_level(logging.WARNING) -def _model(model_name="VlasovMaxwellQuasiNeutral", control_variate=False, **options): +def _model( + model_name="VlasovMaxwellQuasiNeutral", control_variate=False, model_lam=1.0, measure_gauss_law=False, **options +): """Allocated model (1D-ish slab, full-f electrons with alpha=1, epsilon=-1, a density and a B perturbation) and its coupling propagator(s) with the given options.""" from struphy import ( @@ -24,7 +26,11 @@ def _model(model_name="VlasovMaxwellQuasiNeutral", control_variate=False, **opti ) from struphy.linear_algebra.solver import SolverParameters - model = getattr(models, model_name)(alpha=1.0, epsilon=-1.0) + # electrons; lam = kappa = 1 in the cyclotron normalization equals alpha = 1 in the light normalization + if model_name == "VlasovMaxwellQuasiNeutral": + model = getattr(models, model_name)(epsilon=-1.0, lam=model_lam, kappa=1.0, measure_gauss_law=measure_gauss_law) + else: + model = getattr(models, model_name)(alpha=1.0, epsilon=-1.0) model.kinetic_ions.set_markers( loading_params=LoadingParameters(ppc=40, seed=1234), weights_params=WeightsParameters(control_variate=control_variate), @@ -83,13 +89,13 @@ def _vnorm(v): @pytest.mark.parametrize("coeffs", list(COEFFS)) -@pytest.mark.parametrize("kappa", [1.0, 0.0]) +@pytest.mark.parametrize("lam", [1.0, 0.0]) @pytest.mark.parametrize("precond", ["StiffnessPreconditioner", "MassMatrixPreconditioner"]) -def test_energy_conservation(kappa, precond, coeffs): +def test_energy_conservation(lam, precond, coeffs): """The coupling step conserves its energy H exactly (up to the solver tolerance), also for dt >> 1/omega_p and - in the quasi-neutral limit kappa=0 (without the initial projection, after which the kappa=0 step at frozen + in the quasi-neutral limit lam=0 (without the initial projection, after which the lam=0 step at frozen positions exchanges no energy).""" - model = _model(kappa=kappa, precond=precond, initial_projection=False, **COEFFS[coeffs]) + model = _model(lam=lam, precond=precond, initial_projection=False, **COEFFS[coeffs]) prop = model.propagators.coupling b = model.em_fields.b_field.spline.vector energy_0 = prop.energy() @@ -104,14 +110,14 @@ def test_energy_conservation(kappa, precond, coeffs): @pytest.mark.parametrize("matrix_free", [False, True]) -@pytest.mark.parametrize("kappa", [1.0, 0.0]) -def test_discrete_equations(kappa, matrix_free): +@pytest.mark.parametrize("lam", [1.0, 0.0]) +def test_discrete_equations(lam, matrix_free): """The new state satisfies the Crank-Nicolson equations: Ampère's law - kappa M1 (e^{n+1} - e^n) / dt = c1 C^T M2 b_mid - c2 j(V_mid) (with V^{n+1} from the pusher, j linear in V at - fixed positions) and Faraday's law (b^{n+1} - b^n) / dt = -C e_mid, with e_mid = e^{n+1} for kappa = 0 (without + lam^2 M1 (e^{n+1} - e^n) / dt = c1 C^T M2 b_mid - c2 j(V_mid) (with V^{n+1} from the pusher, j linear in V at + fixed positions) and Faraday's law (b^{n+1} - b^n) / dt = -C e_mid, with e_mid = e^{n+1} for lam = 0 (without the initial projection, so that the step is not trivial).""" dt = 0.5 - model = _model(kappa=kappa, matrix_free=matrix_free, initial_projection=False, **COEFFS["explicit"]) + model = _model(lam=lam, matrix_free=matrix_free, initial_projection=False, **COEFFS["explicit"]) prop = model.propagators.coupling e0, b0, _ = _state(model) prop._accum(*prop._args_accum) @@ -121,17 +127,17 @@ def test_discrete_equations(kappa, matrix_free): prop._accum(*prop._args_accum) j_mid = (j0 + prop._accum.vectors[0]) * 0.5 b_mid = (b0 + b1) * 0.5 - e_mid = (e0 + e1) * 0.5 if kappa > 0.0 else e1 + e_mid = (e0 + e1) * 0.5 if lam > 0.0 else e1 curl_b = prop._curl.T.dot(prop._M2.dot(b_mid)) * prop._c1 current = j_mid * prop._c2 - residual = curl_b - current - prop._M1.dot(e1 - e0) * (kappa / dt) + residual = curl_b - current - prop._M1.dot(e1 - e0) * (lam**2 / dt) assert _norm(residual) < 1e-8 * (_norm(curl_b) + _norm(current)) faraday = (b1 - b0) * (1 / dt) + prop._curl.dot(e_mid) assert _norm(faraday) < 1e-10 * _norm(b1 - b0) / dt - if kappa == 0.0: + if lam == 0.0: # weakly divergence-free mid-point current div_j = prop.derham.grad.T.dot(j_mid) assert _norm(div_j) < 1e-8 * _norm(j_mid) @@ -154,7 +160,7 @@ def test_dt_change(): def test_agrees_with_split_propagators(): - """For kappa=1 and the species coefficients, one step agrees with MaxwellWeakAmpere followed by + """For lam=1 and the species coefficients, one step agrees with MaxwellWeakAmpere followed by VlasovAmpereCoupling up to the splitting error O(dt^2).""" diffs = [] for dt in (0.1, 0.05): @@ -175,21 +181,24 @@ def test_agrees_with_split_propagators(): def test_quasi_neutral_limit(): - """b and V converge like kappa to the kappa=0 solution (of the step without the initial projection); the - iteration counts stay bounded as kappa -> 0.""" + """b and V converge like lam^2 to the lam=0 solution (of the step without the initial projection); the + iteration counts stay bounded as lam -> 0.""" dt = 0.5 results = {} niter = {} - for kappa in (1e-2, 1e-4, 0.0): - model = _model(kappa=kappa, initial_projection=False) + for lam in (1e-1, 1e-2, 0.0): + model = _model(lam=lam, initial_projection=False) + # the same initial data for all lam: the initial Poisson field (Gauss' law lam^2 div E = c2 rho) scales + # like 1 / lam^2, and lam^2 M1 e^n would not vanish + model.em_fields.e_field.spline.vector *= 0.0 prop = model.propagators.coupling prop(dt) - niter[kappa] = prop._solver._info["niter"] + niter[lam] = prop._solver._info["niter"] _, b1, v1 = _state(model) - results[kappa] = (b1.toarray(), v1) + results[lam] = (b1.toarray(), v1) b_ref, v_ref = results[0.0] - errs = [_vnorm(results[k][0] - b_ref) + _vnorm(results[k][1] - v_ref) for k in (1e-2, 1e-4)] + errs = [_vnorm(results[k][0] - b_ref) + _vnorm(results[k][1] - v_ref) for k in (1e-1, 1e-2)] assert errs[1] < 3e-2 * errs[0], errs assert max(niter.values()) <= 2 * min(niter.values()), niter @@ -203,11 +212,11 @@ def _constraint_residual(prop): @pytest.mark.parametrize("matrix_free", [False, True]) def test_initial_projection(matrix_free): - """kappa=0: without the initial projection, the constraint residual r = c1 C^T M2 b - c2 j(V) changes sign in + """lam=0: without the initial projection, the constraint residual r = c1 C^T M2 b - c2 j(V) changes sign in every step (period-2 oscillation). The projection in the setup makes r vanish, does not depend on dt, keeps e, and the steps then leave b and V unchanged (frozen positions).""" # without projection: r^{n+1} = -r^n - model = _model(kappa=0.0, initial_projection=False, matrix_free=matrix_free) + model = _model(lam=0.0, initial_projection=False, matrix_free=matrix_free) prop = model.propagators.coupling r0 = _constraint_residual(prop) prop(0.5) @@ -218,7 +227,7 @@ def test_initial_projection(matrix_free): # with projection (default), for two dt projected = [] for dt in (0.5, 2.0): - model = _model(kappa=0.0, matrix_free=matrix_free) + model = _model(lam=0.0, matrix_free=matrix_free) prop = model.propagators.coupling # accumulate at the initial markers for the scale of the current prop._accum(*prop._args_accum) @@ -244,7 +253,7 @@ def test_initial_projection(matrix_free): def test_control_variate_quasi_neutral_rejected(): """The quasi-neutral limit needs full-f weights.""" with pytest.raises(AssertionError, match="quasi-neutral"): - _model(control_variate=True, kappa=0.0) + _model(control_variate=True, lam=0.0) @pytest.mark.mpi_skip @@ -254,7 +263,7 @@ def test_options(): Options = VlasovMaxwellCoupling.Options with pytest.raises(AssertionError): - Options(kappa=-1.0) + Options(lam=-1.0) with pytest.raises(AssertionError): Options(c1=0.0) with pytest.raises(AssertionError): @@ -269,3 +278,49 @@ def test_options(): assert Options().initial_projection is True assert Options().precond == "StiffnessPreconditioner" assert Options(solver="cg").precond is None + + +@pytest.mark.mpi_skip +def test_equation_parameters(): + """lam = Omega_c x / c and kappa = Omega_p x / c of the species: kappa = alpha lam; for the cyclotron velocity scale + (VlasovMaxwellQuasiNeutral) epsilon = 1, for the light velocity scale (VlasovMaxwellOneSpecies) epsilon lam = 1 and + kappa^2 epsilon = alpha^2 / epsilon, so that the propagator coefficients agree for both normalizations.""" + import math + + from struphy import BaseUnits + from struphy.models import VlasovMaxwellOneSpecies, VlasovMaxwellQuasiNeutral + + base_units = BaseUnits(x=0.01, B=0.5, n=2.0) + for model in (VlasovMaxwellQuasiNeutral(base_units=base_units), VlasovMaxwellOneSpecies(base_units=base_units)): + params = model.kinetic_ions.equation_params + assert math.isclose(params.kappa, params.alpha * params.lam, rel_tol=1e-12) + if isinstance(model, VlasovMaxwellQuasiNeutral): + assert math.isclose(params.epsilon, 1.0, rel_tol=1e-12) + else: + assert math.isclose(params.epsilon * params.lam, 1.0, rel_tol=1e-12) + assert math.isclose(params.kappa**2 * params.epsilon, params.alpha**2 / params.epsilon, rel_tol=1e-12) + + # overrides are passed to the species + params = VlasovMaxwellQuasiNeutral(lam=0.0, kappa=2.0).kinetic_ions.equation_params + assert params.lam == 0.0 and params.kappa == 2.0 + + +def test_model_coefficients(): + """The default propagator coefficients come from the species: lam^2 = (epsilon lam_s)^2, c2 = kappa_s^2 epsilon, + c3 = 1 / epsilon; lam=0 in the model is the quasi-neutral limit (no initial Poisson solve, gauss_error NaN).""" + import math + + model = _model(model_lam=0.5, lam=None, measure_gauss_law=True) + prop = model.propagators.coupling + epsilon = model.kinetic_ions.equation_params.epsilon + assert math.isclose(prop._lam2, (epsilon * 0.5) ** 2) + assert math.isclose(prop._c2, 1.0**2 * epsilon) + assert math.isclose(prop._c3, 1 / epsilon) + # Gauss' law of the initial Poisson solve: lam^2 div E = c2 rho + assert math.isclose(model.initial_poisson.coeffs[0], prop._c2 / prop._lam2) + assert not math.isnan(model.calculate_gauss_error()) + + model = _model(model_lam=0.0, lam=None, measure_gauss_law=True) + assert model.propagators.coupling._lam2 == 0.0 + assert math.isnan(model.calculate_gauss_error()) + assert _norm(model.em_fields.e_field.spline.vector) == 0.0 diff --git a/src/struphy/propagators/vlasov_maxwell_coupling.py b/src/struphy/propagators/vlasov_maxwell_coupling.py index d90f0c05d..5c693ebb4 100644 --- a/src/struphy/propagators/vlasov_maxwell_coupling.py +++ b/src/struphy/propagators/vlasov_maxwell_coupling.py @@ -30,7 +30,7 @@ class VlasovMaxwellCoupling(Propagator): .. math:: - \kappa \int_{\Omega} \frac{\partial \mathbf E}{\partial t} \cdot \mathbf F \,\textrm d \mathbf x + \lambda^2 \int_{\Omega} \frac{\partial \mathbf E}{\partial t} \cdot \mathbf F \,\textrm d \mathbf x &= c_1 \int_{\Omega} \mathbf B \cdot \nabla \times \mathbf F \,\textrm d \mathbf x - c_2 \int_{\Omega} \int_{\mathbb{R}^3} f \mathbf{v} \cdot \mathbf{F} \, \text{d}^3 \mathbf{v} \,\textrm d \mathbf x \qquad \forall \, \mathbf F \in H(\textnormal{curl})\,, @@ -39,11 +39,15 @@ class VlasovMaxwellCoupling(Propagator): \\[2mm] \frac{\partial f}{\partial t} + c_3\, \mathbf{E} \cdot \frac{\partial f}{\partial \mathbf{v}} &= 0\,, - with :math:`\kappa \geq 0`, :math:`c_1 > 0` and :math:`c_2 c_3 > 0`. It solves the steps of + with :math:`\lambda \geq 0`, :math:`c_1 > 0` and :math:`c_2 c_3 > 0`. It solves the steps of :class:`~struphy.propagators.maxwell_weak_ampere.MaxwellWeakAmpere` and :class:`~struphy.propagators.vlasov_ampere_coupling.VlasovAmpereCoupling` in one implicit step (for - :math:`\kappa = 1`, :math:`c_1 = 1`, :math:`c_2 = \alpha^2/\varepsilon`, :math:`c_3 = 1/\varepsilon`). - For :math:`\kappa = 0` the displacement current vanishes, :math:`c_1 \nabla \times \mathbf B = c_2 \mathbf J` + :math:`\lambda = 1`, :math:`c_1 = 1`, :math:`c_2 = \alpha^2/\varepsilon`, :math:`c_3 = 1/\varepsilon`, the light + velocity scale). The defaults are the coefficients of the species' :ref:`equation parameters `, + :math:`\lambda = \varepsilon \lambda_\textrm{s}`, :math:`c_2 = \kappa_\textrm{s}^2 \varepsilon`, + :math:`c_3 = 1/\varepsilon`, i.e. :math:`\lambda = \lambda_\textrm{s}`, :math:`c_2 = \kappa_\textrm{s}^2`, + :math:`c_3 = 1` for the cyclotron velocity scale. + For :math:`\lambda = 0` the displacement current vanishes, :math:`c_1 \nabla \times \mathbf B = c_2 \mathbf J` (quasi-neutral limit). With the FE coefficients :math:`\mathbf e \in \mathbb R^{N_1}` of :math:`\mathbf E \in V^1_h` and @@ -54,7 +58,7 @@ class VlasovMaxwellCoupling(Propagator): .. math:: - \kappa\, \mathbb M_1 \dot{\mathbf e} = c_1\, \mathbb C^\top \mathbb M_2 \mathbf b + \lambda^2\, \mathbb M_1 \dot{\mathbf e} = c_1\, \mathbb C^\top \mathbb M_2 \mathbf b - c_2\, (\mathbf \Lambda^1)^\top \, \overline{DF^{-1}} \, \mathbb W \, \mathbf V \,, \qquad \dot{\mathbf b} = - \mathbb C \mathbf e\,, @@ -66,7 +70,7 @@ class VlasovMaxwellCoupling(Propagator): .. math:: - H = \frac{\kappa}{2}\, \mathbf e^\top \mathbb M_1 \mathbf e + \frac{c_1}{2}\, \mathbf b^\top \mathbb M_2 \mathbf b + H = \frac{\lambda^2}{2}\, \mathbf e^\top \mathbb M_1 \mathbf e + \frac{c_1}{2}\, \mathbf b^\top \mathbb M_2 \mathbf b + \frac{c_2}{2 c_3}\, \mathbf V^\top \mathbb W \mathbf V \qquad (:meth:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling.energy`)\,. @@ -75,7 +79,7 @@ class VlasovMaxwellCoupling(Propagator): .. math:: - \kappa\, \mathbb M_1 \frac{\mathbf e^{n+1} - \mathbf e^n}{\Delta t} + \lambda^2\, \mathbb M_1 \frac{\mathbf e^{n+1} - \mathbf e^n}{\Delta t} = c_1\, \mathbb C^\top \mathbb M_2 \mathbf b^\textnormal{mid} - c_2\, (\mathbf \Lambda^1)^\top \, \overline{DF^{-1}} \, \mathbb W \, \mathbf V^\textnormal{mid}\,, \qquad @@ -89,8 +93,8 @@ class VlasovMaxwellCoupling(Propagator): .. math:: - \left(\kappa\, \mathbb M_1 + \frac{\Delta t^2}{4}\, \mathbb K\right) \mathbf e^\textnormal{mid} - = \kappa\, \mathbb M_1 \mathbf e^n + \left(\lambda^2\, \mathbb M_1 + \frac{\Delta t^2}{4}\, \mathbb K\right) \mathbf e^\textnormal{mid} + = \lambda^2\, \mathbb M_1 \mathbf e^n + \frac{\Delta t}{2} \left( c_1\, \mathbb C^\top \mathbb M_2 \mathbf b^n - c_2\, \mathbf j \right)\,, \qquad \mathbb K = c_1\, \mathbb C^\top \mathbb M_2 \mathbb C + c_2 c_3\, \mathbb A\,, @@ -104,13 +108,13 @@ class VlasovMaxwellCoupling(Propagator): \mathbf b^{n+1} = \mathbf b^n - \Delta t\, \mathbb C \mathbf e^\textnormal{mid}\,, \qquad - \mathbf e^{n+1} = 2 \mathbf e^\textnormal{mid} - \mathbf e^n \quad (\kappa > 0)\,, + \mathbf e^{n+1} = 2 \mathbf e^\textnormal{mid} - \mathbf e^n \quad (\lambda > 0)\,, and the velocities are updated by :func:`~struphy.pic.pushing.kernels.push_v_with_efield.push_v_with_efield_kernels.push_v_with_efield` with :math:`\mathbf e^\textnormal{mid}`. - **Quasi-neutral limit** :math:`\kappa = 0`. The curl-curl operator :math:`\mathbb C^\top \mathbb M_2 \mathbb C` + **Quasi-neutral limit** :math:`\lambda = 0`. The curl-curl operator :math:`\mathbb C^\top \mathbb M_2 \mathbb C` is singular on the discrete gradients (and harmonic fields); this kernel is absorbed by the plasma term :math:`c_2 c_3 \mathbb A`, a particle approximation of the density-weighted mass matrix :math:`\mathbb M_1`. The Schur operator is therefore positive definite for positive weights (full-f, no control variate) and plasma @@ -137,10 +141,10 @@ class VlasovMaxwellCoupling(Propagator): **Preconditioner.** With :math:`\mathbb A \approx \bar n\, \mathbb M_1`, the Schur operator is approximately the stabilized curl-curl operator :math:`\mathbb C^\top \left(\frac{\Delta t^2 c_1}{4} \mathbb M_2\right) \mathbb C - + \left(\kappa + \frac{\Delta t^2 c_2 c_3 \bar n}{4}\right) \mathbb M_1`, preconditioned (default) by + + \left(\lambda^2 + \frac{\Delta t^2 c_2 c_3 \bar n}{4}\right) \mathbb M_1`, preconditioned (default) by :class:`~struphy.feec.preconditioner.StiffnessPreconditioner`, built for the current time step (:meth:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling.prepare_time_step`). Its - stabilization stays positive for :math:`\kappa = 0`. + stabilization stays positive for :math:`\lambda = 0`. """ class Variables: @@ -200,16 +204,18 @@ class Options(OptionsBase): Parameters ---------- - kappa : float, default=1.0 - Coefficient :math:`\kappa \geq 0` of the displacement current :math:`\partial_t \mathbf E`; - ``kappa=0.0`` is the quasi-neutral limit (needs full-f weights, no control variate). + lam : float, default=None + :math:`\lambda \geq 0`, with the coefficient :math:`\lambda^2` of the displacement current + :math:`\partial_t \mathbf E`; ``lam=0.0`` is the quasi-neutral limit (needs full-f weights, no control + variate). If ``None``, :math:`\varepsilon \lambda_\textrm{s}` of the species (``lam`` and ``epsilon`` of + its equation parameters). c1 : float, default=1.0 Coefficient :math:`c_1 > 0` of :math:`\nabla \times \mathbf B` in Ampère's law. c2 : float, default=None - Coefficient of the current density in Ampère's law. If ``None``, :math:`\alpha^2/\varepsilon` of the - species. + Coefficient of the current density in Ampère's law. If ``None``, :math:`\kappa_\textrm{s}^2 \varepsilon` of + the species (``kappa`` and ``epsilon`` of its equation parameters). c3 : float, default=None Coefficient of the electric force in the Vlasov equation (:math:`c_2 c_3 > 0`). If ``None``, @@ -245,13 +251,13 @@ class Options(OptionsBase): basis functions per marker. Needs 6 auxiliary marker columns. Ignored on the CuPy backend. initial_projection : bool, default=True - Only for ``kappa=0``: in the setup (first :meth:`prepare_time_step`), replace :math:`(\mathbf b, \mathbf V)` + Only for ``lam=0``: in the setup (first :meth:`prepare_time_step`), replace :math:`(\mathbf b, \mathbf V)` by the mid-point state of one step, which satisfies :math:`c_1 \nabla \times \mathbf B = c_2 \mathbf J` exactly (see the class docstring). Without it, initial data that violate the constraint lead to an undamped period-2 oscillation of :math:`\mathbf b` and :math:`\mathbf V`. Not applied to a restored (restart) state. """ - kappa: float = 1.0 + lam: float = None c1: float = 1.0 c2: float = None c3: float = None @@ -266,7 +272,8 @@ def __post_init__(self): # checks check_option(self.solver, LiteralOptions.OptsSymmSolver) check_option(self.precond, LiteralOptions.OptsMaxwellPrecond) - assert self.kappa >= 0.0, f"kappa must be >= 0, got {self.kappa}." + if self.lam is not None: + assert self.lam >= 0.0, f"lam must be >= 0, got {self.lam}." assert self.c1 > 0.0, f"c1 must be > 0, got {self.c1}." if self.c2 is not None and self.c3 is not None: assert self.c2 * self.c3 > 0.0, f"c2 * c3 must be > 0, got c2={self.c2}, c3={self.c3}." @@ -298,11 +305,11 @@ def options(self, new): def allocate(self): # coefficients equation_params = self.variables.ions.species.equation_params - alpha = equation_params.alpha epsilon = equation_params.epsilon - self._kappa = self.options.kappa + # lam^2 = (epsilon lam_s)^2, c2 = kappa_s^2 epsilon, c3 = 1 / epsilon (see the equation parameters) + self._lam2 = (epsilon * equation_params.lam) ** 2 if self.options.lam is None else self.options.lam**2 self._c1 = self.options.c1 - self._c2 = alpha**2 / epsilon if self.options.c2 is None else self.options.c2 + self._c2 = equation_params.kappa**2 * epsilon if self.options.c2 is None else self.options.c2 self._c3 = 1.0 / epsilon if self.options.c3 is None else self.options.c3 assert self._c2 * self._c3 > 0.0, f"c2 * c3 must be > 0, got c2={self._c2}, c3={self._c3}." @@ -310,8 +317,8 @@ def allocate(self): particles = self.variables.ions.particles if particles.control_variate: - assert self._kappa > 0.0, ( - "The quasi-neutral limit kappa=0 needs full-f weights: with the control variate the accumulated " + assert self._lam2 > 0.0, ( + "The quasi-neutral limit lam=0 needs full-f weights: with the control variate the accumulated " "matrix is indefinite and the Schur system is not well-posed." ) logger.warning( @@ -422,23 +429,23 @@ def allocate(self): ) def prepare_time_step(self, dt: float): - """Schur operator :math:`\\kappa \\mathbb M_1 + \\frac{\\Delta t^2}{4} \\mathbb K`, its preconditioner and + """Schur operator :math:`\\lambda^2 \\mathbb M_1 + \\frac{\\Delta t^2}{4} \\mathbb K`, its preconditioner and solver for the time step ``dt``: built in the setup, rebuilt when dt changes. The particle matrix enters by reference (assembled, or matrix-free reading the fillings stored in the markers), so it is updated by the - accumulation in every step. For ``kappa=0`` (and ``initial_projection``), the first call also projects the + accumulation in every step. For ``lam=0`` (and ``initial_projection``), the first call also projects the state onto the constraint :math:`c_1 \\mathbb C^\\top \\mathbb M_2 \\mathbf b = c_2 \\mathbf j`.""" if dt == self._schur_dt: return schur = (dt**2 * self._c1 / 4) * self._curl_curl + (dt**2 * self._c2 * self._c3 / 4) * self._A_particles - if self._kappa > 0.0: - schur = self._kappa * self._M1 + schur + if self._lam2 > 0.0: + schur = self._lam2 * self._M1 + schur if self._precond == "StiffnessPreconditioner": pc = preconditioner.StiffnessPreconditioner( self.mass_ops, "curl", - sigma=self._kappa + dt**2 * self._c2 * self._c3 * self._n_ref / 4, + sigma=self._lam2 + dt**2 * self._c2 * self._c3 * self._n_ref / 4, mid=(dt**2 * self._c1 / 4) * self._M2, stab="M1", ) @@ -451,15 +458,16 @@ def prepare_time_step(self, dt: float): self._solver = inverse(schur, self.options.solver, **kwargs) self._schur_dt = dt - # kappa = 0: consistent initial state, once (in the setup, before the initial data are saved) - if self._kappa == 0.0 and self.options.initial_projection and not self._projected: + # lam = 0: consistent initial state, once (in the setup, before the initial data are saved) + if self._lam2 == 0.0 and self.options.initial_projection and not self._projected: self._project_onto_constraint(dt) self._projected = True - def energy(self) -> float: - r"""The energy :math:`H = \frac{\kappa}{2} \mathbf e^\top \mathbb M_1 \mathbf e + \frac{c_1}{2} \mathbf b^\top - \mathbb M_2 \mathbf b + \frac{c_2}{2 c_3} \mathbf V^\top \mathbb W \mathbf V` conserved by the propagator - (summed over all MPI ranks; a collective operation).""" + def energies(self) -> tuple[float, float, float]: + r"""The three parts :math:`\frac{\lambda^2}{2} \mathbf e^\top \mathbb M_1 \mathbf e`, + :math:`\frac{c_1}{2} \mathbf b^\top \mathbb M_2 \mathbf b` and + :math:`\frac{c_2}{2 c_3} \mathbf V^\top \mathbb W \mathbf V` of the energy :meth:`energy` (summed over all + MPI ranks; a collective operation).""" e = self.variables.e.spline.vector b = self.variables.b.spline.vector particles = self.variables.ions.particles @@ -470,10 +478,16 @@ def energy(self) -> float: if particles.clone_config is not None: particles.clone_config.inter_comm.Allreduce(MPI.IN_PLACE, kinetic, op=MPI.SUM) - energy = self._c1 / 2 * b.inner(self._M2.dot(b, out=self._b_tmp)) + self._c2 / self._c3 * float(kinetic[0]) - if self._kappa > 0.0: - energy += self._kappa / 2 * e.inner(self._M1.dot(e, out=self._e_tmp)) - return energy + energy_e = self._lam2 / 2 * e.inner(self._M1.dot(e, out=self._e_tmp)) if self._lam2 > 0.0 else 0.0 + energy_b = self._c1 / 2 * b.inner(self._M2.dot(b, out=self._b_tmp)) + energy_f = self._c2 / self._c3 * float(kinetic[0]) + return energy_e, energy_b, energy_f + + def energy(self) -> float: + r"""The energy :math:`H = \frac{\lambda^2}{2} \mathbf e^\top \mathbb M_1 \mathbf e + \frac{c_1}{2} + \mathbf b^\top \mathbb M_2 \mathbf b + \frac{c_2}{2 c_3} \mathbf V^\top \mathbb W \mathbf V` conserved by the + propagator (summed over all MPI ranks; a collective operation).""" + return sum(self.energies()) def _mid_point_field(self, dt: float): """Accumulate at the current markers and solve the Schur system for the mid-point field (into ``_e_mid``, @@ -484,7 +498,7 @@ def _mid_point_field(self, dt: float): # accumulate A (or its fillings per marker) and j at the current marker positions and velocities self._accum(*self._args_accum) - # rhs = kappa M1 e^n + dt/2 (c1 C^T M2 b^n - c2 j) + # rhs = lam^2 M1 e^n + dt/2 (c1 C^T M2 b^n - c2 j) rhs = self._curl.T.dot(self._M2.dot(bn, out=self._b_tmp), out=self._e_rhs) rhs *= self._c1 j = self._accum.vectors[0] @@ -492,9 +506,9 @@ def _mid_point_field(self, dt: float): self._e_tmp *= self._c2 rhs -= self._e_tmp rhs *= dt / 2 - if self._kappa > 0.0: + if self._lam2 > 0.0: self._M1.dot(en, out=self._e_tmp) - self._e_tmp *= self._kappa + self._e_tmp *= self._lam2 rhs += self._e_tmp e_mid = self._solver.dot(rhs, out=self._e_mid) @@ -502,9 +516,9 @@ def _mid_point_field(self, dt: float): return e_mid, self._solver._info def _project_onto_constraint(self, dt: float): - """For kappa = 0: replace (b, V) by the mid-point state of one step, which satisfies + """For lam = 0: replace (b, V) by the mid-point state of one step, which satisfies c1 C^T M2 b = c2 j(V) exactly (independent of dt). The electric field is kept (e.g. from the initial Poisson - solve): e_mid is an impulse of size 1/dt here, and e does not enter the steps for kappa = 0.""" + solve): e_mid is an impulse of size 1/dt here, and e does not enter the steps for lam = 0.""" bn = self.variables.b.spline.vector e_mid, _ = self._mid_point_field(dt) @@ -514,7 +528,7 @@ def _project_onto_constraint(self, dt: float): b_new += bn self._pusher(dt / 2) self.update_feec_variables(b=b_new) - logger.info("VlasovMaxwellCoupling: projected the initial state onto c1 curl B = c2 J (kappa = 0).") + logger.info("VlasovMaxwellCoupling: projected the initial state onto c1 curl B = c2 J (lam = 0).") @profile def __call__(self, dt): @@ -532,9 +546,9 @@ def __call__(self, dt): b_new *= -dt b_new += bn - # new e coeffs: Crank-Nicolson for kappa > 0, the mid-point field for kappa = 0 + # new e coeffs: Crank-Nicolson for lam > 0, the mid-point field for lam = 0 e_new = e_mid.copy(out=self._e_new) - if self._kappa > 0.0: + if self._lam2 > 0.0: e_new *= 2.0 e_new -= en From 9e0d96c444190dd86e838a6207ef325bf82af3f4 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 14:47:47 +0200 Subject: [PATCH 21/23] Review nits: coefficients for an overridden epsilon, alpha not used, sign of lam, AGENTS.md Co-Authored-By: Claude Opus 5.5 --- src/struphy/models/AGENTS.md | 2 +- src/struphy/models/vlasov_maxwell_quasi_neutral.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/struphy/models/AGENTS.md b/src/struphy/models/AGENTS.md index 4eac2f7b5..3ca957989 100644 --- a/src/struphy/models/AGENTS.md +++ b/src/struphy/models/AGENTS.md @@ -23,7 +23,7 @@ Read this before creating or renaming a model in `src/struphy/models/`. The gene 0. `self.params = copy.deepcopy(locals())`. All constructor arguments need defaults, and only bool/int/float/str/tuple/list/None/`BaseUnits` values survive serialization. 1. instantiate the species as attributes; - 2. `self.setup_equation_params(base_units=base_units)` (derives units and `alpha`, `epsilon`, `kappa`); + 2. `self.setup_equation_params(base_units=base_units)` (derives units and `alpha`, `epsilon`, `lam`, `kappa`); 3. `self.propagators = self.Propagators()`; 4. link variables: `self.propagators..variables. = self..`; 5. `self.scalars = Scalars(...)` with the tracked quantities (`scalars.py`, e.g. `BilinearEnergyFEEC`, diff --git a/src/struphy/models/vlasov_maxwell_quasi_neutral.py b/src/struphy/models/vlasov_maxwell_quasi_neutral.py index 42ffaa8ce..c9562fae6 100644 --- a/src/struphy/models/vlasov_maxwell_quasi_neutral.py +++ b/src/struphy/models/vlasov_maxwell_quasi_neutral.py @@ -37,6 +37,8 @@ class VlasovMaxwellQuasiNeutral(VlasovMaxwellOneSpecies): Mass number (in units of Proton mass) of the species (default: 1.0) alpha: float, optional Dimensionless parameter: plasma frequency / cyclotron frequency. If None, computed from units and charge/mass numbers. + It does not enter the equations of this model (which use lam and kappa = alpha lam); when overriding lam and + kappa, alpha is not adapted. epsilon: float, optional Normalized cyclotron period: 1 / (cyclotron frequency × time unit). If None, computed from units and charge/mass numbers (equal to 1 for this model's cyclotron velocity scale; set -1 for electrons with the default charge @@ -213,7 +215,10 @@ def doc_pde(cls): \frac{\partial \mathbf{B}}{\partial t} + \nabla \times \mathbf{E} = 0 with :math:`\varepsilon = 1` in the cyclotron normalization (set :math:`\varepsilon = -1` for electrons with the - default charge number; then :math:`\kappa^2/\varepsilon` carries the sign of the charge). For + default charge number; then :math:`\kappa^2/\varepsilon` carries the sign of the charge). These forms assume + :math:`\varepsilon = \pm 1`; for an overridden :math:`\varepsilon` the coefficients of the displacement and + the plasma current are :math:`\varepsilon^2 \lambda^2` and :math:`\kappa^2 \varepsilon` (see + :ref:`equation_params`). For :math:`\lambda = 0` (quasi-neutral limit) Ampère's law becomes :math:`\nabla \times \mathbf B = \frac{\kappa^2}{\varepsilon} \int \mathbf v f \, \textrm d^3 \mathbf v`, which implies :math:`\nabla \cdot \mathbf J = 0`; the electric field is then determined by this constraint, not by its own evolution. The coefficients of the coupling terms can be changed by the options @@ -250,7 +255,8 @@ def doc_normalization(cls): \lambda = \frac{\hat\Omega_c \hat x}{c}\,,\qquad \kappa = \frac{\hat\Omega_p \hat x}{c} = \alpha \lambda\,, \qquad \varepsilon = \frac{1}{\hat\Omega_c \hat t} = 1\,, - where :math:`c/\hat\Omega_c` and :math:`c/\hat\Omega_p` are the inertial lengths. The Vlasov equation and + where :math:`c/\hat\Omega_c` and :math:`c/\hat\Omega_p` are the inertial lengths (:math:`\lambda` carries the + sign of the charge, like :math:`\hat\Omega_c`; only :math:`\lambda^2` enters). The Vlasov equation and Faraday's law then have no coefficients, and Ampère's law :math:`\frac{1}{c^2} \partial_t \mathbf E = \nabla \times \mathbf B - \mu_0 \mathbf J` becomes :math:`\lambda^2 \partial_t \mathbf E = \nabla \times \mathbf B - \kappa^2 \mathbf J`. The quasi-neutral limit is From 2ce88675dc3204894ee9bfef5f51cb616ef34f33 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 15:27:37 +0200 Subject: [PATCH 22/23] Docs: no general epsilon in VlasovMaxwellQuasiNeutral, drop the Ampere paragraph under Equation parameters, fix the energy reference in VlasovMaxwellCoupling The :meth: reference to VlasovMaxwellCoupling.energy was inside a math block (not rendered); moved into the text. Co-Authored-By: Claude Opus 5.5 --- .../subsections/models-normalization.rst | 16 ---------- .../models/vlasov_maxwell_quasi_neutral.py | 31 +++++++------------ .../propagators/vlasov_maxwell_coupling.py | 5 ++- 3 files changed, 13 insertions(+), 39 deletions(-) diff --git a/doc/sections/subsections/models-normalization.rst b/doc/sections/subsections/models-normalization.rst index f8926413f..a204334bf 100644 --- a/doc/sections/subsections/models-normalization.rst +++ b/doc/sections/subsections/models-normalization.rst @@ -119,22 +119,6 @@ featuring the plasma- and cyclotron frequency of species :math:`\textrm{s}`, res where :math:`Z_\textrm{s}` and :math:`A_\textrm{s}` stand for the species' charge and mass number, respectively. -The parameters :math:`\lambda_\textrm{s}` and :math:`\kappa_\textrm{s}` (``lam`` and ``kappa`` in the code) measure the -length unit in units of the inertial lengths :math:`c/\hat \Omega_\textnormal{cs}` and :math:`c/\hat \Omega_\textnormal{ps}`. -With the ``cyclotron`` velocity scale of species :math:`\textrm{s}` (hence :math:`\varepsilon_\textrm{s} = 1`), Ampère's law -:math:`\frac{1}{c^2} \partial_t \mathbf E = \nabla \times \mathbf B - \mu_0 \mathbf J` becomes - -.. math:: - - \lambda_\textrm{s}^2 \frac{\partial \mathbf E'}{\partial t'} = \nabla' \times \mathbf B' - \kappa_\textrm{s}^2 \mathbf J'\,, - -with :math:`\hat E = \hat B \hat v` and :math:`\hat \jmath = q_\textrm{s} \hat n \hat v`, while the Vlasov equation and -Faraday's law have no coefficients. :math:`\lambda_\textrm{s} \to 0` is the limit without displacement current -(quasi-neutral limit). For a general velocity scale the coefficients are :math:`\varepsilon_\textrm{s}^2 \lambda_\textrm{s}^2` -and :math:`\kappa_\textrm{s}^2 \varepsilon_\textrm{s}`, and :math:`1/\varepsilon_\textrm{s}` in the Vlasov equation; for the -``light`` velocity scale :math:`\varepsilon_\textrm{s} \lambda_\textrm{s} = 1` and -:math:`\kappa_\textrm{s}^2 \varepsilon_\textrm{s} = \alpha_\textrm{s}^2 / \varepsilon_\textrm{s}`. - These equation parameters are defined in :class:`~struphy.models.species.Species.EquationParameters` and can be overridden in the model constructor. diff --git a/src/struphy/models/vlasov_maxwell_quasi_neutral.py b/src/struphy/models/vlasov_maxwell_quasi_neutral.py index c9562fae6..95b0a0341 100644 --- a/src/struphy/models/vlasov_maxwell_quasi_neutral.py +++ b/src/struphy/models/vlasov_maxwell_quasi_neutral.py @@ -41,8 +41,7 @@ class VlasovMaxwellQuasiNeutral(VlasovMaxwellOneSpecies): kappa, alpha is not adapted. epsilon: float, optional Normalized cyclotron period: 1 / (cyclotron frequency × time unit). If None, computed from units and charge/mass - numbers (equal to 1 for this model's cyclotron velocity scale; set -1 for electrons with the default charge - number). + numbers (equal to 1 in this model's normalization). lam: float, optional Cyclotron frequency × length unit / speed of light (coefficient lam^2 of the displacement current; 0 is the quasi-neutral limit). If None, computed from units and charge/mass numbers. @@ -200,13 +199,13 @@ def doc_pde(cls): .. math:: - \frac{\partial f}{\partial t} + \mathbf{v} \cdot \nabla f + \frac{1}{\varepsilon} \left( \mathbf{E} + \mathbf{v} \times \left( \mathbf{B} + \mathbf{B}_0 \right) \right) \cdot \frac{\partial f}{\partial \mathbf{v}} = 0 + \frac{\partial f}{\partial t} + \mathbf{v} \cdot \nabla f + \left( \mathbf{E} + \mathbf{v} \times \left( \mathbf{B} + \mathbf{B}_0 \right) \right) \cdot \frac{\partial f}{\partial \mathbf{v}} = 0 Ampère's law: .. math:: - -\lambda^2 \frac{\partial \mathbf{E}}{\partial t} + \nabla \times \mathbf{B} = \frac{\kappa^2}{\varepsilon} \int_{\mathbb{R}^3} \mathbf{v} f \, \text{d}^3 \mathbf{v} + -\lambda^2 \frac{\partial \mathbf{E}}{\partial t} + \nabla \times \mathbf{B} = \kappa^2 \int_{\mathbb{R}^3} \mathbf{v} f \, \text{d}^3 \mathbf{v} Faraday's law: @@ -214,29 +213,23 @@ def doc_pde(cls): \frac{\partial \mathbf{B}}{\partial t} + \nabla \times \mathbf{E} = 0 - with :math:`\varepsilon = 1` in the cyclotron normalization (set :math:`\varepsilon = -1` for electrons with the - default charge number; then :math:`\kappa^2/\varepsilon` carries the sign of the charge). These forms assume - :math:`\varepsilon = \pm 1`; for an overridden :math:`\varepsilon` the coefficients of the displacement and - the plasma current are :math:`\varepsilon^2 \lambda^2` and :math:`\kappa^2 \varepsilon` (see - :ref:`equation_params`). For - :math:`\lambda = 0` (quasi-neutral limit) Ampère's law becomes :math:`\nabla \times \mathbf B = - \frac{\kappa^2}{\varepsilon} \int \mathbf v f \, \textrm d^3 \mathbf v`, which implies :math:`\nabla \cdot \mathbf J = 0`; the electric field is then determined by this + For :math:`\lambda = 0` (quasi-neutral limit) Ampère's law becomes :math:`\nabla \times \mathbf B = + \kappa^2 \int \mathbf v f \, \textrm d^3 \mathbf v`, which implies :math:`\nabla \cdot \mathbf J = 0`; the electric field is then determined by this constraint, not by its own evolution. The coefficients of the coupling terms can be changed by the options ``lam``, ``c1``, ``c2``, ``c3`` of the propagator :class:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling`. At initial time the weak Poisson equation (Gauss' law :math:`\lambda^2 \nabla \cdot \mathbf E = \kappa^2 \rho`) - (with the charge density :math:`\rho = \frac{1}{\varepsilon} \int (f - f_0) \, \textrm d^3 \mathbf v`) is - solved once for :math:`\lambda > 0`, + is solved once for :math:`\lambda > 0`, .. math:: - \lambda^2 \int_{\Omega} \nabla \psi^{\top} \cdot \nabla \phi \, \textrm{d} \mathbf{x} = \frac{\kappa^2}{\varepsilon} \int_{\Omega} \int_{\mathbb{R}^3} \psi \, (f - f_0) \, \text{d}^3 \mathbf{v} \, \textrm{d} \mathbf{x} \qquad \forall \ \psi \in H^1 + \lambda^2 \int_{\Omega} \nabla \psi^{\top} \cdot \nabla \phi \, \textrm{d} \mathbf{x} = \kappa^2 \int_{\Omega} \int_{\mathbb{R}^3} \psi \, (f - f_0) \, \text{d}^3 \mathbf{v} \, \textrm{d} \mathbf{x} \qquad \forall \ \psi \in H^1 \\[2mm] \mathbf{E}(t=0) = -\nabla \phi(t=0) (for :math:`\lambda = 0` the initial electric field does not enter the time stepping). Moreover, it is assumed - that :math:`\nabla \times \mathbf{B}_0 = \frac{\kappa^2}{\varepsilon} \int_{\mathbb{R}^3} \mathbf{v} f_0 \, \text{d}^3 \mathbf{v}`, + that :math:`\nabla \times \mathbf{B}_0 = \kappa^2 \int_{\mathbb{R}^3} \mathbf{v} f_0 \, \text{d}^3 \mathbf{v}`, where :math:`\mathbf{B}_0` is the static equilibrium magnetic field. """ @@ -253,15 +246,13 @@ def doc_normalization(cls): .. math:: \lambda = \frac{\hat\Omega_c \hat x}{c}\,,\qquad \kappa = \frac{\hat\Omega_p \hat x}{c} = \alpha \lambda\,, - \qquad \varepsilon = \frac{1}{\hat\Omega_c \hat t} = 1\,, where :math:`c/\hat\Omega_c` and :math:`c/\hat\Omega_p` are the inertial lengths (:math:`\lambda` carries the sign of the charge, like :math:`\hat\Omega_c`; only :math:`\lambda^2` enters). The Vlasov equation and Faraday's law then have no coefficients, and Ampère's law :math:`\frac{1}{c^2} \partial_t \mathbf E = \nabla \times \mathbf B - \mu_0 \mathbf J` becomes :math:`\lambda^2 \partial_t \mathbf E = \nabla \times \mathbf B - \kappa^2 \mathbf J`. The quasi-neutral limit is - :math:`\lambda \to 0` at fixed :math:`\kappa`. With :math:`\lambda = \kappa = 1` (and :math:`\varepsilon = -1` for - electrons) the equations are those of :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies` + :math:`\lambda \to 0` at fixed :math:`\kappa`. With :math:`\lambda = \kappa = 1` the equations are those of :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies` with :math:`\alpha = 1`, i.e. lengths in units of :math:`c/\hat\Omega_p` and :math:`\hat\Omega_c = \hat\Omega_p`.""" @classmethod @@ -309,13 +300,13 @@ def doc_long_description(cls): @classmethod def doc_examples(cls): - r"""Create a quasi-neutral Vlasov-Maxwell model (electrons, lengths in units of the electron skin depth): + r"""Create a quasi-neutral Vlasov-Maxwell model (lengths in units of the skin depth): .. code-block:: python from struphy.models import VlasovMaxwellQuasiNeutral - model = VlasovMaxwellQuasiNeutral(epsilon=-1.0, lam=0.0, kappa=1.0) + model = VlasovMaxwellQuasiNeutral(lam=0.0, kappa=1.0) model.em_fields.b_field model.kinetic_ions.var """ diff --git a/src/struphy/propagators/vlasov_maxwell_coupling.py b/src/struphy/propagators/vlasov_maxwell_coupling.py index 5c693ebb4..18234cd8c 100644 --- a/src/struphy/propagators/vlasov_maxwell_coupling.py +++ b/src/struphy/propagators/vlasov_maxwell_coupling.py @@ -66,13 +66,12 @@ class VlasovMaxwellCoupling(Propagator): \dot{\mathbf V} = c_3\, \overline{DF^{-\top}} \, \mathbf \Lambda^1 \, \mathbf e\,, with the notation of :class:`~struphy.propagators.vlasov_ampere_coupling.VlasovAmpereCoupling`. It conserves the - energy + energy (:meth:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling.energy`) .. math:: H = \frac{\lambda^2}{2}\, \mathbf e^\top \mathbb M_1 \mathbf e + \frac{c_1}{2}\, \mathbf b^\top \mathbb M_2 \mathbf b - + \frac{c_2}{2 c_3}\, \mathbf V^\top \mathbb W \mathbf V - \qquad (:meth:`~struphy.propagators.vlasov_maxwell_coupling.VlasovMaxwellCoupling.energy`)\,. + + \frac{c_2}{2 c_3}\, \mathbf V^\top \mathbb W \mathbf V\,. :ref:`time_discret`: Crank-Nicolson (implicit mid-point), with :math:`\mathbf e^\textnormal{mid} = \frac 12 (\mathbf e^{n+1} + \mathbf e^n)` etc., From 96b0f08f699e5fc031c3447f62ed32af83ccfaa5 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 23 Oct 2026 15:55:07 +0200 Subject: [PATCH 23/23] DriftKineticMaxwell: thermal normalization with the equation parameters lam and kappa; safeguard the parallel force - Model DriftKineticMaxwell: velocity scale "thermal" (as DriftKineticElectrostaticAdiabatic), default BaseUnits(kBT=1.0) (also in the generated default parameter file), constructor overrides epsilon, lam, kappa; Ampere's law eps^2 lam^2 dE/dt + eps^2 kappa^2 n0/|B0|^2 dE_perp/dt = curl B - eps kappa^2 J_gc; doc_pde with doc_normalization below it (thermal scale only); scalars en_E, en_B, en_f from the parts of the conserved energy. - DriftKineticMaxwellCoupling: option kappa (displacement current) renamed to lam (coefficient lam^2), defaults lam = eps lam_s, chi = (eps kappa_s)^2, c2 = kappa_s^2 eps, c3 = 1/eps (#821 equation parameters); energies(); the internal E x B scaling of the assembled matrix renamed to skew (also in the dk_maxwell kernel). - The parallel electric force b* . E / eps is applied only by the coupling step: the model asserts that push_para gets no potential and no electric field, doc_discretization lists which propagator applies each term, and a test checks that push_para leaves v_par unchanged for E != 0 (and changes it when given E). - Tests, verification test and example in the new parameters (eps = 0.5, lam = 2, kappa = 6 give the same coefficients as before; same frequencies). Raw docstrings for the pusher constructors (LaTeX). Co-Authored-By: Claude Opus 5.5 --- .../params_inertial_alfven_wave.py | 74 ++++--- .../pproc_inertial_alfven_wave.py | 18 +- src/struphy/models/drift_kinetic_maxwell.py | 197 ++++++++++++------ .../test_verif_DriftKineticMaxwell.py | 37 ++-- .../kernels/dk_maxwell/dk_maxwell_kernels.py | 12 +- .../drift_kinetic_maxwell_coupling.py | 120 ++++++----- .../push_guiding_center_bx_estar.py | 2 +- .../push_guiding_center_parallel.py | 2 +- .../test_drift_kinetic_maxwell_coupling.py | 114 ++++++++-- 9 files changed, 370 insertions(+), 206 deletions(-) diff --git a/examples/DriftKineticMaxwell/inertial_alfven_wave/params_inertial_alfven_wave.py b/examples/DriftKineticMaxwell/inertial_alfven_wave/params_inertial_alfven_wave.py index 11911b954..7d67ebc8a 100644 --- a/examples/DriftKineticMaxwell/inertial_alfven_wave/params_inertial_alfven_wave.py +++ b/examples/DriftKineticMaxwell/inertial_alfven_wave/params_inertial_alfven_wave.py @@ -1,5 +1,5 @@ -"""Inertial Alfvén wave with the DriftKineticMaxwell model, with and without displacement current (`--kappa 1` or -`--kappa 0`) and with or without the E x B current (`--exb-current`).""" +"""Inertial Alfvén wave with the DriftKineticMaxwell model, with and without displacement current (`--lam 1` or +`--lam 0`) and with or without the E x B current (`--exb-current`).""" import argparse from pathlib import Path @@ -12,9 +12,12 @@ default=str(Path(__file__).resolve().parent), help="Folder in which the output folder is created (default: beside this file).", ) -parser.add_argument("--sim-folder", default=None, help="Name of the output folder (default: sim_kappa[_exb]).") +parser.add_argument("--sim-folder", default=None, help="Name of the output folder (default: sim_lam[_exb]).") parser.add_argument( - "--kappa", type=float, default=0.0, help="Coefficient of the displacement current (1: Maxwell, 0: quasi-neutral)." + "--lam", + type=float, + default=0.0, + help="lam = v_th / c, coefficient lam^2 of the displacement current (0: quasi-neutral).", ) parser.add_argument( "--exb-current", action="store_true", help="Include the E x B current of the guiding centers in Ampere's law." @@ -28,7 +31,7 @@ ) args, _ = parser.parse_known_args() if args.sim_folder is None: - args.sim_folder = f"sim_kappa{args.kappa:g}" + ("_exb" if args.exb_current else "") + args.sim_folder = f"sim_lam{args.lam:g}" + ("_exb" if args.exb_current else "") if args.test_run: args.tend = 2 * args.dt @@ -37,34 +40,37 @@ description = f""" Shear (inertial) Alfven wave in a cold guiding-center plasma with the DriftKineticMaxwell model: drift-kinetic particles (PIC, Particles5D) coupled to Maxwell's equations through the implicit, energy conserving -DriftKineticMaxwellCoupling step, in a periodic slab with the uniform field B0 = e_z. Here -kappa = {args.kappa:g}, E x B current {"on" if args.exb_current else "off"}. +DriftKineticMaxwellCoupling step, in a periodic slab with the uniform field B0 = e_z. Here lam = {args.lam:g} +(coefficient lam^2 of the displacement current), E x B current {"on" if args.exb_current else "off"}. -Physics. In the units of the model (velocities in units of c, alpha = Omega_p / Omega_c, epsilon = 1 / (Omega_c t)) +Physics. The model is normalized with the thermal velocity v_th of the species (t = x / v_th, E in units of +v_th B), with the species parameters epsilon = 1 / (Omega_c t), lam_s = Omega_c x / c and kappa_s = Omega_p x / c. Ampere's law reads - kappa dE/dt + chi n0 / |B0|^2 dE_perp/dt = curl B - (alpha^2 / epsilon) J_gc, -with the guiding-center current J_gc and the polarization coefficient chi = alpha^2 (c^2 / v_A^2 of the species). -The drift-kinetic ordering removes the polarization drift from the guiding-center motion; the chi term adds it back. -Without it, the quasi-neutral limit kappa = 0 would be singular for flute modes, and for kappa = 1 the shear Alfven -wave would travel at the speed of light. For a cold plasma and k = (kx, 0, kz), the shear mode (E_x, E_z, B_y) -obeys, without the E x B current and for kappa = 0, - omega^2 = (c1 / chi) kz^2 / (1 + kx^2 d^2), d^2 = c1 / P, P = c2 c3 n0 = alpha^2 / epsilon^2, -the inertial Alfven wave with v_A^2 = 1 / chi and the inertial length d. For kappa > 0, and with the E x B current -(which couples the shear and the fast wave through the Hall term of the single species), the frequencies are the -roots of the determinant of the 3x3 cold-plasma dispersion matrix (computed in the pproc script). - -Setup. alpha = 3, epsilon = 0.5 (chi = 9, v_A = 1/3, d = 1/6); box 1 x 1 x 4 with 8 x 1 x 8 cells and cubic -splines in x and z, kx = 2 pi, kz = pi / 2 (kx^2 d^2 = 1.1); cold guiding centers (vth = 1e-6, full-f weights, -50 markers per cell, seed 1234), initial B_y = 1e-2 sin(kx x + kz z) (2-form component). Time step -dt = {args.dt:g} (omega dt = 0.18) up to t = {args.tend:g}, Lie-Trotter splitting of the coupling step with the -parallel and the E* x b0 guiding-center pushers. For kappa = 0 the coupling step first projects the initial state -onto the constraint of the quasi-neutral limit (option initial_projection, default). - -Expected result. Analytical frequencies of the shear mode: omega = 0.3616 (kappa = 0), 0.2643 (kappa = 0 with the -E x B current) and 0.3428 (kappa = 1; the B_y perturbation then also excites the upper branch, omega = 8.70, with a + lam^2 dE/dt + chi n0 / |B0|^2 dE_perp/dt = curl B - c2 J_gc, +with the guiding-center current J_gc, lam = epsilon lam_s = v_th / c (here an option, 0 or 1), the polarization +coefficient chi = (epsilon kappa_s)^2 = v_th^2 / v_A^2 and c2 = epsilon kappa_s^2; the parallel force has +c3 = 1 / epsilon. The drift-kinetic ordering removes the polarization drift from the guiding-center motion; the chi +term adds it back. Without it, the quasi-neutral limit lam = 0 would be singular for flute modes. For a cold plasma +and k = (kx, 0, kz), the shear mode (E_x, E_z, B_y) obeys, without the E x B current and for lam = 0, + omega^2 = (1 / chi) kz^2 / (1 + kx^2 d^2), d^2 = 1 / P, P = c2 c3 n0 = kappa_s^2, +the inertial Alfven wave with v_A^2 = 1 / chi and the inertial length d = 1 / kappa_s. For lam > 0, and with the +E x B current (which couples the shear and the fast wave through the Hall term of the single species), the +frequencies are the roots of the determinant of the 3x3 cold-plasma dispersion matrix (computed in the pproc +script). + +Setup. epsilon = 0.5, lam_s = 2, kappa_s = 6 (chi = 9, v_A = 1/3, d = 1/6, c2 = 18, c3 = 2; lam = 1 would mean +v_th = c and only tests the displacement-current branch); box 1 x 1 x 4 with 8 x 1 x 8 cells and cubic splines in +x and z, kx = 2 pi, kz = pi / 2 (kx^2 d^2 = 1.1); cold guiding centers (vth = 1e-6, full-f weights, 50 markers per +cell, seed 1234), initial B_y = 1e-2 sin(kx x + kz z) (2-form component). Time step dt = {args.dt:g} +(omega dt = 0.18) up to t = {args.tend:g}, Lie-Trotter splitting of the coupling step with the parallel and the +E* x b0 guiding-center pushers. For lam = 0 the coupling step first projects the initial state onto the constraint +of the quasi-neutral limit (option initial_projection, default). + +Expected result. Analytical frequencies of the shear mode: omega = 0.3616 (lam = 0), 0.2643 (lam = 0 with the +E x B current) and 0.3428 (lam = 1; the B_y perturbation then also excites the upper branch, omega = 8.70, with a small amplitude). The pproc fits omega to the magnetic energy en_B ~ cos^2(omega t) and compares. Measured with -this example (serial, 2026-10-23): -1.0 % for kappa = 0; with the verification test of the same setup -0.25 % with -the E x B current and -0.7 % for kappa = 1 (dt = 0.1). The deviation decreases with the number of markers (-0.35 % +this example (serial, 2026-10-23): -1.0 % for lam = 0; with the verification test of the same setup -0.25 % with +the E x B current and -0.7 % for lam = 1 (dt = 0.1). The deviation decreases with the number of markers (-0.35 % with 200 markers per cell). The total energy en_tot is conserved by the whole splitting up to the solver tolerance (cold plasma, uniform B0): relative change 5e-6 with the default tolerance 1e-8, 1e-9 with 1e-12. @@ -104,11 +110,11 @@ from struphy.models import DriftKineticMaxwell -ALPHA, EPSILON = 3.0, 0.5 +EPSILON, LAM, KAPPA = 0.5, 2.0, 6.0 LX, LZ = 1.0, 4.0 VTH = 1e-6 -model = DriftKineticMaxwell(base_units=BaseUnits(), alpha=ALPHA, epsilon=EPSILON) +model = DriftKineticMaxwell(base_units=BaseUnits(kBT=1.0), epsilon=EPSILON, lam=LAM, kappa=KAPPA) model.em_fields.e_field.save_data = True model.em_fields.b_field.save_data = True @@ -159,10 +165,10 @@ prop = model.propagators.coupling if args.exb_current: - prop.options = prop.Options(kappa=args.kappa) + prop.options = prop.Options(lam=args.lam) else: # without the E x B current the Schur system is symmetric - prop.options = prop.Options(kappa=args.kappa, exb_current=False, solver="pcg") + prop.options = prop.Options(lam=args.lam, exb_current=False, solver="pcg") model.propagators.push_para.options = model.propagators.push_para.Options() # with the electric field (not a potential) only the explicit E* x b0 push is consistent model.propagators.push_bxe.options = model.propagators.push_bxe.Options(algo="explicit") diff --git a/examples/DriftKineticMaxwell/inertial_alfven_wave/pproc_inertial_alfven_wave.py b/examples/DriftKineticMaxwell/inertial_alfven_wave/pproc_inertial_alfven_wave.py index 61e715f3a..3e8e0d579 100644 --- a/examples/DriftKineticMaxwell/inertial_alfven_wave/pproc_inertial_alfven_wave.py +++ b/examples/DriftKineticMaxwell/inertial_alfven_wave/pproc_inertial_alfven_wave.py @@ -1,6 +1,6 @@ """Post-process the inertial Alfvén wave example: wave frequency from the magnetic energy and energy conservation. -Run as ``python pproc_inertial_alfven_wave.py [--kappa K] [--exb-current]`` (same flags as the params file) or with +Run as ``python pproc_inertial_alfven_wave.py [--lam L] [--exb-current]`` (same flags as the params file) or with the path of the output folder. Writes ``energies.png`` into ``/results`` and prints the fitted frequency. """ @@ -16,23 +16,23 @@ SCRIPT_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(SCRIPT_DIR)) -from params_inertial_alfven_wave import ALPHA, EPSILON, LX, LZ, args # noqa: E402 +from params_inertial_alfven_wave import EPSILON, KAPPA, LX, LZ, args # noqa: E402 DEFAULT_OUTPUT = Path(args.out_folder) / args.sim_folder -def dispersion_roots(kappa, exb_current): +def dispersion_roots(lam, exb_current): """Positive real roots omega of the determinant of the cold-slab dispersion matrix - omega^2 (kappa E + chi E_perp) - c1 (k^2 E - k k.E) - P E_z e_z + i omega H E x e_z + omega^2 (lam^2 E + chi E_perp) - c1 (k^2 E - k k.E) - P E_z e_z + i omega H E x e_z (polynomial of degree 6 in omega, interpolated exactly from 7 values).""" - c1, c2, c3 = 1.0, ALPHA**2 / EPSILON, 1.0 / EPSILON - chi, P, H = ALPHA**2, c2 * c3, c2 if exb_current else 0.0 + c1, c2, c3 = 1.0, KAPPA**2 * EPSILON, 1.0 / EPSILON + chi, P, H = (EPSILON * KAPPA) ** 2, c2 * c3, c2 if exb_current else 0.0 k = np.array([2 * np.pi / LX, 0.0, 2 * np.pi / LZ]) cross_z = np.array([[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) def det(w): mat = ( - w**2 * (kappa * np.eye(3) + chi * np.diag([1.0, 1.0, 0.0])) + w**2 * (lam**2 * np.eye(3) + chi * np.diag([1.0, 1.0, 0.0])) - c1 * (k @ k * np.eye(3) - np.outer(k, k)) - P * np.diag([0.0, 0.0, 1.0]) + 1j * w * H * cross_z @@ -54,8 +54,8 @@ def main(path_out=DEFAULT_OUTPUT): results_dir = Path(path_out) / "results" results_dir.mkdir(exist_ok=True) - omega_exact = dispersion_roots(args.kappa, args.exb_current)[0] - # en_B ~ cos^2(omega t) = (1 + cos(2 omega t)) / 2 (plus the upper branch for kappa > 0, filtered by the fit) + omega_exact = dispersion_roots(args.lam, args.exb_current)[0] + # en_B ~ cos^2(omega t) = (1 + cos(2 omega t)) / 2 (plus the upper branch for lam > 0, filtered by the fit) omega = None if len(t) > 10: popt, _ = curve_fit( diff --git a/src/struphy/models/drift_kinetic_maxwell.py b/src/struphy/models/drift_kinetic_maxwell.py index 93371ed15..0da63428d 100644 --- a/src/struphy/models/drift_kinetic_maxwell.py +++ b/src/struphy/models/drift_kinetic_maxwell.py @@ -4,7 +4,7 @@ from struphy import BaseUnits from struphy.io.options import LiteralOptions from struphy.models.base import StruphyModel -from struphy.models.scalars import BilinearEnergyFEEC, FunctionScalarFEEC, KineticEnergyPIC, Scalars +from struphy.models.scalars import FunctionScalarFEEC, Scalars from struphy.models.species import ( FieldSpecies, ParticleSpecies, @@ -18,27 +18,32 @@ class DriftKineticMaxwell(StruphyModel): - """Electromagnetic drift-kinetic equation for one guiding-center species coupled to Maxwell's equations, with an - implicit, energy conserving field-particle coupling that also holds in the quasi-neutral limit. + """Electromagnetic drift-kinetic equation for one guiding-center species coupled to Maxwell's equations, in the + thermal normalization, with an implicit, energy conserving field-particle coupling that also holds in the + quasi-neutral limit. The electric field enters the guiding-center equations through the gauge-invariant - :math:`\\mathbf E^* = \\mathbf E - \\varepsilon \\mu \\nabla |B_0|`. Ampère's law contains the guiding-center - current and the (linearized) polarization current; option ``kappa`` of the coupling propagator - :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling` multiplies the - displacement current, ``kappa=0.0`` is the quasi-neutral limit. + :math:`\\mathbf E^* = \\mathbf E - \\varepsilon \\mu \\nabla |B|`. Ampère's law contains the guiding-center + current and the (linearized) polarization current; the displacement current has the coefficient + :math:`\\lambda^2 \\varepsilon^2 = \\hat v_\\textrm{th}^2/c^2`, option ``lam`` of the coupling propagator + :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling`; ``lam=0.0`` is the + quasi-neutral limit. Parameters ---------- base_units: BaseUnits - Base units for normalization (default: BaseUnits()) + Base units for normalization (default: ``BaseUnits(kBT=1.0)``; the thermal energy defines the velocity unit). charge_number: int Charge number (in units of the positive elementary charge) of the species (default: 1) mass_number: float Mass number (in units of Proton mass) of the species (default: 1.0) - alpha: float, optional - Dimensionless parameter: plasma frequency / cyclotron frequency. If None, computed from units and charge/mass numbers. epsilon: float, optional - Normalized cyclotron period: 1 / (cyclotron frequency × time unit). If None, computed from units and charge/mass numbers. + Normalized cyclotron period: 1 / (cyclotron frequency × time unit). If None, computed from units and charge/mass + numbers. + lam: float, optional + Cyclotron frequency × length unit / speed of light. If None, computed from units and charge/mass numbers. + kappa: float, optional + Plasma frequency × length unit / speed of light. If None, computed from units and charge/mass numbers. """ @classmethod @@ -58,15 +63,17 @@ def __init__( self, charge_number: int = 1, mass_number: float = 1.0, - alpha: float = None, epsilon: float = None, + lam: float = None, + kappa: float = None, ): self.var = PICVariable(space="Particles5D") self.init_variables( charge_number=charge_number, mass_number=mass_number, - alpha=alpha, epsilon=epsilon, + lam=lam, + kappa=kappa, ) ## propagators @@ -77,6 +84,7 @@ def __init__( e_field: FEECVariable = None, b_field: FEECVariable = None, ): + # the parallel electric force is applied by the coupling step only: push_para gets no phi and no e_field self.coupling = DriftKineticMaxwellCoupling() self.push_para = PushGuidingCenterParallel(b_tilde=b_field) self.push_bxe = PushGuidingCenterBxEstar(b_tilde=b_field, e_field=e_field) @@ -85,23 +93,19 @@ def __init__( def __init__( self, - base_units: BaseUnits = BaseUnits(), + base_units: BaseUnits = BaseUnits(kBT=1.0), charge_number: int = 1, mass_number: float = 1.0, - alpha: float = None, epsilon: float = None, + lam: float = None, + kappa: float = None, ): # 0. store input parameters self.params = copy.deepcopy(locals()) # 1. instantiate all species self.em_fields = self.EMFields() - self.kinetic_ions = self.KineticIons( - charge_number, - mass_number, - alpha, - epsilon, - ) + self.kinetic_ions = self.KineticIons(charge_number, mass_number, epsilon, lam, kappa) # 2. derive units (must be done after instantiating species to access charge and mass numbers) self.setup_equation_params(base_units=base_units) @@ -116,15 +120,17 @@ def __init__( self.propagators.push_para.variables.ions = self.kinetic_ions.var self.propagators.push_bxe.variables.ions = self.kinetic_ions.var - # 5. define scalars to be tracked during simulation + # 5. define scalars to be tracked during simulation: the parts of the energy conserved by the coupling step + # (with the coefficients of the propagator, known after its allocation) + coupling = self.propagators.coupling + electric_energy = FunctionScalarFEEC(lambda: coupling.energies()[0]) + magnetic_energy = FunctionScalarFEEC(lambda: coupling.energies()[1]) + particle_energy = FunctionScalarFEEC(lambda: coupling.energies()[2]) self.scalars = Scalars( - en_E=BilinearEnergyFEEC(self.em_fields.e_field), - en_B=BilinearEnergyFEEC(self.em_fields.b_field), - en_f=KineticEnergyPIC( - self.kinetic_ions.var, - normalization=self.kinetic_ions.equation_params.alpha**2, - ), - en_tot=FunctionScalarFEEC(self.propagators.coupling.energy), + en_E=electric_energy, + en_B=magnetic_energy, + en_f=particle_energy, + en_tot=electric_energy + magnetic_energy + particle_energy, ) @property @@ -133,13 +139,34 @@ def bulk_species(self): @property def velocity_scale(self): - return "light" + return "thermal" def post_allocate(self): - """No initial field solve: the electric field is initialized from the parameter file. + """Check that the parallel electric force is applied only once (by the coupling step), then no initial field + solve: the electric field is initialized from the parameter file. + + :meta private: + """ + push_para = self.propagators.push_para + assert push_para.e_field is None and push_para._allocate_phi, ( + "DriftKineticMaxwell: the parallel electric force b* . E is applied by the coupling step; " + "push_para must not get an electric field or a potential (it would be applied twice)." + ) + + ## default parameters + + def generate_default_parameter_file(self, path=None, prompt=True): + """The thermal velocity scale needs the thermal energy unit: ``BaseUnits(kBT=1.0)``. :meta private: """ + params_path = super().generate_default_parameter_file(path=path, prompt=prompt) + with open(params_path, "r") as f: + lines = f.readlines() + with open(params_path, "w") as f: + for line in lines: + f.write("base_units = BaseUnits(kBT=1.0)\n" if "BaseUnits(" in line else line) + return params_path @classmethod def doc_pde(cls): @@ -164,15 +191,13 @@ def doc_pde(cls): where :math:`|\mathbf B| = \mathbf b_0 \cdot (\mathbf B_0 + \tilde{\mathbf B})`. - Ampère's law with the displacement current (coefficient :math:`\kappa`) and the polarization current - (coefficient :math:`\chi`, default :math:`\alpha^2`), options of - :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling`: + Ampère's law, with the displacement current and the (linearized) polarization current of the guiding centers: .. math:: - \kappa \frac{\partial \mathbf E}{\partial t} - + \chi \frac{n_0}{|B_0|^2} \frac{\partial \mathbf E_\perp}{\partial t} - = \nabla \times \tilde{\mathbf B} - \frac{\alpha^2}{\varepsilon} \iint f B^*_\parallel + \varepsilon^2 \lambda^2 \frac{\partial \mathbf E}{\partial t} + + \varepsilon^2 \kappa^2 \frac{n_0}{|\mathbf B_0|^2} \frac{\partial \mathbf E_\perp}{\partial t} + = \nabla \times \tilde{\mathbf B} - \varepsilon \kappa^2 \iint f B^*_\parallel \left( v_\parallel \frac{\mathbf B^*}{B^*_\parallel} + \frac{\mathbf E \times \mathbf b_0}{B^*_\parallel} \right) \textrm d v_\parallel \textrm d \mu\,, @@ -182,36 +207,63 @@ def doc_pde(cls): \frac{\partial \tilde{\mathbf B}}{\partial t} + \nabla \times \mathbf E = 0\,. - :math:`\mathbf B_0` is a static equilibrium field, :math:`n_0` the equilibrium density. The current in - Ampère's law is the guiding-center current without the magnetic (grad-:math:`B`) drift, whose work on - :math:`\mathbf E` is therefore missing where :math:`\nabla |B| \neq 0`. For :math:`\kappa = 0` (quasi-neutral - limit) the parallel electric field is determined by the parallel current (Ohm's law with inertia), the - perpendicular one by the polarization current. + :math:`\mathbf B_0` is a static equilibrium field, :math:`n_0` the equilibrium density, and + :math:`\mathbf E_\perp = \mathbf E - (\mathbf b_0 \cdot \mathbf E) \mathbf b_0`. The current in Ampère's law is + the guiding-center current without the magnetic (grad-:math:`B`) drift, whose work on :math:`\mathbf E` is + therefore missing where :math:`\nabla |B| \neq 0`. The coefficients :math:`\varepsilon\lambda` (option ``lam``, + 0 is the quasi-neutral limit), :math:`(\varepsilon\kappa)^2` (option ``chi``) and :math:`\varepsilon\kappa^2` + (option ``c2``) can be changed in + :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling`. Without the + polarization current the quasi-neutral limit would be singular for flute modes (:math:`k_\parallel = 0`). """ @classmethod def doc_normalization(cls): - r"""The model uses the light speed as reference velocity: + r"""The model uses the thermal velocity of the species as reference velocity: + + .. math:: + + \hat v = \hat v_\textrm{th} = \sqrt{\frac{k_\textrm{B} \hat T}{m}}\,,\qquad \hat t = \frac{\hat x}{\hat v}\,, + \qquad \hat E = \hat v \hat B\,,\qquad \hat \mu = \frac{m \hat v^2}{\hat B}\,. + + The species parameters (see :ref:`equation_params`) are + + .. math:: + + \varepsilon = \frac{1}{\hat\Omega_c \hat t} = \frac{\hat v}{\hat\Omega_c \hat x}\,,\qquad + \lambda = \frac{\hat\Omega_c \hat x}{c}\,,\qquad \kappa = \frac{\hat\Omega_p \hat x}{c}\,. + + The drift-kinetic equation and Faraday's law then have only the coefficient :math:`\varepsilon` + (:math:`\frac{m \hat v}{q \hat B \hat x} = \varepsilon` in :math:`\mathbf B^*` and :math:`\mathbf E^*`, + :math:`\frac{q \hat B \hat t}{m} = \frac{1}{\varepsilon}` in the parallel force). Ampère's law + :math:`\frac{1}{c^2} \partial_t \mathbf E + \mu_0 \frac{n m}{|B|^2} \partial_t \mathbf E_\perp = \nabla \times + \mathbf B - \mu_0 q \mathbf J`, multiplied by :math:`\hat x / \hat B`, has the coefficients .. math:: - \hat v = c,\qquad \hat E = \hat B \hat v\,. + \frac{\hat v^2}{c^2} = \varepsilon^2 \lambda^2\,,\qquad + \frac{\mu_0 \hat n m \hat v^2}{\hat B^2} = \frac{\hat v^2}{\hat v_A^2} = \varepsilon^2 \kappa^2\,,\qquad + \frac{\mu_0 q \hat n \hat v \hat x}{\hat B} = \varepsilon \kappa^2\,. - The species parameters are :math:`\alpha=\hat\Omega_p/\hat\Omega_c` and - :math:`\varepsilon=1/(\hat\Omega_c\hat t)`. The polarization coefficient :math:`\chi = \alpha^2` times - :math:`n_0/|B_0|^2` is :math:`c^2/v_A^2` of the species.""" + Their ratio :math:`\kappa^2/\lambda^2 = \hat\Omega_p^2/\hat\Omega_c^2 = c^2/\hat v_A^2` compares the polarization + with the vacuum dielectric, and :math:`1/\kappa` is the inertial length :math:`c/\hat\Omega_p` in units of + :math:`\hat x`. For example, deuterium with :math:`\hat x = 1` m, :math:`\hat B = 5` T, + :math:`\hat n = 10^{20}` m\ :sup:`-3`, :math:`k_\textrm{B} \hat T = 10` keV gives :math:`\varepsilon = 2.9 \cdot + 10^{-3}`, :math:`\lambda = 0.80`, :math:`\kappa = 31`, i.e. :math:`\varepsilon^2 \lambda^2 = 5.3 \cdot 10^{-6}`, + :math:`\varepsilon^2 \kappa^2 = 8.0 \cdot 10^{-3}` and :math:`\varepsilon \kappa^2 = 2.8`.""" @classmethod def doc_scalar_quantities(cls): - r"""**The following scalars are tracked during simulation:** + r"""**The following scalars are tracked during simulation** (the parts of the energy conserved by the coupling + step, with the coefficients of + :class:`~struphy.propagators.drift_kinetic_maxwell_coupling.DriftKineticMaxwellCoupling`, here the defaults): - - Electric field energy :math:`\frac 12 \|\mathbf E\|^2`: ``en_E`` - - Magnetic field energy :math:`\frac 12 \|\tilde{\mathbf B}\|^2`: ``en_B`` - - Parallel kinetic energy :math:`\alpha^2 \sum_p w_p v_{\parallel,p}^2/2`: ``en_f`` - - Total energy conserved by the coupling step, - :math:`\frac{\kappa}{2} \| \mathbf E \|^2 + \frac{\chi}{2} \| \mathbf E_\perp \|^2_{n_0/|B_0|^2} - + \frac{1}{2} \| \tilde{\mathbf B} \|^2 + \alpha^2 \sum_p w_p (v_{\parallel,p}^2/2 + \mu_p |B_0|)`: - ``en_tot``""" + - Electric field energy (displacement and polarization) + :math:`\frac{\varepsilon^2 \lambda^2}{2} \| \mathbf E \|^2 + \frac{\varepsilon^2 \kappa^2}{2} + \| \mathbf E_\perp \|^2_{n_0/|B_0|^2}`: ``en_E`` + - Magnetic field energy :math:`\frac{1}{2} \| \tilde{\mathbf B} \|^2`: ``en_B`` + - Particle energy :math:`\varepsilon^2 \kappa^2 \sum_p w_p (v_{\parallel,p}^2/2 + \mu_p |B_0|)`: ``en_f`` + - Total energy: ``en_tot`` (conserved by the coupling step up to the solver tolerance)""" @classmethod def doc_discretization(cls): @@ -221,7 +273,21 @@ def doc_discretization(cls): 2. :class:`~struphy.propagators.push_guiding_center_parallel.PushGuidingCenterParallel` 3. :class:`~struphy.propagators.push_guiding_center_bx_estar.PushGuidingCenterBxEstar` """ - doc = rf"""**1. DriftKineticMaxwellCoupling:** + doc = rf"""Each term of the drift-kinetic equation is applied by exactly one propagator; the parallel electric +force only by the coupling step (the model passes no potential and no electric field to ``push_para``, and asserts +this): + +- :math:`\dot v_\parallel \ni \frac{{1}}{{\varepsilon}} \mathbf b^* \cdot \mathbf E`: coupling step + (``push_vpar_with_efield``); +- :math:`\dot v_\parallel \ni -\mu\, \mathbf b^* \cdot \nabla |B|` (mirror force): ``push_para``; +- :math:`\dot{{\mathbf X}} \ni v_\parallel \mathbf b^*` (streaming, curvature drift): ``push_para``; its current + enters Ampère's law in the coupling step; +- :math:`\dot{{\mathbf X}} \ni \mathbf E \times \mathbf b_0 / B^*_\parallel`: ``push_bxe``; its current enters + Ampère's law in the coupling step (option ``exb_current``); +- :math:`\dot{{\mathbf X}} \ni -\varepsilon \mu \nabla |B| \times \mathbf b_0 / B^*_\parallel` (grad-:math:`B` + drift): ``push_bxe``; its current is not included. + +**1. DriftKineticMaxwellCoupling:** {DriftKineticMaxwellCoupling.__doc__} @@ -239,13 +305,14 @@ def doc_discretization(cls): def doc_long_description(cls): r"""DriftKineticMaxwell couples one drift-kinetic (guiding-center) species to Maxwell's equations in the gauge-invariant :math:`v_\parallel` formulation (the electric field, not a potential, enters - :math:`\mathbf E^*`). Maxwell's equations and the parallel acceleration are solved together in one implicit, - energy conserving step, which allows time steps above the plasma period and has no cancellation problem. - Since the drift-kinetic ordering removes the polarization drift, its (linearized) polarization current is - added to Ampère's law; with it, the quasi-neutral limit without displacement current is well-posed and gives - the inertial Alfvén wave. The guiding centers then stream along the field and drift with - :math:`\mathbf E^* \times \mathbf b_0`. The total energy is conserved exactly by the splitting if - :math:`\nabla |B_0| = 0` and either :math:`\mu = 0` or :math:`\mathbf b_0 \cdot \tilde{\mathbf B} = 0`.""" + :math:`\mathbf E^*`), normalized with the thermal velocity of the species. Maxwell's equations and the parallel + acceleration are solved together in one implicit, energy conserving step, which allows time steps above the + plasma period and has no cancellation problem. Since the drift-kinetic ordering removes the polarization drift, + its (linearized) polarization current is added to Ampère's law; with it, the quasi-neutral limit without + displacement current is well-posed and gives the inertial Alfvén wave. The guiding centers then stream along + the field and drift with :math:`\mathbf E^* \times \mathbf b_0`. The total energy is conserved exactly by the + splitting if :math:`\nabla |B_0| = 0` and either :math:`\mu = 0` or :math:`\mathbf b_0 \cdot \tilde{\mathbf B} + = 0`.""" @classmethod def doc_examples(cls): @@ -257,7 +324,7 @@ def doc_examples(cls): model = DriftKineticMaxwell() prop = model.propagators.coupling - prop.options = prop.Options(kappa=0.0) + prop.options = prop.Options(lam=0.0) """ @classmethod @@ -266,7 +333,7 @@ def doc_use_cases(cls): - electromagnetic drift-kinetic simulations with time steps above the plasma period - shear (inertial) Alfvén waves with kinetic parallel dynamics - - the quasi-neutral limit (``kappa=0.0``) with full-f weights""" + - the quasi-neutral limit (``lam=0.0``) with full-f weights""" @classmethod def doc_cannot_be_used_for(cls): diff --git a/src/struphy/models/tests/verification/test_verif_DriftKineticMaxwell.py b/src/struphy/models/tests/verification/test_verif_DriftKineticMaxwell.py index f4e56bb36..a33dcf944 100644 --- a/src/struphy/models/tests/verification/test_verif_DriftKineticMaxwell.py +++ b/src/struphy/models/tests/verification/test_verif_DriftKineticMaxwell.py @@ -3,11 +3,13 @@ .. math:: - \\omega^2 (\\kappa \\mathbf E + \\chi \\mathbf E_\\perp) = c_1 (k^2 \\mathbf E - \\mathbf k\\, \\mathbf k \\cdot \\mathbf E) + \\omega^2 (\\lambda^2 \\mathbf E + \\chi \\mathbf E_\\perp) = c_1 (k^2 \\mathbf E - \\mathbf k\\, \\mathbf k \\cdot \\mathbf E) + P E_z \\mathbf e_z - \\mathrm i \\omega H\\, \\mathbf E \\times \\mathbf e_z\\,, -with :math:`P = c_2 c_3 n_0` (parallel response) and :math:`H = c_2 n_0` (E x B current, zero if switched off). -For :math:`\\kappa = 0` and :math:`H = 0` the shear branch is the inertial Alfvén wave +with :math:`P = c_2 c_3 n_0` (parallel response) and :math:`H = c_2 n_0` (E x B current, zero if switched off), in the +thermal normalization: :math:`\\chi = (\\varepsilon\\kappa_\\textrm{s})^2`, :math:`c_2 = \\varepsilon\\kappa_\\textrm{s}^2`, +:math:`c_3 = 1/\\varepsilon`, and :math:`\\lambda` the option ``lam`` (coefficient of the displacement current). +For :math:`\\lambda = 0` and :math:`H = 0` the shear branch is the inertial Alfvén wave :math:`\\omega^2 = (c_1/\\chi)\\, k_z^2 / (1 + k_x^2 c_1/P)`. The energy is conserved exactly by the splitting (cold plasma, uniform B0). @@ -25,23 +27,24 @@ set_logging_level(logging.WARNING) -ALPHA, EPSILON = 3.0, 0.5 +# species parameters (in the thermal normalization of the model): chi = (EPSILON KAPPA)^2 = 9, c2 = 18, c3 = 2 +EPSILON, LAM, KAPPA = 0.5, 2.0, 6.0 LX, LZ = 1.0, 4.0 VTH = 1e-6 # cold -def _dispersion_roots(kappa, exb_current): +def _dispersion_roots(lam, exb_current): """Positive real roots omega of the determinant of the cold-slab dispersion matrix (polynomial of degree 6, interpolated exactly from 7 values).""" - c1, c2, c3 = 1.0, ALPHA**2 / EPSILON, 1.0 / EPSILON - chi, P, H = ALPHA**2, c2 * c3, c2 if exb_current else 0.0 + c1, c2, c3 = 1.0, KAPPA**2 * EPSILON, 1.0 / EPSILON + chi, P, H = (EPSILON * KAPPA) ** 2, c2 * c3, c2 if exb_current else 0.0 k = np.array([2 * np.pi / LX, 0.0, 2 * np.pi / LZ]) proj_perp = np.diag([1.0, 1.0, 0.0]) cross_z = np.array([[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) # E -> E x e_z def det(w): mat = ( - w**2 * (kappa * np.eye(3) + chi * proj_perp) + w**2 * (lam**2 * np.eye(3) + chi * proj_perp) - c1 * (k @ k * np.eye(3) - np.outer(k, k)) - P * np.diag([0.0, 0.0, 1.0]) + 1j * w * H * cross_z @@ -54,7 +57,7 @@ def det(w): return np.sort(roots[(abs(roots.imag) < 1e-8) & (roots.real > 1e-8)].real) -def _alfven_signal(kappa, exb_current, dt, n_steps): +def _alfven_signal(lam, exb_current, dt, n_steps): """Time series s(t) = b(t)^T M2 b(0) / |b(0)|^2 of the shear Alfvén wave, excited by a B_y perturbation, and the relative change of the energy.""" from struphy import ( @@ -70,7 +73,7 @@ def _alfven_signal(kappa, exb_current, dt, n_steps): ) from struphy.linear_algebra.solver import SolverParameters - model = models.DriftKineticMaxwell(alpha=ALPHA, epsilon=EPSILON) + model = models.DriftKineticMaxwell(epsilon=EPSILON, lam=LAM, kappa=KAPPA) model.kinetic_ions.set_markers( loading_params=LoadingParameters(ppc=50, seed=1234, moments=(0.0, 0.0, VTH, VTH), B0=1.0) ) @@ -83,7 +86,7 @@ def _alfven_signal(kappa, exb_current, dt, n_steps): prop = model.propagators.coupling solver = {} if exb_current else {"solver": "pcg"} prop.options = prop.Options( - kappa=kappa, exb_current=exb_current, solver_params=SolverParameters(tol=1e-12, maxiter=3000), **solver + lam=lam, exb_current=exb_current, solver_params=SolverParameters(tol=1e-12, maxiter=3000), **solver ) Simulation( model=model, @@ -94,7 +97,7 @@ def _alfven_signal(kappa, exb_current, dt, n_steps): ).allocate() props = model.propagators - prop.prepare_time_step(dt) # the initial projection for kappa = 0 + prop.prepare_time_step(dt) # the initial projection for lam = 0 energy_0 = prop.energy() b = model.em_fields.b_field.spline.vector b_init = b.copy() @@ -126,13 +129,13 @@ def model(t, c, *p): @pytest.mark.parametrize( - "kappa, exb_current, dt, n_steps", + "lam, exb_current, dt, n_steps", [(0.0, False, 0.5, 160), (0.0, True, 0.5, 160), (1.0, False, 0.1, 400)], ) -def test_shear_alfven_wave(kappa, exb_current, dt, n_steps): - roots = _dispersion_roots(kappa, exb_current) - t, s, energy_change = _alfven_signal(kappa, exb_current, dt, n_steps) - if kappa > 0.0: +def test_shear_alfven_wave(lam, exb_current, dt, n_steps): + roots = _dispersion_roots(lam, exb_current) + t, s, energy_change = _alfven_signal(lam, exb_current, dt, n_steps) + if lam > 0.0: # the B_y perturbation also excites the fast (light-wave like) upper branch of the shear mode omegas = _fit(t, s, (roots[0], roots[-1])) else: diff --git a/src/struphy/pic/accumulation/kernels/dk_maxwell/dk_maxwell_kernels.py b/src/struphy/pic/accumulation/kernels/dk_maxwell/dk_maxwell_kernels.py index 1bd4a1dbe..9b1eee3a9 100644 --- a/src/struphy/pic/accumulation/kernels/dk_maxwell/dk_maxwell_kernels.py +++ b/src/struphy/pic/accumulation/kernels/dk_maxwell/dk_maxwell_kernels.py @@ -34,7 +34,7 @@ def dk_maxwell( vec2: "float[:,:,:]", vec3: "float[:,:,:]", epsilon: float, - lam: float, + skew: float, b2_1: "float[:,:,:]", b2_2: "float[:,:,:]", b2_3: "float[:,:,:]", @@ -53,7 +53,7 @@ def dk_maxwell( .. math:: - A_p^{\mu\nu} = w_p \left( a_{p,\mu} a_{p,\nu} + \lambda\, S_{p,\mu\nu} \right)\,, + A_p^{\mu\nu} = w_p \left( a_{p,\mu} a_{p,\nu} + \sigma\, S_{p,\mu\nu} \right)\,, \qquad B_p^\mu = w_p\, v_{\parallel,p}\, a_{p,\mu}\,, \qquad @@ -66,8 +66,8 @@ def dk_maxwell( \widehat{(\nabla \times \mathbf b_0) \cdot \mathbf b_0})`, as in :class:`~struphy.propagators.push_guiding_center_parallel.PushGuidingCenterParallel` and :class:`~struphy.propagators.push_guiding_center_bx_estar.PushGuidingCenterBxEstar`. The rank-one part is the - parallel response, the antisymmetric part :math:`S_p` the :math:`E \times B` current, scaled by ``lam`` - (:math:`\lambda`). + parallel response, the antisymmetric part :math:`S_p` the :math:`E \times B` current, scaled by ``skew`` + (:math:`\sigma`). """ markers = args_markers.markers @@ -111,12 +111,12 @@ def dk_maxwell( # unit vector b0 (1-form) eval_1form_spline_mpi(span1, span2, span3, args_derham, unit_b1_1, unit_b1_2, unit_b1_3, unit_b1) - # filling: w (a a^T + lam S), with S x = x cross b0 / B*_parallel + # filling: w (a a^T + skew S), with S x = x cross b0 / B*_parallel for i in range(3): for j in range(3): filling_m[i, j] = weight * a[i] * a[j] - s = weight * lam / b_star_parallel + s = weight * skew / b_star_parallel filling_m[0, 1] += s * unit_b1[2] filling_m[0, 2] -= s * unit_b1[1] filling_m[1, 0] -= s * unit_b1[2] diff --git a/src/struphy/propagators/drift_kinetic_maxwell_coupling.py b/src/struphy/propagators/drift_kinetic_maxwell_coupling.py index 7ee3d6a92..406238372 100644 --- a/src/struphy/propagators/drift_kinetic_maxwell_coupling.py +++ b/src/struphy/propagators/drift_kinetic_maxwell_coupling.py @@ -33,7 +33,7 @@ class DriftKineticMaxwellCoupling(Propagator): .. math:: - \kappa \int_{\Omega} \frac{\partial \mathbf E}{\partial t} \cdot \mathbf F \,\textrm d \mathbf x + \lambda^2 \int_{\Omega} \frac{\partial \mathbf E}{\partial t} \cdot \mathbf F \,\textrm d \mathbf x + \chi \int_{\Omega} \frac{n_0}{|B_0|^2} \frac{\partial \mathbf E_\perp}{\partial t} \cdot \mathbf F_\perp \,\textrm d \mathbf x &= c_1 \int_{\Omega} \tilde{\mathbf B} \cdot \nabla \times \mathbf F \,\textrm d \mathbf x @@ -60,13 +60,16 @@ class DriftKineticMaxwellCoupling(Propagator): The current is the guiding-center current :math:`\iint f^\textnormal{vol} \dot{\mathbf X}` without the magnetic drifts (which move the positions): parallel streaming, including the curvature drift in :math:`\mathbf B^*`, and (option ``exb_current``) the :math:`E \times B` drift. The coefficients are - :math:`\kappa \geq 0`, :math:`\chi \geq 0` with :math:`\kappa + \chi > 0`, :math:`c_1 > 0` and - :math:`c_2 c_3 > 0`; the defaults :math:`\kappa = 1`, :math:`\chi = \alpha^2`, :math:`c_1 = 1`, - :math:`c_2 = \alpha^2/\varepsilon`, :math:`c_3 = 1/\varepsilon` are the units of - :class:`~struphy.models.vlasov_maxwell_one_species.VlasovMaxwellOneSpecies`. The :math:`\chi` term is the + :math:`\lambda \geq 0`, :math:`\chi \geq 0` with :math:`\lambda^2 + \chi > 0`, :math:`c_1 > 0` and + :math:`c_2 c_3 > 0`. The defaults are the coefficients of the species' :ref:`equation parameters `, + :math:`\lambda = \varepsilon \lambda_\textrm{s}`, :math:`\chi = (\varepsilon \kappa_\textrm{s})^2`, + :math:`c_1 = 1`, :math:`c_2 = \kappa_\textrm{s}^2 \varepsilon`, :math:`c_3 = 1/\varepsilon`, i.e. for the thermal + velocity scale of :class:`~struphy.models.drift_kinetic_maxwell.DriftKineticMaxwell` + :math:`\lambda = \hat v_\textrm{th}/c`, :math:`\chi = \hat v_\textrm{th}^2/\hat v_A^2`. The :math:`\chi` term is the (linearized, long-wavelength) polarization current of the guiding centers, which the drift-kinetic ordering removes from :math:`\dot{\mathbf X}`: without it, the perpendicular electrostatic field is undetermined for - :math:`\kappa = 0` (flute modes), and for :math:`\kappa = 1` the shear Alfvén wave travels at the speed of light. + :math:`\lambda = 0` (flute modes), and with only the displacement current the shear Alfvén wave travels at the + speed of light. With the FE coefficients :math:`\mathbf e \in \mathbb R^{N_1}` of :math:`\mathbf E \in V^1_h` and :math:`\mathbf b \in \mathbb R^{N_2}` of :math:`\tilde{\mathbf B} \in V^2_h`, and :math:`N` markers with logical @@ -82,7 +85,7 @@ class DriftKineticMaxwellCoupling(Propagator): \qquad \dot v_{\parallel,p} = c_3\, \mathbf a_p \cdot \mathbf \Lambda^1_p \mathbf e\,, - with :math:`\mathbb M_E = \kappa\, \mathbb M_1 + \chi\, \mathbb M_1^\perp` (:math:`\mathbb M_1^\perp` = + with :math:`\mathbb M_E = \lambda^2\, \mathbb M_1 + \chi\, \mathbb M_1^\perp` (:math:`\mathbb M_1^\perp` = :attr:`~struphy.feec.mass.WeightedMassOperators.M1gyro`), :math:`\mathbf a_p = \hat{\mathbf B}^{*2} / \hat B^{*3}_\parallel (\boldsymbol \eta_p)`, :math:`\mathbf \Lambda^1_p` the basis of :math:`V^1_h` at :math:`\boldsymbol \eta_p`, and @@ -134,7 +137,7 @@ class DriftKineticMaxwellCoupling(Propagator): with the velocity update by :func:`~struphy.pic.pushing.kernels.push_vpar_with_efield.push_vpar_with_efield_kernels.push_vpar_with_efield`. - **Quasi-neutral limit** :math:`\kappa = 0`. The parallel part of :math:`\mathbf e` is then determined by + **Quasi-neutral limit** :math:`\lambda = 0`. The parallel part of :math:`\mathbf e` is then determined by :math:`\mathbb A_\parallel` alone (which needs positive weights, i.e. full-f, and markers everywhere), the perpendicular part by :math:`\chi\, \mathbb M_1^\perp` and the curl-curl operator. The perpendicular field still carries the energy :math:`\frac 12 \mathbf e^\top \mathbb M_E \mathbf e`, so the Crank-Nicolson update is @@ -210,15 +213,18 @@ class Options(OptionsBase): Parameters ---------- - kappa : float, default=1.0 - Coefficient :math:`\kappa \geq 0` of the displacement current :math:`\partial_t \mathbf E`; - ``kappa=0.0`` is the quasi-neutral limit (needs ``chi > 0`` and full-f weights, no control variate). + lam : float, default=None + The displacement current has the coefficient :math:`\lambda^2` (``lam`` :math:`= \lambda \geq 0`); + ``lam=0.0`` is the quasi-neutral limit (needs ``chi > 0`` and full-f weights, no control variate). If + ``None``, :math:`\varepsilon \lambda_\textrm{s}` of the species (``lam`` and ``epsilon`` of + its equation parameters). chi : float, default=None Coefficient :math:`\chi \geq 0` of the polarization current - :math:`\frac{n_0}{|B_0|^2} \partial_t \mathbf E_\perp`. If ``None``, :math:`\alpha^2` of the species - (the polarization of the guiding centers themselves); a larger value mimics the polarization of a heavier - species. ``kappa + chi`` must be positive. The density :math:`n_0` is the one of the MHD equilibrium + :math:`\frac{n_0}{|B_0|^2} \partial_t \mathbf E_\perp`. If ``None``, + :math:`(\varepsilon \kappa_\textrm{s})^2` of the species (the polarization of the + guiding centers themselves); a larger value mimics the polarization of a heavier species. ``lam**2 + chi`` + must be positive. The density :math:`n_0` is the one of the MHD equilibrium (:attr:`~struphy.feec.mass.WeightedMassOperators.M1gyro`), not of the kinetic background: choose them consistently. @@ -226,8 +232,8 @@ class Options(OptionsBase): Coefficient :math:`c_1 > 0` of :math:`\nabla \times \tilde{\mathbf B}` in Ampère's law. c2 : float, default=None - Coefficient of the current density in Ampère's law. If ``None``, :math:`\alpha^2/\varepsilon` of the - species. + Coefficient of the current density in Ampère's law. If ``None``, :math:`\kappa_\textrm{s}^2 \varepsilon` + of the species (``kappa`` and ``epsilon`` of its equation parameters). c3 : float, default=None Coefficient of the parallel electric force (:math:`c_2 c_3 > 0`). If ``None``, :math:`1/\varepsilon` of @@ -266,7 +272,7 @@ class Options(OptionsBase): of assembling them. Needs 7 auxiliary marker columns. initial_projection : bool, default=True - Only for ``kappa=0``: in the setup (first :meth:`prepare_time_step`), replace + Only for ``lam=0``: in the setup (first :meth:`prepare_time_step`), replace :math:`(\mathbf b, \mathbf V)` by the mid-point state of one step, which satisfies the constraint of the quasi-neutral limit exactly. Not applied to a restored (restart) state. """ @@ -274,7 +280,7 @@ class Options(OptionsBase): # specific literals OptsSolver = Literal["pbicgstab", "bicgstab", "gmres", "pcg", "cg"] - kappa: float = 1.0 + lam: float = None chi: float = None c1: float = 1.0 c2: float = None @@ -291,11 +297,13 @@ def __post_init__(self): # checks check_option(self.solver, self.OptsSolver) check_option(self.precond, LiteralOptions.OptsMaxwellPrecond) - assert self.kappa >= 0.0, f"kappa must be >= 0, got {self.kappa}." + if self.lam is not None: + assert self.lam >= 0.0, f"lam must be >= 0, got {self.lam}." if self.chi is not None: assert self.chi >= 0.0, f"chi must be >= 0, got {self.chi}." - assert self.kappa + self.chi > 0.0, ( - "kappa + chi must be > 0: without displacement and polarization current, the perpendicular " + if self.lam is not None and self.chi is not None: + assert self.lam**2 + self.chi > 0.0, ( + "lam**2 + chi must be > 0: without displacement and polarization current, the perpendicular " "electric field is undetermined." ) assert self.c1 > 0.0, f"c1 must be > 0, got {self.c1}." @@ -334,24 +342,25 @@ def options(self, new): def allocate(self): # coefficients equation_params = self.variables.ions.species.equation_params - alpha = equation_params.alpha epsilon = equation_params.epsilon self._epsilon = epsilon - self._kappa = self.options.kappa - self._chi = alpha**2 if self.options.chi is None else self.options.chi + # lam^2 = (epsilon lam_s)^2, chi = (epsilon kappa_s)^2, c2 = kappa_s^2 epsilon, c3 = 1 / epsilon + # (see the equation parameters) + self._lam2 = (epsilon * equation_params.lam) ** 2 if self.options.lam is None else self.options.lam**2 + self._chi = (epsilon * equation_params.kappa) ** 2 if self.options.chi is None else self.options.chi self._c1 = self.options.c1 - self._c2 = alpha**2 / epsilon if self.options.c2 is None else self.options.c2 + self._c2 = equation_params.kappa**2 * epsilon if self.options.c2 is None else self.options.c2 self._c3 = 1.0 / epsilon if self.options.c3 is None else self.options.c3 self._exb = self.options.exb_current assert self._c2 * self._c3 > 0.0, f"c2 * c3 must be > 0, got c2={self._c2}, c3={self._c3}." - assert self._kappa + self._chi > 0.0, f"kappa + chi must be > 0, got kappa={self._kappa}, chi={self._chi}." + assert self._lam2 + self._chi > 0.0, f"lam**2 + chi must be > 0, got lam**2={self._lam2}, chi={self._chi}." assert not self.derham.polar_splines, "DriftKineticMaxwellCoupling does not support polar splines yet." particles = self.variables.ions.particles if particles.control_variate: - assert self._kappa > 0.0, ( - "The quasi-neutral limit kappa=0 needs full-f weights: with the control variate the accumulated " + assert self._lam2 > 0.0, ( + "The quasi-neutral limit lam=0 needs full-f weights: with the control variate the accumulated " "matrix is indefinite and the Schur system is not well-posed." ) logger.warning( @@ -428,7 +437,7 @@ def allocate(self): symmetry="asym", ) else: - # one matrix A_par + lam * S, lam = 2 / (dt * c3) (set in prepare_time_step) + # one matrix A_par + skew * S, skew = 2 / (dt * c3) (set in prepare_time_step) self._args_accum_pre = (epsilon,) self._args_accum_post = args_fields + args_unit_b + args_b_par self._accum = Accumulator( @@ -441,17 +450,17 @@ def allocate(self): symmetry=None, ) self._A_lam = self._accum.operators[0] - self._lam = 0.0 + self._skew = 0.0 # operators self._M1 = self.mass_ops.M1 self._M2 = self.mass_ops.M2 self._curl = self.derham.curl self._curl_curl = self._curl.T @ self._M2 @ self._curl - if self._kappa > 0.0 and self._chi > 0.0: - self._ME = self._kappa * self._M1 + self._chi * self.mass_ops.M1gyro - elif self._kappa > 0.0: - self._ME = self._kappa * self._M1 + if self._lam2 > 0.0 and self._chi > 0.0: + self._ME = self._lam2 * self._M1 + self._chi * self.mass_ops.M1gyro + elif self._lam2 > 0.0: + self._ME = self._lam2 * self._M1 else: self._ME = self._chi * self.mass_ops.M1gyro @@ -533,12 +542,12 @@ def _accumulate(self): if self._matrix_free: self._accum(*self._args_accum_pre, *self._args_accum_post) else: - self._accum(*self._args_accum_pre, self._lam, *self._args_accum_post) + self._accum(*self._args_accum_pre, self._skew, *self._args_accum_post) def prepare_time_step(self, dt: float): """Schur operator, its preconditioner and solver for the time step ``dt``: built in the setup, rebuilt when dt changes. The particle matrices enter by reference (assembled, or matrix-free reading the fillings stored in - the markers), so they are updated by the accumulation in every step. For ``kappa=0`` (and + the markers), so they are updated by the accumulation in every step. For ``lam=0`` (and ``initial_projection``), the first call also projects the state onto the constraint.""" if dt == self._schur_dt: return @@ -549,8 +558,8 @@ def prepare_time_step(self, dt: float): if self._exb: schur = schur + (dt * self._c2 / 2) * self._S else: - # A_lam = A_par + lam S with lam = 2 / (dt c3): dt^2 c2 c3 / 4 A_lam = dt^2 c2 c3 / 4 A_par + dt c2 / 2 S - self._lam = 2.0 / (dt * self._c3) if self._exb else 0.0 + # A_lam = A_par + skew S with skew = 2 / (dt c3): dt^2 c2 c3 / 4 A_lam = dt^2 c2 c3 / 4 A_par + dt c2 / 2 S + self._skew = 2.0 / (dt * self._c3) if self._exb else 0.0 schur = schur + (dt**2 * self._c2 * self._c3 / 4) * self._A_lam if self._precond == "StiffnessPreconditioner": @@ -571,15 +580,16 @@ def prepare_time_step(self, dt: float): self._solver = inverse(schur, self.options.solver, **kwargs) self._schur_dt = dt - # kappa = 0: consistent initial state, once (in the setup, before the initial data are saved) - if self._kappa == 0.0 and self.options.initial_projection and not self._projected: + # lam = 0: consistent initial state, once (in the setup, before the initial data are saved) + if self._lam2 == 0.0 and self.options.initial_projection and not self._projected: self._project_onto_constraint(dt) self._projected = True - def energy(self) -> float: - r"""The energy :math:`H = \frac{1}{2} \mathbf e^\top \mathbb M_E \mathbf e + \frac{c_1}{2} \mathbf b^\top - \mathbb M_2 \mathbf b + \frac{c_2}{c_3} \sum_p w_p (v_{\parallel,p}^2/2 + \mu_p |B_0|(\boldsymbol \eta_p))` - conserved by the propagator (summed over all MPI ranks; a collective operation).""" + def energies(self) -> tuple[float, float, float]: + r"""The three parts :math:`\frac{1}{2} \mathbf e^\top \mathbb M_E \mathbf e` (displacement and polarization), + :math:`\frac{c_1}{2} \mathbf b^\top \mathbb M_2 \mathbf b` and + :math:`\frac{c_2}{c_3} \sum_p w_p (v_{\parallel,p}^2/2 + \mu_p |B_0|(\boldsymbol \eta_p))` of the energy + :meth:`energy` (summed over all MPI ranks; a collective operation).""" e = self.variables.e.spline.vector b = self.variables.b.spline.vector particles = self.variables.ions.particles @@ -599,12 +609,16 @@ def energy(self) -> float: if particles.clone_config is not None: particles.clone_config.inter_comm.Allreduce(MPI.IN_PLACE, kinetic, op=MPI.SUM) - energy = ( - 0.5 * e.inner(self._ME.dot(e, out=self._e_tmp)) - + self._c1 / 2 * b.inner(self._M2.dot(b, out=self._b_tmp)) - + self._c2 / self._c3 * float(kinetic[0]) - ) - return energy + energy_e = 0.5 * e.inner(self._ME.dot(e, out=self._e_tmp)) + energy_b = self._c1 / 2 * b.inner(self._M2.dot(b, out=self._b_tmp)) + energy_f = self._c2 / self._c3 * float(kinetic[0]) + return energy_e, energy_b, energy_f + + def energy(self) -> float: + r"""The energy :math:`H = \frac{1}{2} \mathbf e^\top \mathbb M_E \mathbf e + \frac{c_1}{2} \mathbf b^\top + \mathbb M_2 \mathbf b + \frac{c_2}{c_3} \sum_p w_p (v_{\parallel,p}^2/2 + \mu_p |B_0|(\boldsymbol \eta_p))` + conserved by the propagator (summed over all MPI ranks; a collective operation).""" + return sum(self.energies()) def _mid_point_field(self, dt: float): """Accumulate at the current markers and solve the Schur system for the mid-point field (into ``_e_mid``, @@ -631,7 +645,7 @@ def _mid_point_field(self, dt: float): return e_mid, self._solver._info def _project_onto_constraint(self, dt: float): - """For kappa = 0: replace (b, V) by the mid-point state of one step, which satisfies the constraint of the + """For lam = 0: replace (b, V) by the mid-point state of one step, which satisfies the constraint of the quasi-neutral limit exactly. The electric field is kept.""" bn = self.variables.b.spline.vector e_mid, _ = self._mid_point_field(dt) @@ -642,7 +656,7 @@ def _project_onto_constraint(self, dt: float): b_new += bn self._pusher(dt / 2) self.update_feec_variables(b=b_new) - logger.info("DriftKineticMaxwellCoupling: projected the initial state onto the constraint (kappa = 0).") + logger.info("DriftKineticMaxwellCoupling: projected the initial state onto the constraint (lam = 0).") @profile def __call__(self, dt): @@ -660,7 +674,7 @@ def __call__(self, dt): b_new *= -dt b_new += bn - # new e coeffs: Crank-Nicolson (M_E = kappa M1 + chi M1gyro is nonzero, so e carries energy also for kappa = 0) + # new e coeffs: Crank-Nicolson (M_E = lam^2 M1 + chi M1gyro is nonzero, so e carries energy also for lam = 0) e_new = e_mid.copy(out=self._e_new) e_new *= 2.0 e_new -= en diff --git a/src/struphy/propagators/push_guiding_center_bx_estar.py b/src/struphy/propagators/push_guiding_center_bx_estar.py index eb9836f51..c1a08dde9 100644 --- a/src/struphy/propagators/push_guiding_center_bx_estar.py +++ b/src/struphy/propagators/push_guiding_center_bx_estar.py @@ -85,7 +85,7 @@ def __init__( b_tilde: FEECVariable = None, e_field: FEECVariable = None, ): - """ + r""" Parameters ---------- phi : FEECVariable, default=None diff --git a/src/struphy/propagators/push_guiding_center_parallel.py b/src/struphy/propagators/push_guiding_center_parallel.py index a0892fd04..8c6472961 100644 --- a/src/struphy/propagators/push_guiding_center_parallel.py +++ b/src/struphy/propagators/push_guiding_center_parallel.py @@ -101,7 +101,7 @@ def __init__( b_tilde: FEECVariable = None, e_field: FEECVariable = None, ): - """ + r""" Parameters ---------- phi : FEECVariable, default=None diff --git a/src/struphy/propagators/tests/test_drift_kinetic_maxwell_coupling.py b/src/struphy/propagators/tests/test_drift_kinetic_maxwell_coupling.py index 50eca67d7..1f0db84dc 100644 --- a/src/struphy/propagators/tests/test_drift_kinetic_maxwell_coupling.py +++ b/src/struphy/propagators/tests/test_drift_kinetic_maxwell_coupling.py @@ -9,8 +9,8 @@ def _model(control_variate=False, domain=None, **options): - """Allocated DriftKineticMaxwell model (slab with a tilted homogeneous B0, full-f guiding centers with alpha=1, - epsilon=0.5, a parallel flow and a B perturbation) with the given options of its coupling propagator.""" + """Allocated DriftKineticMaxwell model (slab with a tilted homogeneous B0, full-f guiding centers with epsilon=0.5, + lam=2, kappa=2, a parallel flow and a B perturbation) with the given options of its coupling propagator.""" from struphy import ( DerhamOptions, LoadingParameters, @@ -25,7 +25,7 @@ def _model(control_variate=False, domain=None, **options): ) from struphy.linear_algebra.solver import SolverParameters - model = models.DriftKineticMaxwell(alpha=1.0, epsilon=0.5) + model = models.DriftKineticMaxwell(epsilon=0.5, lam=2.0, kappa=2.0) model.kinetic_ions.set_markers( loading_params=LoadingParameters(ppc=20, seed=1234), weights_params=WeightsParameters(control_variate=control_variate), @@ -76,12 +76,12 @@ def _random_v1(derham, seed): @pytest.mark.parametrize("matrix_free", [False, True]) @pytest.mark.parametrize("exb_current", [True, False]) -@pytest.mark.parametrize("kappa", [1.0, 0.0]) -def test_energy_conservation(kappa, exb_current, matrix_free): +@pytest.mark.parametrize("lam", [1.0, 0.0]) +def test_energy_conservation(lam, exb_current, matrix_free): """The coupling step conserves its energy H exactly (up to the solver tolerance), also for a large time step and - in the quasi-neutral limit kappa=0 (without the initial projection, which changes H once).""" + in the quasi-neutral limit lam=0 (without the initial projection, which changes H once).""" solver = {} if exb_current else {"solver": "pcg"} - model = _model(kappa=kappa, exb_current=exb_current, matrix_free=matrix_free, initial_projection=False, **solver) + model = _model(lam=lam, exb_current=exb_current, matrix_free=matrix_free, initial_projection=False, **solver) prop = model.propagators.coupling b = model.em_fields.b_field.spline.vector energy_0 = prop.energy() @@ -108,12 +108,12 @@ def test_energy_conservation_mapped(): assert abs(prop.energy() - energy_0) < 1e-11 * energy_0 -@pytest.mark.parametrize("kappa", [1.0, 0.0]) -def test_matrix_free_agrees(kappa): +@pytest.mark.parametrize("lam", [1.0, 0.0]) +def test_matrix_free_agrees(lam): """The matrix-free particle matrices give the same step as the assembled ones (with the E x B current).""" states = [] for matrix_free in (False, True): - model = _model(kappa=kappa, matrix_free=matrix_free, initial_projection=False) + model = _model(lam=lam, matrix_free=matrix_free, initial_projection=False) model.propagators.coupling(0.5) # the two simulations have different Derham objects: compare the arrays e, b, v = _state(model) @@ -140,13 +140,13 @@ def test_particle_matrices(): @pytest.mark.parametrize("matrix_free", [False, True]) -@pytest.mark.parametrize("kappa", [1.0, 0.0]) -def test_discrete_equations(kappa, matrix_free): +@pytest.mark.parametrize("lam", [1.0, 0.0]) +def test_discrete_equations(lam, matrix_free): """Without the E x B current, the new state satisfies the Crank-Nicolson equations: Ampère's law M_E (e^{n+1} - e^n) / dt = c1 C^T M2 b_mid - c2 j(V_mid) (j linear in v_par at fixed positions and fixed a_p = B*/B*_par, which depends on v_par only through the curvature, zero here) and Faraday's law.""" dt = 0.5 - model = _model(kappa=kappa, matrix_free=matrix_free, exb_current=False, solver="pcg", initial_projection=False) + model = _model(lam=lam, matrix_free=matrix_free, exb_current=False, solver="pcg", initial_projection=False) prop = model.propagators.coupling e0, b0, _ = _state(model) prop._accumulate() @@ -168,11 +168,11 @@ def test_discrete_equations(kappa, matrix_free): def test_initial_projection(): - """For kappa=0, without the initial projection the magnetic energy oscillates with period 2 (the constraint + """For lam=0, without the initial projection the magnetic energy oscillates with period 2 (the constraint residual changes sign in every step); the projection removes this.""" def en_b_series(projection): - model = _model(kappa=0.0, initial_projection=projection) + model = _model(lam=0.0, initial_projection=projection) prop = model.propagators.coupling b = model.em_fields.b_field.spline.vector series = [] @@ -209,12 +209,12 @@ def test_invalid_options(): Options = DriftKineticMaxwellCoupling.Options with pytest.raises(AssertionError): - Options(kappa=-1.0) + Options(lam=-1.0) with pytest.raises(AssertionError): Options(chi=-1.0) with pytest.raises(AssertionError): # no displacement and no polarization current: the perpendicular field is undetermined - Options(kappa=0.0, chi=0.0) + Options(lam=0.0, chi=0.0) with pytest.raises(AssertionError): Options(c1=0.0) with pytest.raises(AssertionError): @@ -225,7 +225,81 @@ def test_invalid_options(): Options(solver="pcg", exb_current=False) -def test_control_variate_needs_kappa(): - """kappa=0 needs full-f weights (the particle matrix is indefinite with the control variate).""" +def test_control_variate_needs_lam(): + """lam=0 needs full-f weights (the particle matrix is indefinite with the control variate).""" with pytest.raises(AssertionError): - _model(kappa=0.0, control_variate=True) + _model(lam=0.0, control_variate=True) + + +def _cold_model(push_para_e_field=False): + """DriftKineticMaxwell with cold guiding centers (mu ~ 0), uniform B0 and no B perturbation (no mirror force), and + a large electric field with a parallel component. Optionally pushes with the electric field also in push_para + (forbidden in the model).""" + from struphy import DerhamOptions, LoadingParameters, Simulation, domains, equils, grids, maxwellians, models + from struphy.propagators.push_guiding_center_parallel import PushGuidingCenterParallel + + vth = 1e-6 + model = models.DriftKineticMaxwell(epsilon=0.5, lam=2.0, kappa=2.0) + model.kinetic_ions.set_markers( + loading_params=LoadingParameters(ppc=5, seed=1, moments=(0.0, 0.0, 1.0, vth), B0=1.0) + ) + model.kinetic_ions.var.add_background( + maxwellians.GyroMaxwellian2D(n=(1.0, None), vth_para=(1.0, None), vth_perp=(vth, None), B0=1.0) + ) + if push_para_e_field: + model.propagators.push_para = PushGuidingCenterParallel( + b_tilde=model.em_fields.b_field, e_field=model.em_fields.e_field + ) + model.propagators.push_para.variables.ions = model.kinetic_ions.var + Simulation( + model=model, + domain=domains.Cuboid(r1=2.0, r2=2.0, r3=2.0), + equil=equils.HomogenSlab(B0x=0.3, B0z=1.0), + grid=grids.TensorProductGrid(num_elements=(4, 4, 4)), + derham_opts=DerhamOptions(degree=(2, 2, 2)), + ).allocate() + e = model.em_fields.e_field.spline.vector + for blk in e.blocks: + blk._data[:] = 1.0 + e.update_ghost_regions() + return model + + +def _all_vpar(particles): + """Sorted parallel velocities of all valid markers on all processes (markers may change their process during a + push, so they are compared as a set).""" + from maybempi import MPI + + local = particles.markers[3, particles.valid_mks].copy() + comm = MPI.COMM_WORLD + if comm.Get_size() > 1: + local = xp.concatenate(comm.allgather(local)) + return xp.sort(local) + + +@pytest.mark.parametrize("evaluate_e_field", [False, True]) +@pytest.mark.parametrize("algo", ["discrete_gradient_1st_order", "explicit"]) +def test_parallel_force_applied_once(algo, evaluate_e_field): + """The parallel electric force b* . E / epsilon is applied only by the coupling step: push_para of the model + leaves v_par unchanged when E != 0 (cold markers, no mirror force), for every algorithm. An electric field passed + to push_para in the model is rejected.""" + model = _cold_model() + prop = model.propagators.push_para + prop.options = prop.Options(algo=algo, evaluate_e_field=evaluate_e_field) + prop.allocate() + particles = model.kinetic_ions.var.particles + v_0 = _all_vpar(particles) + prop(0.1) + assert xp.max(xp.abs(_all_vpar(particles) - v_0)) == 0.0 + + # the same push with the electric field changes v_par (by about dt / epsilon * b* . E) + model.propagators.push_para.e_field = model.em_fields.e_field + prop.options = prop.Options(algo="explicit") + prop.allocate() + prop(0.1) + assert xp.max(xp.abs(_all_vpar(particles) - v_0)) > 1e-2 + + +def test_parallel_force_in_push_para_rejected(): + with pytest.raises(AssertionError): + _cold_model(push_para_e_field=True)