Source code for struphy.models.variables

# for type checking (cyclic imports)
from __future__ import annotations

import inspect
import logging
from abc import ABCMeta, abstractmethod
from typing import TYPE_CHECKING

import cunumpy as xp
from feectools.ddm.mpi import mpi as MPI

from struphy.feec.linear_operators import BoundaryOperator
from struphy.feec.psydac_derham import Derham, SplineFunction
from struphy.fields_background.base import FluidEquilibrium
from struphy.fields_background.projected_equils import ProjectedFluidEquilibrium
from struphy.geometry.base import Domain
from struphy.geometry.utilities import TransformedPformComponent
from struphy.initial.perturbations import Perturbation
from struphy.io.options import FieldsBackground, LiteralOptions
from struphy.kinetic_background.base import KineticBackground
from struphy.pic import particles
from struphy.pic.base import Particles
from struphy.pic.particles import ParticlesSPH
from struphy.utils.clone_config import CloneConfig
from struphy.utils.utils import check_option

if TYPE_CHECKING:
    from struphy.models.species import FieldSpecies, FluidSpecies, ParticleSpecies, Species

logger = logging.getLogger("struphy")


class Variable(metaclass=ABCMeta):
    """
    Abstract base class for a single variable of a Species object.

    Variables represent the fundamental degrees of freedom in a plasma simulation. The complete solution
    of a StruphyModel is a collection of Variables organized within Species. Multiple Variables can be
    combined within a Species to represent different physical quantities (e.g., density, velocity, temperature).

    Attributes
    ----------
    space : str
        The function space or discretization type of the variable (e.g., 'H1' for finite elements,
        'Particles6D' for PIC, 'ParticlesSPH' for SPH). Must be implemented by subclasses.
    backgrounds : FluidEquilibrium | KineticBackground | None
        Static background(s) representing equilibrium or reference state. Multiple backgrounds can be added
        and are combined by addition. Defaults to None.
    perturbations : Perturbation | list[Perturbation] | None
        Initial perturbations added to backgrounds to define initial conditions.
        Multiple perturbations can be added and are combined by addition. Defaults to None.
    save_data : bool
        Flag indicating whether this variable's data should be saved during simulation (default=True).
    species : Species
        Reference to the parent Species object containing this variable.

    Methods
    -------
    allocate()
        Allocate memory and initialize data structures. Must be implemented by subclasses.
    add_background(background)
        Add a static background condition to the variable.
    show_backgrounds()
        Display information about defined backgrounds.
    show_perturbations()
        Display information about defined perturbations.

    Notes
    -----
    The initial condition for a variable is constructed as: background(s) + perturbation(s).
    If neither is specified, the variable initializes to zero.

    All concrete implementations (FEECVariable, PICVariable, SPHVariable) must define the `space` property
    and implement the `allocate()` method.
    """

    @property
    @abstractmethod
    def space(self):
        """The function space of the variable, e.g. 'H1' for finite element variables or 'Particles6D' for PIC variables."""
        pass

    @abstractmethod
    def allocate(self):
        """Alocate object and memory for variable."""

    def __repr__(self):
        return f"{self.__class__.__name__} ({self.space})"

    @property
    def backgrounds(self):
        """The static background. Multiple backgrounds can be defined in a list,
        via the add_background method."""
        if not hasattr(self, "_backgrounds"):
            self._backgrounds = None
        return self._backgrounds

    @property
    def perturbations(self):
        """The perturbations. Multiple perturbations can be defined in a list,
        via the add_perturbation method defined in sub-classes."""
        if not hasattr(self, "_perturbations"):
            self._perturbations = None
        return self._perturbations

    @property
    def save_data(self):
        """Store variable data during simulation (default=True)."""
        if not hasattr(self, "_save_data"):
            self._save_data = True
        return self._save_data

    @save_data.setter
    def save_data(self, new):
        assert isinstance(new, bool)
        self._save_data = new

    @property
    def species(self) -> Species:
        if not hasattr(self, "_species"):
            self._species = None
        return self._species

    @property
    def __name__(self):
        if not hasattr(self, "_name"):
            self._name = None
        return self._name

    def add_background(self, background):
        """Add a static background for this variable.
        Multiple backgrounds can be added up."""
        if not hasattr(self, "_backgrounds") or self.backgrounds is None:
            self._backgrounds = background
        else:
            if not isinstance(self.backgrounds, list):
                self._backgrounds = [self.backgrounds]
            self._backgrounds += [background]
        logger.info(
            f"\nAdded background\n{background}\nto variable '{self.__name__}' of species '{self.species.__class__.__name__}'."
        )

    def show_backgrounds(self):
        print(f"\nBackgrounds for variable '{self.__name__}' of species '{self.species.__class__.__name__}':")
        if self.backgrounds is not None:
            if isinstance(self.backgrounds, list):
                for background in self.backgrounds:
                    print(background)
            else:
                print(self.backgrounds)
        else:
            print("None.")

    def show_perturbations(self):
        print(f"\nPerturbations for variable '{self.__name__}' of species '{self.species.__class__.__name__}':")
        if self.perturbations is not None:
            if isinstance(self.perturbations, list):
                for perturbation in self.perturbations:
                    print(perturbation)
            else:
                print(self.perturbations)
        else:
            print("None.")


