import logging
import warnings
from abc import ABCMeta, abstractmethod
import cunumpy as xp
from feectools.ddm.mpi import mpi as MPI
from struphy.models.variables import Variable
from struphy.particles.parameters import (
BinningPlot,
BoundaryParameters,
KernelDensityPlot,
LoadingParameters,
SavingParameters,
SortingParameters,
WeightsParameters,
)
from struphy.physics.physics import ConstantsOfNature, Units
logger = logging.getLogger("struphy")
class Species(metaclass=ABCMeta):
"""
Abstract base class representing a single plasma species in a StruphyModel.
This class manages variables, charge/mass properties, and equation parameters for a plasma species.
Concrete implementations include FieldSpecies, FluidSpecies, ParticleSpecies, and DiagnosticSpecies.
Attributes
----------
variables : dict
Dictionary of Variable objects associated with this species, keyed by variable name.
Variables are automatically discovered during initialization.
charge_number : int
Charge number in units of elementary charge (default = 1).
For field species, this is set to 0.
mass_number : int
Mass number in units of proton mass (default = 1).
For field species, this is set to 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.
kappa : float, optional
Normalized plasma frequency: plasma frequency × time unit.
If None, computed from units and charge/mass numbers.
equation_params : EquationParameters
Object containing normalization parameters (alpha, epsilon, kappa) for scaled equations.
Methods
-------
init_variables()
Discover and cache Variable objects from instance attributes.
setup_equation_params(units)
Compute equation normalization parameters from physical units.
Notes
-----
All Species subclasses must implement __init__() and call init_variables() to properly
set up the variables dictionary.
"""
@abstractmethod
def __init__(self):
self.init_variables()
def __repr__(self):
out = ""
for k, v in self.variables.items():
out += f" {k}:".ljust(20)
out += f"{v}\n"
return out
def init_variables(
self,
charge_number: int = 1,
mass_number: int = 1,
alpha: float = None,
epsilon: float = None,
kappa: 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.
Parameters
----------
charge_number : int, optional
Charge number in units of elementary charge (default = 1).
mass_number : int, optional
Mass number in units of proton mass (default = 1).
alpha : float, optional
Dimensionless parameter: plasma frequency / cyclotron frequency.
If None, computed from units and charge/mass numbers (default = None).
epsilon : float, optional
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.
If None, computed from units and charge/mass numbers (default = None)."""
# create variables dict
self._variables = {}
for k, v in self.__dict__.items():
if isinstance(v, Variable):
v._name = k
v._species = self
self._variables[k] = v
# set species properties
self._charge_number = charge_number
self._mass_number = mass_number
self._alpha = alpha
self._epsilon = epsilon
self._kappa = kappa
@property
def variables(self) -> dict:
return self._variables
@property
def charge_number(self) -> int:
"""Charge number in units of elementary charge (default = 1)."""
if not hasattr(self, "_charge_number"):
self._charge_number = 1
return self._charge_number
@property
def mass_number(self) -> int:
"""Mass number in units of proton mass (default = 1)."""
if not hasattr(self, "_mass_number"):
self._mass_number = 1
return self._mass_number
@property
def alpha(self) -> float:
"""The ratio of plasma frequency to cyclotron frequency, Omega_p / Omega_c (default = None)."""
if not hasattr(self, "_alpha"):
self._alpha = None
return self._alpha
@property
def epsilon(self) -> float:
"""The normalized cyclotron period, 1/(Omega_c * time_unit), default = None."""
if not hasattr(self, "_epsilon"):
self._epsilon = None
return self._epsilon
@property
def kappa(self) -> float:
"""The normalized plasma frequency, Omega_p * time_unit (default = None)."""
if not hasattr(self, "_kappa"):
self._kappa = None
return self._kappa
class EquationParameters:
"""Normalization parameters of one species, appearing in scaled equations."""
def __init__(
self,
species,
units: Units = None,
alpha: float = None,
epsilon: float = None,
kappa: float = None,
):
self.species = species
if units is None:
units = Units()
Z = species.charge_number
A = species.mass_number
con = ConstantsOfNature()
# relevant frequencies
om_p = xp.sqrt(units.n * (Z * con.e) ** 2 / (con.eps0 * A * con.mH))
om_c = Z * con.e * units.B / (A * con.mH)
# compute equation parameters
if alpha is None:
self.alpha = om_p / om_c
else:
self.alpha = alpha
if MPI.COMM_WORLD.Get_rank() == 0:
warnings.warn(f"Override equation parameter {self.alpha =}")
if epsilon is None:
self.epsilon = 1.0 / (om_c * units.t)
else:
self.epsilon = epsilon
if MPI.COMM_WORLD.Get_rank() == 0:
warnings.warn(f"Override equation parameter {self.epsilon =}")
if kappa is None:
self.kappa = om_p * units.t
else:
self.kappa = kappa
if MPI.COMM_WORLD.Get_rank() == 0:
warnings.warn(f"Override equation parameter {self.kappa =}")
def show(self):
print(f"\nEquation parameters for species {self.species.__class__.__name__}:")
for key, val in self.__dict__.items():
if key != "species":
print(f"{(key + ':').ljust(25)} {val:4.3e}")
@property
def equation_params(self) -> EquationParameters:
return self._equation_params
def setup_equation_params(self, units: Units):
"""Set the following equation parameters:
* alpha = plasma-frequenca / cyclotron frequency
* epsilon = 1 / (cyclotron frequency * time unit)
* kappa = plasma frequency * time unit
"""
self._equation_params = self.EquationParameters(
species=self,
units=units,
alpha=self.alpha,
epsilon=self.epsilon,
kappa=self.kappa,
)
[docs]
class FieldSpecies(Species):
"""
Represents a field species with zero mass and charge.
Field species are used to represent electromagnetic or other non-particle fields in a plasma
model. They have no direct physical mass or charge properties (charge_number = 0, mass_number = 0),
but may have associated equation parameters for scaled formulations.
"""
def init_variables(self):
"""Create variables dict.
For field species, set charge and mass numbers to zero by default."""
super().init_variables(charge_number=0, mass_number=0)
[docs]
class FluidSpecies(Species):
"""
Represents a single fluid species evolving in 3D configuration space.
FluidSpecies describes macroscopic plasma dynamics using fluid or moment-based equations
(e.g., Euler equations, MHD equations). Each fluid species has a specific charge and mass number
and evolves according to fluid propagators.
Examples
--------
>>> electrons = MyFluidSpecies(charge_number=-1, mass_number=1/1836) # for electrons
"""
[docs]
class ParticleSpecies(Species):
"""
Represents a single kinetic species in 3D configuration space plus velocity space.
ParticleSpecies describes plasma dynamics using kinetic theory via particles or markers
in 3D + vdim (where vdim is 2 or 3) phase space. This class manages particle initialization,
sorting, and diagnostic output.
Methods
-------
set_markers(loading_params, weights_params, boundary_params, sorting_params, saving_params, bufsize)
Configure marker initialization and parameters.
Examples
--------
>>> electrons = MyParticleSpecies(charge_number=-1, mass_number=1/1836)
>>> load_params = LoadingParameters(Np=100000)
>>> electrons.set_markers(loading_params=load_params)
"""
[docs]
def set_markers(
self,
loading_params: LoadingParameters = None,
weights_params: WeightsParameters = None,
boundary_params: BoundaryParameters = None,
sorting_params: SortingParameters = None,
saving_params: SavingParameters = None,
bufsize: float = 1.0,
):
"""Set marker parameters for loading, weight calculation, kernel density reconstruction
and boundary conditions in parameter/launch files.
Parameters
----------
loading_params : LoadingParameters
weights_params : WeightsParameters
boundary_params : BoundaryParameters
sorting_params : SortingParameters
saving_params : SavingParameters
bufsize : float
Size of buffer (as multiple of total size, default=.25) in markers array.
"""
# defaults
if loading_params is None:
loading_params = LoadingParameters()
if weights_params is None:
weights_params = WeightsParameters()
if boundary_params is None:
boundary_params = BoundaryParameters()
if sorting_params is None:
sorting_params = SortingParameters()
if saving_params is None:
saving_params = SavingParameters()
self.loading_params = loading_params
self.weights_params = weights_params
self.boundary_params = boundary_params
self.sorting_params = sorting_params
self.saving_params = saving_params
self.bufsize = bufsize
logger.info(f"\nMarker parameters for species '{self.__class__.__name__}':")
logger.info(self.loading_params.__repr_no_defaults__())
logger.info(self.weights_params.__repr_no_defaults__())
logger.info(self.boundary_params.__repr_no_defaults__())
logger.info(self.sorting_params.__repr_no_defaults__())
logger.info(self.saving_params.__repr_no_defaults__())
logger.info(f"\nMarker array buffer size: {self.bufsize * 100:.1f}% of total size")
# def set_sorting_boxes(
# self,
# do_sort: bool = False,
# sorting_frequency: int = 0,
# boxes_per_dim: tuple = (12, 12, 1),
# box_bufsize: float = 2.0,
# dims_mask: tuple = (True, True, True),
# ):
# """Set options for sorting particles/markers in parameter/launch files.
# The sorting boxes are used to sort particles in memory and for SPH kernel evaluations.
# For SPH kernel evaluation, the box size 1.0/boxes_per_dim[i] defines the maximal
# kernel width in direction i.
# Parameters
# ----------
# do_sort: bool
# Whether to sort particles in memory.
# sorting_frequency: int
# The number of time steps between two sortings (=0 means no sorting is performed).
# boxes_per_dim: tuple
# Number of boxes in each direction of logical space, (n_eta1, n_eta2, n_eta3).
# box_bufsize : float
# Between 0 and 1, relative buffer size for box array (default = 0.25).
# A number of 1.0 means that the box array is double the size needed to hold N/n_boxes particles,
# where N is the total number of particles.
# mpi_dims_mask: tuple[bool]
# True if the dimension is to be used in the domain decomposition (=default for each dimension).
# If mpi_dims_mask[i]=False, the i-th dimension will not be decomposed.
# """
# self.do_sort = do_sort
# self.sorting_fequency = sorting_frequency
# self.boxes_per_dim = boxes_per_dim
# self.box_bufsize = box_bufsize
# self.dims_mask = dims_mask
# def set_save_data(
# self,
# n_markers: int | float = 3,
# binning_plots: tuple[BinningPlot] = (),
# kernel_density_plots: tuple[KernelDensityPlot] = (),
# ):
# """Set options for saving particle/marker information in parameter/launch files.
# Parameters
# ----------
# n_markers: int | float
# Number of particles/markers for which to save trajectories.
# If float and <1.0, then understood as the fraction of the total number of markers.
# binned_data: tuple[BinningPlot]
# A tuple of BinningPlot objects.
# kernel_density_plots: tuple[KernelDensityPlot]
# A tuple of KernelDensityPlot objects.
# """
# self.n_markers = n_markers
# self.binning_plots = binning_plots
# self.kernel_density_plots = kernel_density_plots
class DiagnosticSpecies(Species):
"""
Represents a diagnostic species for output and analysis of non-physical quantities.
DiagnosticSpecies are used to track derived quantities and diagnostics that are not part
of the primary simulation fields. They have zero mass and charge and are typically computed
from other species data during simulation postprocessing or on-the-fly diagnostics.
Notes
-----
Diagnostic species do not directly participate in the dynamics equations but are updated
based on values from other species (field, fluid, or particle species).
Examples
--------
>>> vorticity = DiagnosticSpecies() # For tracking curl of velocity field
>>> energy = DiagnosticSpecies() # For tracking kinetic/thermal energy density
"""