[docs] class FEECVariable(Variable): """ Grid-based finite element variable using FEEC (Finite Element Exterior Calculus) discretization. FEECVariable represents field quantities discretized on a computational grid using finite element methods. It supports arbitrary FEEC spaces (H1, Hdiv, Hcurl, L2) and is used for electromagnetic fields, fluid moments, or other spatially distributed quantities. Parameters ---------- space : str The FEEC function space. Options: 'H1', 'HDiv', 'HCurl', 'L2'. Determines the continuity and smoothness properties of the discretization. Attributes ---------- space : str The FEEC function space. Options: 'H1', 'HDiv', 'HCurl', 'L2'. Determines the continuity and smoothness properties of the discretization. spline : SplineFunction The underlying spline-based representation of the field on the computational mesh. Only available after calling `allocate()`. species : FieldSpecies | FluidSpecies Parent species containing this variable. backgrounds : FieldsBackground | None Equilibrium fields added to initial conditions. perturbations : Perturbation | list[Perturbation] | None Initial perturbations added to backgrounds. Methods ------- add_background(background) Add an equilibrium field background. add_perturbation(perturbation) Add initial perturbations to the field. allocate(derham, domain, equil) Allocate spline function and initialize on the mesh. Notes ----- Initial conditions are constructed by combining backgrounds and perturbations: initial_field = background(s) + perturbation(s) If neither background nor perturbation is specified, the variable initializes to zero. Examples -------- >>> E_field = FEECVariable(space='Hdiv') # For electric field >>> E_field.add_background(FieldsBackground(type='uniform', magnitude=1.0)) """ def __init__(self, space: LiteralOptions.OptsFEECSpace = "H1"): check_option(space, LiteralOptions.OptsFEECSpace) self._space = space @property def space(self) -> str: return self._space @property def lifting_function(self) -> Perturbation | None: """The lifting function for the case of lifting of boundary conditions. Its values at the boundary determine the inhomogeneous boundary conditions. If None, no lifting is applied.""" if not hasattr(self, "_lifting_function"): self._lifting_function = None return self._lifting_function @lifting_function.setter def lifting_function(self, new: Perturbation | None): self._lifting_function = new @property def spline(self) -> SplineFunction: if not hasattr(self, "_spline"): raise ValueError("Warning: spline not allocated yet. Call allocate() first.") return self._spline @property def spline_lift(self) -> SplineFunction | None: """The lifting function for the case of lifting of boundary conditions. Only allocated if lifting_function is not None.""" if not hasattr(self, "_spline_lift"): self._spline_lift = None return self._spline_lift @property def spline_0(self) -> SplineFunction | None: """The spline function with zero boundary conditions, used for the lifting of boundary conditions. Only allocated if lifting_function is not None.""" if not hasattr(self, "_spline_0"): self._spline_0 = None return self._spline_0 @property def boundary_spline(self) -> SplineFunction | None: """The spline function representing the boundary conditions, used for the lifting of boundary conditions. Only allocated if lifting_function is not None.""" if not hasattr(self, "_boundary_spline"): self._boundary_spline = None return self._boundary_spline @property def boundary_op(self) -> BoundaryOperator | None: """The boundary operator, used for the lifting of boundary conditions. Only allocated if lifting_function is not None.""" if not hasattr(self, "_boundary_op"): self._boundary_op = None return self._boundary_op @property def derham_lift(self) -> Derham | None: """The Derham object for the lifting function. Only allocated if lifting_function is not None.""" if not hasattr(self, "_derham_lift"): self._derham_lift = None return self._derham_lift @property def species(self) -> FieldSpecies | FluidSpecies: if not hasattr(self, "_species"): self._species = None return self._species
[docs] def add_background(self, background: FieldsBackground): super().add_background(background)
[docs] def add_perturbation(self, perturbation: Perturbation): """Add an initial :class:`~struphy.initial.base.Perturbation` for this variable. Multiple perturbations can be added up.""" if not hasattr(self, "_perturbations") or self.perturbations is None: self._perturbations = perturbation else: if not isinstance(self.perturbations, list): self._perturbations = [self.perturbations] self._perturbations += [perturbation] logger.info( f"\nAdded perturbation\n{perturbation}\nto variable '{self.__name__}' of species '{self.species.__class__.__name__}'." )
[docs] def allocate( self, derham: Derham, domain: Domain = None, equil: FluidEquilibrium = None, ): self._spline = derham.create_spline_function( name=self.__name__, space_id=self.space, backgrounds=self.backgrounds, perturbations=self.perturbations, domain=domain, equil=equil, ) self._derham_lift = None self._spline_lift = None if self.lifting_function is not None: check_bcs = False for bc in derham.bcs: if "dirichlet" in bc: check_bcs = True break assert check_bcs, ( f"Lifting of boundary conditions can only be applied if at least one homogenous Dirichlet boundary condition is present in the Derham object, but here {derham.bcs = }" ) # create another Derham object with the same options but with homogenous Dirichlet BCs replaced by free BCs, to be used for the lifting function dct = derham.to_dict() bcs_lift = list(dct["options"]["bcs"]) for i, bc in enumerate(bcs_lift): if bc is not None: bcn = list(bc) # convert tuple to list to allow modification if bcn[0] == "dirichlet": bcn[0] = "free" if bcn[1] == "dirichlet": bcn[1] = "free" bcn = tuple(bcn) # convert back to tuple bcs_lift[i] = bcn dct["options"]["bcs"] = tuple(bcs_lift) # convert list back to tuple self._derham_lift = Derham.from_dict(dct, comm=derham.comm) # spline function for the lifting function self._spline_lift = self.derham_lift.create_spline_function( name=self.__name__ + "_lift" if self.__name__ is not None else None, space_id=self.space, domain=domain, equil=equil, ) # project lifting function to spline space ptb = self.lifting_function if self.space in { "H1", "L2", }: # TODO: this is a copy-paste from SplineFunction.initialize_coeffs(), to be unified if ptb.given_in_basis is None: ptb.given_in_basis = "0" fun = TransformedPformComponent( ptb, ptb.given_in_basis, derham.space_to_form[self.space], domain=domain, ) elif self.space in {"Hcurl", "Hdiv", "H1vec"}: fun_vec = [None] * 3 fun_vec[ptb.comp] = ptb if ptb.given_in_basis is None: ptb.given_in_basis = "v" # pullback callable for each component fun = [] for comp in range(3): fun += [ TransformedPformComponent( fun_vec, ptb.given_in_basis, derham.space_to_form[self.space], comp=comp, domain=domain, ), ] # peform projection self.spline_lift.vector += self.derham_lift.projectors[derham.space_to_form[self.space]](fun) # other helper objects for the lifting of boundary conditions self._spline_0 = self.spline_lift.copy() self.spline_0.vector[:] = self.spline_lift.vector[:] self._boundary_spline = self.spline_lift.copy() self._boundary_op = BoundaryOperator(self.spline_lift.space, self.space, derham.dirichlet_bc) self.compute_boundary_spline()
def compute_boundary_spline(self, spline_lift: SplineFunction | None = None): """Compute boundary_spline = spline_lift - spline_0. If spline_lift is None, uses self.spline_lift from the initial condition. This method can be used to update the boundary spline during the simulation if the lifting function changes in time.""" # update spline_0 if spline_lift is None: spline_lift = self.spline_lift self.boundary_op.dot(spline_lift.vector, out=self.spline_0.vector) # set new boundary spline diff_vec = spline_lift.vector - self.spline_0.vector self.boundary_spline.vector[:] = diff_vec[:]
[docs] class PICVariable(Variable): """ Kinetic variable using Particle-In-Cell (PIC) discretization in phase space. PICVariable represents kinetic distribution functions discretized via marker/particle methods in 3D configuration space plus velocity space (3D+2D or 3D+3D). Supports various phase-space decompositions for efficient kinetic simulations. Attributes ---------- space : str Phase space decomposition. Options include, among others: - 'Particles6D': Full 3D config + 3D velocity space - 'Particles5D': 3D config + 2D gyro-velocity space (for strong magnetic fields) particles : Particles The marker/particle object managing kinetic data. Only available after `allocate()`. particles_class : type Reference to the Particles class corresponding to the space. species : ParticleSpecies Parent kinetic species containing this variable. backgrounds : KineticBackground Mandatory equilibrium distribution function (e.g., Maxwellian). initial_condition : KineticBackground The initial kinetic distribution. If not explicitly set, uses the background. perturbations : Perturbation | list[Perturbation] | None Perturbations to moments of the distribution function. n_as_volume_form : bool Whether number density is represented as a differential form (volume-weighted) or scalar. n_to_save : int Number of markers for which trajectories are saved. saved_markers : ndarray Array storing saved marker data. Methods ------- add_background(background, n_as_volume_form) Set mandatory equilibrium distribution (e.g., Maxwellian). add_initial_condition(init) Set initial kinetic distribution (must be consistent with background). show_initial_condition() Display current initial condition information. allocate(clone_config, derham, domain, equil, projected_equil) Initialize particles and allocate marker arrays. Notes ----- The background is mandatory and often used for noise reduction. The initial condition should be consistent with the background to avoid numerical artifacts. If no initial condition is specified, the background is used as the initial condition. If both are specified, they should be the same base distribution with perturbations on top. Examples -------- >>> electrons = PICVariable(space='Particles6D') >>> maxwellian = Maxwellian3D(n=(1.0, None), vth2=(0.1, None)) >>> electrons.add_background(maxwellian) >>> pert = TorusModesCos(amps=(0.1,)) >>> electrons.add_perturbation(pert) """ def __init__(self, space: LiteralOptions.OptsPICSpace = "Particles6D"): check_option(space, LiteralOptions.OptsPICSpace) self._space = space for name, cls in inspect.getmembers(particles): if inspect.isclass(cls) and cls.__module__ == particles.__name__ and name == space: self._particles_class = cls @property def space(self) -> LiteralOptions.OptsPICSpace: return self._space @property def particles_class(self) -> Particles: return self._particles_class @property def particles(self) -> Particles: if not hasattr(self, "_particles"): raise ValueError("Warning: particles not allocated yet. Call allocate() first.") return self._particles @property def species(self) -> ParticleSpecies: if not hasattr(self, "_species"): self._species = None return self._species @property def n_as_volume_form(self) -> bool: """Whether the number density n is given as a volume form or a scalar function (=default).""" if not hasattr(self, "_n_as_volume_form"): self._n_as_volume_form = False return self._n_as_volume_form
[docs] def add_background(self, background: KineticBackground, n_as_volume_form: bool = False): self._n_as_volume_form = n_as_volume_form super().add_background(background)
[docs] def add_initial_condition(self, init: KineticBackground): """The initial condition must be consistent with the background.""" self._initial_condition = init logger.info( f"\nAdded initial condition\n{init}\nto variable '{self.__name__}' of species '{self.species.__class__.__name__}'." )
[docs] def show_initial_condition(self): print(f"\nInitial condition for variable '{self.__name__}' of species '{self.species.__class__.__name__}':") if self.initial_condition is not None: print(self.initial_condition) else: print("Same as background.")
@property def initial_condition(self) -> KineticBackground: if not hasattr(self, "_initial_condition"): self._initial_condition = self.backgrounds return self._initial_condition
[docs] def allocate( self, clone_config: CloneConfig = None, derham: Derham = None, domain: Domain = None, equil: FluidEquilibrium = None, projected_equil: ProjectedFluidEquilibrium = None, ): # assert isinstance(self.species, KineticSpecies) assert isinstance(self.backgrounds, KineticBackground), ( "List input not allowed, you can sum Kineticbackgrounds before passing them to add_background." ) if derham is None: domain_decomp = None else: domain_array = derham.domain_array nprocs = derham.domain_decomposition.nprocs domain_decomp = (domain_array, nprocs) kinetic_class = getattr(particles, self.space) comm_world = MPI.COMM_WORLD if comm_world.Get_size() == 1: comm_world = None self._particles: Particles = kinetic_class( comm_world=comm_world, clone_config=clone_config, domain_decomp=domain_decomp, name=self.species.__class__.__name__, loading_params=self.species.loading_params, weights_params=self.species.weights_params, boundary_params=self.species.boundary_params, sorting_params=self.species.sorting_params, bufsize=self.species.bufsize, domain=domain, equil=equil, projected_equil=projected_equil, background=self.backgrounds, initial_condition=self.initial_condition, n_as_volume_form=self.n_as_volume_form, equation_params=self.species.equation_params, ) if self.species.sorting_params.do_sort: sort = True else: sort = False self.particles.draw_markers(sort=sort) # set zero velocity according to loading_params zero_index = xp.nonzero(self.particles.loading_params.set_zero_velocity)[0].flatten() self.particles.set_velocities_comp(velocity=0.0, comp=zero_index) self.particles.initialize_weights() # allocate array for saving markers if not present n_markers = self.species.saving_params.n_markers if isinstance(n_markers, float): if n_markers > 1.0: self._n_to_save = int(n_markers) else: self._n_to_save = int(self.particles.n_mks_global * n_markers) else: self._n_to_save = n_markers assert self._n_to_save <= self.particles.Np, ( f"The number of markers for which data should be stored (={self._n_to_save}) must be <= than the total number of markers (={self.particles.Np})" ) if self._n_to_save > 0: self._saved_markers = xp.zeros( (self._n_to_save, self.particles.markers.shape[1]), dtype=float, )
# other data (wave-particle power exchange, etc.) # TODO @property def n_to_save(self) -> int: return self._n_to_save @property def saved_markers(self) -> xp.ndarray: return self._saved_markers
[docs] class SPHVariable(Variable): """ Fluid variable using Smoothed Particle Hydrodynamics (SPH) discretization. SPHVariable represents macroscopic fluid quantities (density, velocity, temperature) using SPH methods, where fields are reconstructed from marker positions and properties. This is a particle-based Lagrangian approach to fluid dynamics. Attributes ---------- space : str Always 'ParticlesSPH' for SPH method. particles : ParticlesSPH The SPH marker/particle object. Only available after `allocate()`. particles_class : type Reference to the ParticlesSPH class. particle_data : dict Dictionary storing particle-associated data fields. species : ParticleSpecies Parent SPH species containing this variable. backgrounds : FluidEquilibrium Background fluid state (density, velocity, pressure profiles). perturbations : dict[str, Perturbation] Perturbations to density ('n') and velocity components ('u1', 'u2', 'u3'). Each component can have independent perturbations or None. n_as_volume_form : bool Always True for SPH; number density represented as volume-weighted quantity. n_to_save : int Number of SPH particles for which data is saved. saved_markers : ndarray Array storing saved particle data. Methods ------- add_background(background) Set the background fluid equilibrium state. add_perturbation(del_n, del_u1, del_u2, del_u3) Add perturbations to density and/or velocity components. show_perturbations() Display detailed information about density and velocity perturbations. allocate(derham, domain, equil, projected_equil) Initialize SPH particles and allocate marker arrays. Notes ----- Initial conditions combine background and perturbations: - density: background + del_n - velocity: background + (del_u1, del_u2, del_u3) If neither background nor perturbations are specified, fields initialize to zero. Examples -------- >>> fluid = SPHVariable() >>> equil = HomogenSlab() >>> fluid.add_background(equil) >>> pert = TorusModesCos(amps=(0.1,)) >>> fluid.add_perturbation(del_n=pert, del_u1=pert) """ def __init__(self): self._space = "ParticlesSPH" self._n_as_volume_form = True self._particle_data = {} @property def space(self): return self._space @property def particles_class(self) -> Particles: return ParticlesSPH @property def particles(self) -> ParticlesSPH: if not hasattr(self, "_particles"): raise ValueError("Warning: particles not allocated yet. Call allocate() first.") return self._particles @property def particle_data(self): return self._particle_data @property def species(self) -> ParticleSpecies: if not hasattr(self, "_species"): self._species = None return self._species @property def n_as_volume_form(self) -> bool: """Whether the number density n is given as a volume form or scalar function (=default).""" return self._n_as_volume_form
[docs] def add_background(self, background: FluidEquilibrium): super().add_background(background)
[docs] def add_perturbation( self, del_n: Perturbation = None, del_u1: Perturbation = None, del_u2: Perturbation = None, del_u3: Perturbation = None, ): """Add an initial :class:`~struphy.initial.base.Perturbation` for the fluid density and/or velocity.""" self._perturbations = {} self._perturbations["n"] = del_n self._perturbations["u1"] = del_u1 self._perturbations["u2"] = del_u2 self._perturbations["u3"] = del_u3 if del_n is not None: logger.info( f"\nAdded density perturbation\n{del_n}\nto variable '{self.__name__}' of species '{self.species.__class__.__name__}'." ) if del_u1 is not None: logger.info( f"\nAdded velocity component u1 perturbation\n{del_u1}\nto variable '{self.__name__}' of species '{self.species.__class__.__name__}'." ) if del_u2 is not None: logger.info( f"\nAdded velocity component u2 perturbation\n{del_u2}\nto variable '{self.__name__}' of species '{self.species.__class__.__name__}'." ) if del_u3 is not None: logger.info( f"\nAdded velocity component u3 perturbation\n{del_u3}\nto variable '{self.__name__}' of species '{self.species.__class__.__name__}'." )
[docs] def show_perturbations(self): print(f"Perturbations for variable '{self.__name__}' of species '{self.species.__class__.__name__}':") if self.perturbations is not None: for key, perturbation in self.perturbations.items(): print(perturbation) else: print("None.")
@property def perturbations(self) -> dict[str, Perturbation]: if not hasattr(self, "_perturbations"): self._perturbations = None return self._perturbations
[docs] def allocate( self, derham: Derham = None, domain: Domain = None, equil: FluidEquilibrium = None, projected_equil: ProjectedFluidEquilibrium = None, ): assert isinstance(self.backgrounds, FluidEquilibrium), ( "List input not allowed, you can sum Kineticbackgrounds before passing them to add_background." ) self.backgrounds.domain = domain if derham is None: domain_decomp = None else: domain_array = derham.domain_array nprocs = derham.domain_decomposition.nprocs domain_decomp = (domain_array, nprocs) comm_world = MPI.COMM_WORLD if comm_world.Get_size() == 1: comm_world = None self._particles = ParticlesSPH( comm_world=comm_world, domain_decomp=domain_decomp, name=self.species.__class__.__name__, loading_params=self.species.loading_params, weights_params=self.species.weights_params, boundary_params=self.species.boundary_params, sorting_params=self.species.sorting_params, bufsize=self.species.bufsize, domain=domain, equil=equil, projected_equil=projected_equil, background=self.backgrounds, n_as_volume_form=self.n_as_volume_form, perturbations=self.perturbations, equation_params=self.species.equation_params, ) if self.species.sorting_params.do_sort: sort = True else: sort = False self.particles.draw_markers(sort=sort) self.particles.initialize_weights() # if self.particles.sorting_boxes.communicate: # self.particles.put_particles_in_boxes() # allocate array for saving markers if not present n_markers = self.species.saving_params.n_markers if isinstance(n_markers, float): if n_markers > 1.0: self._n_to_save = int(n_markers) else: self._n_to_save = int(self.particles.n_mks_global * n_markers) else: self._n_to_save = n_markers assert self._n_to_save <= self.particles.Np, ( f"The number of markers for which data should be stored (={self._n_to_save}) must be <= than the total number of markers (={self.particles.Np})" ) if self._n_to_save > 0: self._saved_markers = xp.zeros( (self._n_to_save, self.particles.markers.shape[1]), dtype=float, )
# other data (wave-particle power exchange, etc.) # TODO @property def n_to_save(self) -> int: return self._n_to_save @property def saved_markers(self) -> xp.ndarray: return self._saved_markers