"""Classes following the PICMI standard
These should be the base classes for Python implementation of the PICMI standard
"""
from __future__ import annotations
import sys
from typing import Any, Literal
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
from collections.abc import Sequence
from pydantic import Field, field_validator, model_validator
from .base import _ClassWithInit
# Type aliases - using forward references for types defined later in this file
# Note: Grid types are defined later in this file, so we use string forward references
PICMI_Grid = (
"PICMI_Cartesian1DGrid | PICMI_Cartesian2DGrid | "
"PICMI_Cartesian3DGrid | PICMI_CylindricalGrid"
)
# ---------------
# Physics objects
# ---------------
[docs]
class PICMI_ElectromagneticSolver(_ClassWithInit):
"""
Electromagnetic field solver.
The advance method used to solve Maxwell's equations. The default method is code dependent.
Method options:
- 'Yee': standard solver using the staggered Yee grid (https://doi.org/10.1109/TAP.1966.1138693)
- 'CKC': solver with the extended Cole-Karkkainen-Cowan stencil with better dispersion properties (https://doi.org/10.1103/PhysRevSTAB.16.041303)
- 'Lehe': CKC-style solver with modified dispersion (https://doi.org/10.1103/PhysRevSTAB.16.021301)
- 'PSTD': Spectral solver with finite difference in time domain, e.g., Q. H. Liu, Letters 15 (3) (1997) 158–165
- 'PSATD': Spectral solver with analytic in time domain (https://doi.org/10.1016/j.jcp.2013.03.010)
- 'DS': Directional Splitting after Yasuhiko Sentoku (https://doi.org/10.1140/epjd/e2014-50162-y)
- 'ECT': Enlarged Cell Technique solver, allowing internal conductors (https://doi.org/10.1109/APS.2005.1551259)
"""
methods_list: list[str] = ['Yee', 'CKC', 'Lehe', 'PSTD', 'PSATD', 'GPSTD', 'DS', 'ECT']
grid: "PICMI_Cartesian1DGrid | PICMI_Cartesian2DGrid | PICMI_Cartesian3DGrid | PICMI_CylindricalGrid" = Field(description="Grid object for the diagnostic")
method: Literal['Yee', 'CKC', 'Lehe', 'PSTD', 'PSATD', 'GPSTD', 'DS', 'ECT'] | None = Field(
default=None,
description="The advance method use to solve Maxwell's equations. The default method is code dependent."
)
stencil_order: Sequence[int] | None = Field(
default=None,
description="Vector of integers. Order of stencil for each axis (-1=infinite)"
)
cfl: float | None = Field(
default=None,
description="Fraction of the Courant-Friedrich-Lewy criteria [1]"
)
source_smoother: "PICMI_BinomialSmoother | None" = Field(
default=None,
description="Smoother instance. Smoother object to apply to the sources"
)
field_smoother: "PICMI_BinomialSmoother | None" = Field(
default=None,
description="Smoother instance. Smoother object to apply to the fields"
)
subcycling: int | None = Field(
default=None,
description="Level of subcycling for the GPSTD solver"
)
galilean_velocity: Sequence[float] | None = Field(
default=None,
description="Vector of floats. Velocity of Galilean reference frame [m/s]"
)
divE_cleaning: bool | None = Field(
default=None,
description="Solver uses div(E) cleaning if True"
)
divB_cleaning: bool | None = Field(
default=None,
description="Solver uses div(B) cleaning if True"
)
pml_divE_cleaning: bool | None = Field(
default=None,
description="Solver uses div(E) cleaning in the PML if True"
)
pml_divB_cleaning: bool | None = Field(
default=None,
description="Solver uses div(B) cleaning in the PML if True"
)
@field_validator('method')
@classmethod
def _validate_method(cls, v):
if v is not None and v not in PICMI_ElectromagneticSolver.methods_list:
raise ValueError(f'method must be one of {", ".join(PICMI_ElectromagneticSolver.methods_list)}')
return v
[docs]
class PICMI_ElectrostaticSolver(_ClassWithInit):
"""
Electrostatic field solver.
"""
methods_list: list[str] = ['FFT', 'Multigrid']
grid: "PICMI_Cartesian1DGrid | PICMI_Cartesian2DGrid | PICMI_Cartesian3DGrid | PICMI_CylindricalGrid" = Field(description="Grid instance. Grid object for the diagnostic")
method: Literal['FFT', 'Multigrid'] | None = Field(
default=None,
description="String. One of 'FFT', or 'Multigrid'"
)
required_precision: float | None = Field(
default=None,
description="Level of precision required for iterative solvers"
)
maximum_iterations: int | None = Field(
default=None,
description="Maximum number of iterations for iterative solvers"
)
@field_validator('method')
@classmethod
def _validate_method(cls, v):
if v is not None and v not in PICMI_ElectrostaticSolver.methods_list:
raise ValueError(f'method must be one of {", ".join(PICMI_ElectrostaticSolver.methods_list)}')
return v
[docs]
class PICMI_MagnetostaticSolver(_ClassWithInit):
"""
Magnetostatic field solver.
"""
methods_list: list[str] = ['FFT', 'Multigrid']
grid: "PICMI_Cartesian1DGrid | PICMI_Cartesian2DGrid | PICMI_Cartesian3DGrid | PICMI_CylindricalGrid" = Field(description="Grid instance. Grid object for the diagnostic")
method: Literal['FFT', 'Multigrid'] | None = Field(
default=None,
description="String. One of 'FFT', or 'Multigrid'"
)
@field_validator('method')
@classmethod
def _validate_method(cls, v):
if v is not None and v not in PICMI_MagnetostaticSolver.methods_list:
raise ValueError(f'method must be one of {", ".join(PICMI_MagnetostaticSolver.methods_list)}')
return v
# ------------------
# Numeric Objects
# ------------------
[docs]
class PICMI_BinomialSmoother(_ClassWithInit):
"""
Describes a binomial smoother operator (applied to grids).
"""
n_pass: Sequence[int] | None = Field(
default=None,
description="Vector of integers. Number of passes along each axis"
)
compensation: Sequence[bool] | None = Field(
default=None,
description="Vector of booleans, optional. Flags whether to apply compensation along each axis"
)
stride: Sequence[int] | None = Field(
default=None,
description="Vector of integers, optional. Stride along each axis"
)
alpha: Sequence[float] | None = Field(
default=None,
description="Vector of floats, optional. Smoothing coefficients along each axis"
)
[docs]
class PICMI_Cartesian1DGrid(_ClassWithInit):
"""
One-dimensional Cartesian grid.
Parameters can be specified either as vectors or separately.
(If both are specified, the vector is used.)
References
----------
absorbing_silver_mueller: A local absorbing boundary condition that works best under normal incidence angle.
Based on the Silver-Mueller Radiation Condition, e.g., in
* A. K. Belhora and L. Pichon, "Maybe Efficient Absorbing Boundary Conditions for the Finite Element Solution of 3D Scattering Problems," 1995,
https://doi.org/10.1109/20.376322
* B Engquist and A. Majdat, "Absorbing boundary conditions for numerical simulation of waves," 1977,
https://doi.org/10.1073/pnas.74.5.1765
* R. Lehe, "Electromagnetic wave propagation in Particle-In-Cell codes," 2016,
US Particle Accelerator School (USPAS) Summer Session, Self-Consistent Simulations of Beam and Plasma Systems
https://people.nscl.msu.edu/~lund/uspas/scs_2016/lec_adv/A1b_EM_Waves.pdf
"""
number_of_dimensions: int = 1
# Vector form parameters (preferred)
number_of_cells: Sequence[int] | None = Field(
default=None,
min_length=1,
max_length=1,
description="Vector of integers. Number of cells along each axis (number of nodes is number_of_cells+1). Either this or nx must be specified (not both)."
)
lower_bound: Sequence[float] | None = Field(
default=None,
min_length=1,
max_length=1,
description="Vector of floats. Position of the node at the lower bound [m]. Either this or xmin must be specified (not both)."
)
upper_bound: Sequence[float] | None = Field(
default=None,
min_length=1,
max_length=1,
description="Vector of floats. Position of the node at the upper bound [m]. Either this or xmax must be specified (not both)."
)
lower_boundary_conditions: Sequence[str] | None = Field(
default=None,
min_length=1,
max_length=1,
description="Vector of strings. Conditions at lower boundaries: periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this or bc_xmin must be specified (not both)."
)
upper_boundary_conditions: Sequence[str] | None = Field(
default=None,
min_length=1,
max_length=1,
description="Vector of strings. Conditions at upper boundaries: periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this or bc_xmax must be specified (not both)."
)
# Individual named parameters (alternative form)
nx: int | None = Field(
default=None,
description="Integer. Number of cells along X (number of nodes=nx+1). Either this or number_of_cells must be specified (not both)."
)
xmin: float | None = Field(
default=None,
description="Float. Position of first node along X [m]. Either this or lower_bound must be specified (not both)."
)
xmax: float | None = Field(
default=None,
description="Float. Position of last node along X [m]. Either this or upper_bound must be specified (not both)."
)
bc_xmin: str | None = Field(
default=None,
description="String. Boundary condition at min X: One of periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this or lower_boundary_conditions must be specified (not both)."
)
bc_xmax: str | None = Field(
default=None,
description="String. Boundary condition at max X: One of periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this or upper_boundary_conditions must be specified (not both)."
)
# Particle boundary parameters (vector form)
lower_bound_particles: Sequence[float] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of floats, optional. Position of particle lower bound [m]. By default, if not specified, particle boundary values are the same as field boundary values."
)
upper_bound_particles: Sequence[float] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of floats, optional. Position of particle upper bound [m]. By default, if not specified, particle boundary values are the same as field boundary values."
)
lower_boundary_conditions_particles: Sequence[str] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of strings, optional. Conditions at lower boundaries for particles: periodic, absorbing, reflect or thermal. By default, if not specified, particle boundary conditions are the same as field boundary conditions."
)
upper_boundary_conditions_particles: Sequence[str] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of strings, optional. Conditions at upper boundaries for particles: periodic, absorbing, reflect or thermal. By default, if not specified, particle boundary conditions are the same as field boundary conditions."
)
# Particle boundary parameters (individual form)
xmin_particles: float | None = Field(
default=None,
description="Float, optional. Position of min particle boundary along X [m]."
)
xmax_particles: float | None = Field(
default=None,
description="Float, optional. Position of max particle boundary along X [m]."
)
bc_xmin_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at min X for particles: One of periodic, absorbing, reflect, thermal."
)
bc_xmax_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at max X for particles: One of periodic, absorbing, reflect, thermal."
)
# Other parameters
moving_window_velocity: Sequence[float] | None = Field(
default=None,
description="Vector of floats, optional. Moving frame velocity [m/s]"
)
refined_regions: list[list] = Field(
default_factory=list,
description="List of lists, optional. List of refined regions, each element being a list of the format [level, lo, hi, refinement_factor], with level being the refinement level (1 being first, 2 being second etc), lo and hi being vectors of length 1 specifying the extent of the region, and refinement_factor defaulting to [2] (relative to next lower level)."
)
guard_cells: Sequence[int] | None = Field(
default=None,
description="Vector of integers, optional. Number of guard cells used along each direction"
)
pml_cells: Sequence[int] | None = Field(
default=None,
description="Vector of integers, optional. Number of Perfectly Matched Layer (PML) cells along each direction"
)
@model_validator(mode='before')
@classmethod
def _normalize_parameters(cls, data: Any) -> Any:
"""Normalize individual parameters to vector form before validation"""
if not isinstance(data, dict):
return data
data = dict(data)
# Validate either/or constraints for grid parameters
number_of_cells = data.get('number_of_cells')
nx = data.get('nx')
if (number_of_cells is None) == (nx is None):
raise ValueError('Exactly one of number_of_cells or nx must be specified')
lower_bound = data.get('lower_bound')
xmin = data.get('xmin')
if (lower_bound is None) == (xmin is None):
raise ValueError('Exactly one of lower_bound or xmin must be specified')
upper_bound = data.get('upper_bound')
xmax = data.get('xmax')
if (upper_bound is None) == (xmax is None):
raise ValueError('Exactly one of upper_bound or xmax must be specified')
lower_boundary_conditions = data.get('lower_boundary_conditions')
bc_xmin = data.get('bc_xmin')
if (lower_boundary_conditions is None) == (bc_xmin is None):
raise ValueError('Exactly one of lower_boundary_conditions or bc_xmin must be specified')
upper_boundary_conditions = data.get('upper_boundary_conditions')
bc_xmax = data.get('bc_xmax')
if (upper_boundary_conditions is None) == (bc_xmax is None):
raise ValueError('Exactly one of upper_boundary_conditions or bc_xmax must be specified')
# Convert individual parameters to vectors if vectors not provided
# If both provided, vector takes precedence (as per original docstring)
if number_of_cells is None and nx is not None:
data['number_of_cells'] = [nx]
data.pop('nx', None)
elif number_of_cells is not None and nx is not None:
data.pop('nx', None) # Prefer vector form
if lower_bound is None and xmin is not None:
data['lower_bound'] = [xmin]
data.pop('xmin', None)
elif lower_bound is not None and xmin is not None:
data.pop('xmin', None)
if upper_bound is None and xmax is not None:
data['upper_bound'] = [xmax]
data.pop('xmax', None)
elif upper_bound is not None and xmax is not None:
data.pop('xmax', None)
if lower_boundary_conditions is None and bc_xmin is not None:
data['lower_boundary_conditions'] = [bc_xmin]
data.pop('bc_xmin', None)
elif lower_boundary_conditions is not None and bc_xmin is not None:
data.pop('bc_xmin', None)
if upper_boundary_conditions is None and bc_xmax is not None:
data['upper_boundary_conditions'] = [bc_xmax]
data.pop('bc_xmax', None)
elif upper_boundary_conditions is not None and bc_xmax is not None:
data.pop('bc_xmax', None)
# Handle particle boundaries
if data.get('lower_bound_particles') is None:
if data.get('xmin_particles') is not None:
data['lower_bound_particles'] = [data.pop('xmin_particles')]
if data.get('upper_bound_particles') is None:
if data.get('xmax_particles') is not None:
data['upper_bound_particles'] = [data.pop('xmax_particles')]
if data.get('lower_boundary_conditions_particles') is None:
if data.get('bc_xmin_particles') is not None:
data['lower_boundary_conditions_particles'] = [data.pop('bc_xmin_particles')]
if data.get('upper_boundary_conditions_particles') is None:
if data.get('bc_xmax_particles') is not None:
data['upper_boundary_conditions_particles'] = [data.pop('bc_xmax_particles')]
return data
@model_validator(mode='after')
def _validate_and_normalize(self) -> Self:
"""Validate dimensions and normalize particle boundaries"""
# Handle particle boundaries - default to field boundaries if not specified
if self.lower_bound_particles is None:
if self.xmin_particles is None:
self.lower_bound_particles = list(self.lower_bound)
else:
self.lower_bound_particles = [self.xmin_particles]
self.xmin_particles = None
if self.upper_bound_particles is None:
if self.xmax_particles is None:
self.upper_bound_particles = list(self.upper_bound)
else:
self.upper_bound_particles = [self.xmax_particles]
self.xmax_particles = None
if self.lower_boundary_conditions_particles is None:
if self.bc_xmin_particles is None:
self.lower_boundary_conditions_particles = list(self.lower_boundary_conditions)
else:
self.lower_boundary_conditions_particles = [self.bc_xmin_particles]
self.bc_xmin_particles = None
if self.upper_boundary_conditions_particles is None:
if self.bc_xmax_particles is None:
self.upper_boundary_conditions_particles = list(self.upper_boundary_conditions)
else:
self.upper_boundary_conditions_particles = [self.bc_xmax_particles]
self.bc_xmax_particles = None
# Dimensions are validated by Field(min_length=1, max_length=1) constraints
# Process refined regions
for region in self.refined_regions:
if len(region) == 3:
region.append([2])
if len(region[1]) != 1:
raise ValueError('The lo extent of the refined region must be a vector of length 1')
if len(region[2]) != 1:
raise ValueError('The hi extent of the refined region must be a vector of length 1')
if len(region[3]) != 1:
raise ValueError('The refinement factor of the refined region must be a vector of length 1')
return self
[docs]
def add_refined_region(self, level, lo, hi, refinement_factor=[2]):
"""Add a refined region.
level: the refinement level, with 1 being the first level of refinement, 2 being the second etc.
lo, hi: vectors of length 2 specifying the extent of the region
refinement_factor: defaulting to [2,2] (relative to next lower level)
"""
self.refined_regions.append([level, lo, hi, refinement_factor])
[docs]
class PICMI_CylindricalGrid(_ClassWithInit):
"""
Axisymmetric, cylindrical grid.
Parameters can be specified either as vectors or separately.
(If both are specified, the vector is used.)
References
----------
absorbing_silver_mueller: A local absorbing boundary condition that works best under normal incidence angle.
Based on the Silver-Mueller Radiation Condition, e.g., in
* A. K. Belhora and L. Pichon, "Maybe Efficient Absorbing Boundary Conditions for the Finite Element Solution of 3D Scattering Problems," 1995,
https://doi.org/10.1109/20.376322
* B Engquist and A. Majdat, "Absorbing boundary conditions for numerical simulation of waves," 1977,
https://doi.org/10.1073/pnas.74.5.1765
* R. Lehe, "Electromagnetic wave propagation in Particle-In-Cell codes," 2016,
US Particle Accelerator School (USPAS) Summer Session, Self-Consistent Simulations of Beam and Plasma Systems
https://people.nscl.msu.edu/~lund/uspas/scs_2016/lec_adv/A1b_EM_Waves.pdf
"""
number_of_dimensions: int = 2
# Vector form parameters (preferred)
number_of_cells: Sequence[int] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of integers. Number of cells along each axis (number of nodes is number_of_cells+1). Either this or nr and nz must be specified (not both)."
)
lower_bound: Sequence[float] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of floats. Position of the node at the lower bound [m]. Either this or rmin and zmin must be specified (not both)."
)
upper_bound: Sequence[float] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of floats. Position of the node at the upper bound [m]. Either this or rmax and zmax must be specified (not both)."
)
lower_boundary_conditions: Sequence[str] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of strings. Conditions at lower boundaries: periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this or bc_rmin and bc_zmin must be specified (not both). Note: bc_rmin may be None since it will usually be the axis."
)
upper_boundary_conditions: Sequence[str] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of strings. Conditions at upper boundaries: periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this or bc_rmax and bc_zmax must be specified (not both)."
)
# Individual named parameters (alternative form)
nr: int | None = Field(
default=None,
description="Integer. Number of cells along R (number of nodes=nr+1). Either this (along with nz) or number_of_cells must be specified (not both)."
)
nz: int | None = Field(
default=None,
description="Integer. Number of cells along Z (number of nodes=nz+1). Either this (along with nr) or number_of_cells must be specified (not both)."
)
n_azimuthal_modes: int | None = Field(
default=None,
description="Integer. Number of azimuthal modes"
)
rmin: float | None = Field(
default=None,
description="Float. Position of first node along R [m]. Either this (along with zmin) or lower_bound must be specified (not both)."
)
rmax: float | None = Field(
default=None,
description="Float. Position of last node along R [m]. Either this (along with zmax) or upper_bound must be specified (not both)."
)
zmin: float | None = Field(
default=None,
description="Float. Position of first node along Z [m]. Either this (along with rmin) or lower_bound must be specified (not both)."
)
zmax: float | None = Field(
default=None,
description="Float. Position of last node along Z [m]. Either this (along with rmax) or upper_bound must be specified (not both)."
)
bc_rmin: str | None = Field(
default=None,
description="String. Boundary condition at min R: One of open, dirichlet, absorbing_silver_mueller, or neumann. Either this (along with bc_zmin) or lower_boundary_conditions must be specified (not both). May be None since it will usually be the axis."
)
bc_rmax: str | None = Field(
default=None,
description="String. Boundary condition at max R: One of open, dirichlet, absorbing_silver_mueller, or neumann. Either this (along with bc_zmax) or upper_boundary_conditions must be specified (not both)."
)
bc_zmin: str | None = Field(
default=None,
description="String. Boundary condition at min Z: One of periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this (along with bc_rmin) or lower_boundary_conditions must be specified (not both)."
)
bc_zmax: str | None = Field(
default=None,
description="String. Boundary condition at max Z: One of periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this (along with bc_rmax) or upper_boundary_conditions must be specified (not both)."
)
# Particle boundary parameters (vector form)
lower_bound_particles: Sequence[float] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of floats, optional. Position of particle lower bound [m]. By default, if not specified, particle boundary values are the same as field boundary values."
)
upper_bound_particles: Sequence[float] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of floats, optional. Position of particle upper bound [m]. By default, if not specified, particle boundary values are the same as field boundary values."
)
lower_boundary_conditions_particles: Sequence[str] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of strings, optional. Conditions at lower boundaries for particles: periodic, absorbing, reflect or thermal. By default, if not specified, particle boundary conditions are the same as field boundary conditions."
)
upper_boundary_conditions_particles: Sequence[str] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of strings, optional. Conditions at upper boundaries for particles: periodic, absorbing, reflect or thermal. By default, if not specified, particle boundary conditions are the same as field boundary conditions."
)
# Particle boundary parameters (individual form)
rmin_particles: float | None = Field(
default=None,
description="Float, optional. Position of min particle boundary along R [m]."
)
rmax_particles: float | None = Field(
default=None,
description="Float, optional. Position of max particle boundary along R [m]."
)
zmin_particles: float | None = Field(
default=None,
description="Float, optional. Position of min particle boundary along Z [m]."
)
zmax_particles: float | None = Field(
default=None,
description="Float, optional. Position of max particle boundary along Z [m]."
)
bc_rmin_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at min R for particles: One of periodic, absorbing, reflect, thermal."
)
bc_rmax_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at max R for particles: One of periodic, absorbing, reflect, thermal."
)
bc_zmin_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at min Z for particles: One of periodic, absorbing, reflect, thermal."
)
bc_zmax_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at max Z for particles: One of periodic, absorbing, reflect, thermal."
)
# Other parameters
moving_window_velocity: Sequence[float] | None = Field(
default=None,
description="Vector of floats, optional. Moving frame velocity [m/s]"
)
refined_regions: list[list] = Field(
default_factory=list,
description="List of lists, optional. List of refined regions, each element being a list of the format [level, lo, hi, refinement_factor], with level being the refinement level (1 being first, 2 being second etc), lo and hi being vectors of length 2 specifying the extent of the region, and refinement_factor defaulting to [2,2] (relative to next lower level)."
)
guard_cells: Sequence[int] | None = Field(
default=None,
description="Vector of integers, optional. Number of guard cells used along each direction"
)
pml_cells: Sequence[int] | None = Field(
default=None,
description="Vector of integers, optional. Number of Perfectly Matched Layer (PML) cells along each direction"
)
@model_validator(mode='before')
@classmethod
def _normalize_parameters(cls, data: Any) -> Any:
"""Normalize individual parameters to vector form before validation"""
if not isinstance(data, dict):
return data
data = dict(data)
# Validate either/or constraints for grid parameters
number_of_cells = data.get('number_of_cells')
nr = data.get('nr')
nz = data.get('nz')
if (number_of_cells is None) == ((nr is not None) and (nz is not None)):
raise ValueError('Exactly one of number_of_cells or (nr and nz) must be specified')
lower_bound = data.get('lower_bound')
rmin = data.get('rmin')
zmin = data.get('zmin')
if (lower_bound is None) == ((rmin is not None) and (zmin is not None)):
raise ValueError('Exactly one of lower_bound or (rmin and zmin) must be specified')
upper_bound = data.get('upper_bound')
rmax = data.get('rmax')
zmax = data.get('zmax')
if (upper_bound is None) == ((rmax is not None) and (zmax is not None)):
raise ValueError('Exactly one of upper_bound or (rmax and zmax) must be specified')
lower_boundary_conditions = data.get('lower_boundary_conditions')
bc_rmin = data.get('bc_rmin')
bc_zmin = data.get('bc_zmin')
# Special case: bc_rmin can be None (axis), so only check bc_zmin
if (lower_boundary_conditions is None) and (bc_zmin is None):
raise ValueError('Either lower_boundary_conditions or bc_zmin (and optionally bc_rmin) must be specified')
if (lower_boundary_conditions is not None) and (bc_rmin is not None or bc_zmin is not None):
raise ValueError('Either lower_boundary_conditions or (bc_rmin and bc_zmin) must be specified (not both)')
upper_boundary_conditions = data.get('upper_boundary_conditions')
bc_rmax = data.get('bc_rmax')
bc_zmax = data.get('bc_zmax')
if (upper_boundary_conditions is None) == ((bc_rmax is not None) and (bc_zmax is not None)):
raise ValueError('Exactly one of upper_boundary_conditions or (bc_rmax and bc_zmax) must be specified')
# Convert individual parameters to vectors if vectors not provided
if number_of_cells is None and nr is not None and nz is not None:
data['number_of_cells'] = [nr, nz]
data.pop('nr', None)
data.pop('nz', None)
elif number_of_cells is not None:
data.pop('nr', None)
data.pop('nz', None)
if lower_bound is None and rmin is not None and zmin is not None:
data['lower_bound'] = [rmin, zmin]
data.pop('rmin', None)
data.pop('zmin', None)
elif lower_bound is not None:
data.pop('rmin', None)
data.pop('zmin', None)
if upper_bound is None and rmax is not None and zmax is not None:
data['upper_bound'] = [rmax, zmax]
data.pop('rmax', None)
data.pop('zmax', None)
elif upper_bound is not None:
data.pop('rmax', None)
data.pop('zmax', None)
if lower_boundary_conditions is None:
bc_rmin_val = data.get('bc_rmin')
bc_zmin_val = data.get('bc_zmin')
if bc_zmin_val is not None:
data['lower_boundary_conditions'] = [bc_rmin_val, bc_zmin_val]
data.pop('bc_rmin', None)
data.pop('bc_zmin', None)
else:
data.pop('bc_rmin', None)
data.pop('bc_zmin', None)
if upper_boundary_conditions is None and bc_rmax is not None and bc_zmax is not None:
data['upper_boundary_conditions'] = [bc_rmax, bc_zmax]
data.pop('bc_rmax', None)
data.pop('bc_zmax', None)
elif upper_boundary_conditions is not None:
data.pop('bc_rmax', None)
data.pop('bc_zmax', None)
# Handle particle boundaries
if data.get('lower_bound_particles') is None:
rmin_p = data.get('rmin_particles')
zmin_p = data.get('zmin_particles')
if rmin_p is not None or zmin_p is not None:
data['lower_bound_particles'] = [rmin_p, zmin_p]
data.pop('rmin_particles', None)
data.pop('zmin_particles', None)
if data.get('upper_bound_particles') is None:
rmax_p = data.get('rmax_particles')
zmax_p = data.get('zmax_particles')
if rmax_p is not None or zmax_p is not None:
data['upper_bound_particles'] = [rmax_p, zmax_p]
data.pop('rmax_particles', None)
data.pop('zmax_particles', None)
if data.get('lower_boundary_conditions_particles') is None:
bc_rmin_p = data.get('bc_rmin_particles')
bc_zmin_p = data.get('bc_zmin_particles')
if bc_rmin_p is not None or bc_zmin_p is not None:
data['lower_boundary_conditions_particles'] = [bc_rmin_p, bc_zmin_p]
data.pop('bc_rmin_particles', None)
data.pop('bc_zmin_particles', None)
if data.get('upper_boundary_conditions_particles') is None:
bc_rmax_p = data.get('bc_rmax_particles')
bc_zmax_p = data.get('bc_zmax_particles')
if bc_rmax_p is not None or bc_zmax_p is not None:
data['upper_boundary_conditions_particles'] = [bc_rmax_p, bc_zmax_p]
data.pop('bc_rmax_particles', None)
data.pop('bc_zmax_particles', None)
return data
@model_validator(mode='after')
def _validate_and_normalize(self) -> Self:
"""Validate dimensions and normalize particle boundaries"""
# At this point, vectors should already be set by mode='before' validator
# Handle particle boundaries - default to field boundaries if not specified
if self.lower_bound_particles is None:
if self.rmin_particles is None and self.zmin_particles is None:
self.lower_bound_particles = list(self.lower_bound)
else:
self.lower_bound_particles = [self.rmin_particles, self.zmin_particles]
self.rmin_particles = None
self.zmin_particles = None
if self.upper_bound_particles is None:
if self.rmax_particles is None and self.zmax_particles is None:
self.upper_bound_particles = list(self.upper_bound)
else:
self.upper_bound_particles = [self.rmax_particles, self.zmax_particles]
self.rmax_particles = None
self.zmax_particles = None
if self.lower_boundary_conditions_particles is None:
if self.bc_rmin_particles is None and self.bc_zmin_particles is None:
self.lower_boundary_conditions_particles = list(self.lower_boundary_conditions)
else:
self.lower_boundary_conditions_particles = [self.bc_rmin_particles, self.bc_zmin_particles]
self.bc_rmin_particles = None
self.bc_zmin_particles = None
if self.upper_boundary_conditions_particles is None:
if self.bc_rmax_particles is None and self.bc_zmax_particles is None:
self.upper_boundary_conditions_particles = list(self.upper_boundary_conditions)
else:
self.upper_boundary_conditions_particles = [self.bc_rmax_particles, self.bc_zmax_particles]
self.bc_rmax_particles = None
self.bc_zmax_particles = None
# Dimensions are validated by Field(min_length=2, max_length=2) constraints
# Process refined regions
for region in self.refined_regions:
if len(region) == 3:
region.append([2, 2])
if len(region[1]) != 2:
raise ValueError('The lo extent of the refined region must be a vector of length 2')
if len(region[2]) != 2:
raise ValueError('The hi extent of the refined region must be a vector of length 2')
if len(region[3]) != 2:
raise ValueError('The refinement factor of the refined region must be a vector of length 2')
return self
[docs]
def add_refined_region(self, level, lo, hi, refinement_factor=[2, 2]):
"""Add a refined region.
level: the refinement level, with 1 being the first level of refinement, 2 being the second etc.
lo, hi: vectors of length 2 specifying the extent of the region
refinement_factor: defaulting to [2,2] (relative to next lower level)
"""
self.refined_regions.append([level, lo, hi, refinement_factor])
[docs]
class PICMI_Cartesian2DGrid(_ClassWithInit):
"""
Two dimensional Cartesian grid.
Parameters can be specified either as vectors or separately.
(If both are specified, the vector is used.)
References
----------
absorbing_silver_mueller: A local absorbing boundary condition that works best under normal incidence angle.
Based on the Silver-Mueller Radiation Condition, e.g., in
* A. K. Belhora and L. Pichon, "Maybe Efficient Absorbing Boundary Conditions for the Finite Element Solution of 3D Scattering Problems," 1995,
https://doi.org/10.1109/20.376322
* B Engquist and A. Majdat, "Absorbing boundary conditions for numerical simulation of waves," 1977,
https://doi.org/10.1073/pnas.74.5.1765
* R. Lehe, "Electromagnetic wave propagation in Particle-In-Cell codes," 2016,
US Particle Accelerator School (USPAS) Summer Session, Self-Consistent Simulations of Beam and Plasma Systems
https://people.nscl.msu.edu/~lund/uspas/scs_2016/lec_adv/A1b_EM_Waves.pdf
"""
number_of_dimensions: int = 2
# Vector form parameters (preferred)
number_of_cells: Sequence[int] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of integers. Number of cells along each axis (number of nodes is number_of_cells+1). Either this or nx and ny must be specified (not both)."
)
lower_bound: Sequence[float] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of floats. Position of the node at the lower bound [m]. Either this or xmin and ymin must be specified (not both)."
)
upper_bound: Sequence[float] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of floats. Position of the node at the upper bound [m]. Either this or xmax and ymax must be specified (not both)."
)
lower_boundary_conditions: Sequence[str] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of strings. Conditions at lower boundaries: periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this or bc_xmin and bc_ymin must be specified (not both)."
)
upper_boundary_conditions: Sequence[str] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of strings. Conditions at upper boundaries: periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this or bc_xmax and bc_ymax must be specified (not both)."
)
# Individual named parameters (alternative form)
nx: int | None = Field(
default=None,
description="Integer. Number of cells along X (number of nodes=nx+1). Either this (along with ny) or number_of_cells must be specified (not both)."
)
ny: int | None = Field(
default=None,
description="Integer. Number of cells along Y (number of nodes=ny+1). Either this (along with nx) or number_of_cells must be specified (not both)."
)
xmin: float | None = Field(
default=None,
description="Float. Position of first node along X [m]. Either this (along with ymin) or lower_bound must be specified (not both)."
)
xmax: float | None = Field(
default=None,
description="Float. Position of last node along X [m]. Either this (along with ymax) or upper_bound must be specified (not both)."
)
ymin: float | None = Field(
default=None,
description="Float. Position of first node along Y [m]. Either this (along with xmin) or lower_bound must be specified (not both)."
)
ymax: float | None = Field(
default=None,
description="Float. Position of last node along Y [m]. Either this (along with xmax) or upper_bound must be specified (not both)."
)
bc_xmin: str | None = Field(
default=None,
description="String. Boundary condition at min X: One of periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this (along with bc_ymin) or lower_boundary_conditions must be specified (not both)."
)
bc_xmax: str | None = Field(
default=None,
description="String. Boundary condition at max X: One of periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this (along with bc_ymax) or upper_boundary_conditions must be specified (not both)."
)
bc_ymin: str | None = Field(
default=None,
description="String. Boundary condition at min Y: One of periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this (along with bc_xmin) or lower_boundary_conditions must be specified (not both)."
)
bc_ymax: str | None = Field(
default=None,
description="String. Boundary condition at max Y: One of periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this (along with bc_xmax) or upper_boundary_conditions must be specified (not both)."
)
# Particle boundary parameters (vector form)
lower_bound_particles: Sequence[float] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of floats, optional. Position of particle lower bound [m]. By default, if not specified, particle boundary values are the same as field boundary values."
)
upper_bound_particles: Sequence[float] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of floats, optional. Position of particle upper bound [m]. By default, if not specified, particle boundary values are the same as field boundary values."
)
lower_boundary_conditions_particles: Sequence[str] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of strings, optional. Conditions at lower boundaries for particles: periodic, absorbing, reflect or thermal. By default, if not specified, particle boundary conditions are the same as field boundary conditions."
)
upper_boundary_conditions_particles: Sequence[str] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of strings, optional. Conditions at upper boundaries for particles: periodic, absorbing, reflect or thermal. By default, if not specified, particle boundary conditions are the same as field boundary conditions."
)
# Particle boundary parameters (individual form)
xmin_particles: float | None = Field(
default=None,
description="Float, optional. Position of min particle boundary along X [m]."
)
xmax_particles: float | None = Field(
default=None,
description="Float, optional. Position of max particle boundary along X [m]."
)
ymin_particles: float | None = Field(
default=None,
description="Float, optional. Position of min particle boundary along Y [m]."
)
ymax_particles: float | None = Field(
default=None,
description="Float, optional. Position of max particle boundary along Y [m]."
)
bc_xmin_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at min X for particles: One of periodic, absorbing, reflect, thermal."
)
bc_xmax_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at max X for particles: One of periodic, absorbing, reflect, thermal."
)
bc_ymin_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at min Y for particles: One of periodic, absorbing, reflect, thermal."
)
bc_ymax_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at max Y for particles: One of periodic, absorbing, reflect, thermal."
)
# Other parameters
moving_window_velocity: Sequence[float] | None = Field(
default=None,
description="Vector of floats, optional. Moving frame velocity [m/s]"
)
refined_regions: list[list] = Field(
default_factory=list,
description="List of lists, optional. List of refined regions, each element being a list of the format [level, lo, hi, refinement_factor], with level being the refinement level (1 being first, 2 being second etc), lo and hi being vectors of length 2 specifying the extent of the region, and refinement_factor defaulting to [2,2] (relative to next lower level)."
)
guard_cells: Sequence[int] | None = Field(
default=None,
description="Vector of integers, optional. Number of guard cells used along each direction"
)
pml_cells: Sequence[int] | None = Field(
default=None,
description="Vector of integers, optional. Number of Perfectly Matched Layer (PML) cells along each direction"
)
@model_validator(mode='before')
@classmethod
def _normalize_parameters(cls, data: Any) -> Any:
"""Normalize individual parameters to vector form before validation"""
if not isinstance(data, dict):
return data
data = dict(data)
# Validate either/or constraints for grid parameters
number_of_cells = data.get('number_of_cells')
nx = data.get('nx')
ny = data.get('ny')
if (number_of_cells is None) == ((nx is not None) and (ny is not None)):
raise ValueError('Exactly one of number_of_cells or (nx and ny) must be specified')
lower_bound = data.get('lower_bound')
xmin = data.get('xmin')
ymin = data.get('ymin')
if (lower_bound is None) == ((xmin is not None) and (ymin is not None)):
raise ValueError('Exactly one of lower_bound or (xmin and ymin) must be specified')
upper_bound = data.get('upper_bound')
xmax = data.get('xmax')
ymax = data.get('ymax')
if (upper_bound is None) == ((xmax is not None) and (ymax is not None)):
raise ValueError('Exactly one of upper_bound or (xmax and ymax) must be specified')
lower_boundary_conditions = data.get('lower_boundary_conditions')
bc_xmin = data.get('bc_xmin')
bc_ymin = data.get('bc_ymin')
if (lower_boundary_conditions is None) == ((bc_xmin is not None) and (bc_ymin is not None)):
raise ValueError('Exactly one of lower_boundary_conditions or (bc_xmin and bc_ymin) must be specified')
upper_boundary_conditions = data.get('upper_boundary_conditions')
bc_xmax = data.get('bc_xmax')
bc_ymax = data.get('bc_ymax')
if (upper_boundary_conditions is None) == ((bc_xmax is not None) and (bc_ymax is not None)):
raise ValueError('Exactly one of upper_boundary_conditions or (bc_xmax and bc_ymax) must be specified')
# Convert individual parameters to vectors if vectors not provided
if number_of_cells is None and nx is not None and ny is not None:
data['number_of_cells'] = [nx, ny]
data.pop('nx', None)
data.pop('ny', None)
elif number_of_cells is not None:
data.pop('nx', None)
data.pop('ny', None)
if lower_bound is None and xmin is not None and ymin is not None:
data['lower_bound'] = [xmin, ymin]
data.pop('xmin', None)
data.pop('ymin', None)
elif lower_bound is not None:
data.pop('xmin', None)
data.pop('ymin', None)
if upper_bound is None and xmax is not None and ymax is not None:
data['upper_bound'] = [xmax, ymax]
data.pop('xmax', None)
data.pop('ymax', None)
elif upper_bound is not None:
data.pop('xmax', None)
data.pop('ymax', None)
if lower_boundary_conditions is None and bc_xmin is not None and bc_ymin is not None:
data['lower_boundary_conditions'] = [bc_xmin, bc_ymin]
data.pop('bc_xmin', None)
data.pop('bc_ymin', None)
elif lower_boundary_conditions is not None:
data.pop('bc_xmin', None)
data.pop('bc_ymin', None)
if upper_boundary_conditions is None and bc_xmax is not None and bc_ymax is not None:
data['upper_boundary_conditions'] = [bc_xmax, bc_ymax]
data.pop('bc_xmax', None)
data.pop('bc_ymax', None)
elif upper_boundary_conditions is not None:
data.pop('bc_xmax', None)
data.pop('bc_ymax', None)
# Handle particle boundaries
if data.get('lower_bound_particles') is None:
xmin_p = data.get('xmin_particles')
ymin_p = data.get('ymin_particles')
if xmin_p is not None or ymin_p is not None:
data['lower_bound_particles'] = [xmin_p, ymin_p]
data.pop('xmin_particles', None)
data.pop('ymin_particles', None)
if data.get('upper_bound_particles') is None:
xmax_p = data.get('xmax_particles')
ymax_p = data.get('ymax_particles')
if xmax_p is not None or ymax_p is not None:
data['upper_bound_particles'] = [xmax_p, ymax_p]
data.pop('xmax_particles', None)
data.pop('ymax_particles', None)
if data.get('lower_boundary_conditions_particles') is None:
bc_xmin_p = data.get('bc_xmin_particles')
bc_ymin_p = data.get('bc_ymin_particles')
if bc_xmin_p is not None or bc_ymin_p is not None:
data['lower_boundary_conditions_particles'] = [bc_xmin_p, bc_ymin_p]
data.pop('bc_xmin_particles', None)
data.pop('bc_ymin_particles', None)
if data.get('upper_boundary_conditions_particles') is None:
bc_xmax_p = data.get('bc_xmax_particles')
bc_ymax_p = data.get('bc_ymax_particles')
if bc_xmax_p is not None or bc_ymax_p is not None:
data['upper_boundary_conditions_particles'] = [bc_xmax_p, bc_ymax_p]
data.pop('bc_xmax_particles', None)
data.pop('bc_ymax_particles', None)
return data
@model_validator(mode='after')
def _validate_and_normalize(self) -> Self:
"""Validate dimensions and normalize particle boundaries"""
# At this point, vectors should already be set by mode='before' validator
# Handle particle boundaries - default to field boundaries if not specified
if self.lower_bound_particles is None:
if self.xmin_particles is None and self.ymin_particles is None:
self.lower_bound_particles = list(self.lower_bound)
else:
self.lower_bound_particles = [self.xmin_particles, self.ymin_particles]
self.xmin_particles = None
self.ymin_particles = None
if self.upper_bound_particles is None:
if self.xmax_particles is None and self.ymax_particles is None:
self.upper_bound_particles = list(self.upper_bound)
else:
self.upper_bound_particles = [self.xmax_particles, self.ymax_particles]
self.xmax_particles = None
self.ymax_particles = None
if self.lower_boundary_conditions_particles is None:
if self.bc_xmin_particles is None and self.bc_ymin_particles is None:
self.lower_boundary_conditions_particles = list(self.lower_boundary_conditions)
else:
self.lower_boundary_conditions_particles = [self.bc_xmin_particles, self.bc_ymin_particles]
self.bc_xmin_particles = None
self.bc_ymin_particles = None
if self.upper_boundary_conditions_particles is None:
if self.bc_xmax_particles is None and self.bc_ymax_particles is None:
self.upper_boundary_conditions_particles = list(self.upper_boundary_conditions)
else:
self.upper_boundary_conditions_particles = [self.bc_xmax_particles, self.bc_ymax_particles]
self.bc_xmax_particles = None
self.bc_ymax_particles = None
# Dimensions are validated by Field(min_length=2, max_length=2) constraints
# Process refined regions
for region in self.refined_regions:
if len(region) == 3:
region.append([2, 2])
if len(region[1]) != 2:
raise ValueError('The lo extent of the refined region must be a vector of length 2')
if len(region[2]) != 2:
raise ValueError('The hi extent of the refined region must be a vector of length 2')
if len(region[3]) != 2:
raise ValueError('The refinement factor of the refined region must be a vector of length 2')
return self
[docs]
def add_refined_region(self, level, lo, hi, refinement_factor=[2, 2]):
"""Add a refined region.
level: the refinement level, with 1 being the first level of refinement, 2 being the second etc.
lo, hi: vectors of length 2 specifying the extent of the region
refinement_factor: defaulting to [2,2] (relative to next lower level)
"""
self.refined_regions.append([level, lo, hi, refinement_factor])
[docs]
class PICMI_Cartesian3DGrid(_ClassWithInit):
"""
Three dimensional Cartesian grid.
Parameters can be specified either as vectors or separately.
(If both are specified, the vector is used.)
References
----------
absorbing_silver_mueller: A local absorbing boundary condition that works best under normal incidence angle.
Based on the Silver-Mueller Radiation Condition, e.g., in
* A. K. Belhora and L. Pichon, "Maybe Efficient Absorbing Boundary Conditions for the Finite Element Solution of 3D Scattering Problems," 1995,
https://doi.org/10.1109/20.376322
* B Engquist and A. Majdat, "Absorbing boundary conditions for numerical simulation of waves," 1977,
https://doi.org/10.1073/pnas.74.5.1765
* R. Lehe, "Electromagnetic wave propagation in Particle-In-Cell codes," 2016,
US Particle Accelerator School (USPAS) Summer Session, Self-Consistent Simulations of Beam and Plasma Systems
https://people.nscl.msu.edu/~lund/uspas/scs_2016/lec_adv/A1b_EM_Waves.pdf
"""
number_of_dimensions: int = 3
# Vector form parameters (preferred)
number_of_cells: Sequence[int] | None = Field(
default=None,
min_length=3,
max_length=3,
description="Vector of integers. Number of cells along each axis (number of nodes is number_of_cells+1). Either this or nx, ny, and nz must be specified (not both)."
)
lower_bound: Sequence[float] | None = Field(
default=None,
min_length=3,
max_length=3,
description="Vector of floats. Position of the node at the lower bound [m]. Either this or xmin, ymin, and zmin must be specified (not both)."
)
upper_bound: Sequence[float] | None = Field(
default=None,
min_length=3,
max_length=3,
description="Vector of floats. Position of the node at the upper bound [m]. Either this or xmax, ymax, and zmax must be specified (not both)."
)
lower_boundary_conditions: Sequence[str] | None = Field(
default=None,
min_length=3,
max_length=3,
description="Vector of strings. Conditions at lower boundaries: periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this or bc_xmin, bc_ymin, and bc_zmin must be specified (not both)."
)
upper_boundary_conditions: Sequence[str] | None = Field(
default=None,
min_length=3,
max_length=3,
description="Vector of strings. Conditions at upper boundaries: periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this or bc_xmax, bc_ymax, and bc_zmax must be specified (not both)."
)
# Individual named parameters (alternative form)
nx: int | None = Field(
default=None,
description="Integer. Number of cells along X (number of nodes=nx+1). Either this (along with ny and nz) or number_of_cells must be specified (not both)."
)
ny: int | None = Field(
default=None,
description="Integer. Number of cells along Y (number of nodes=ny+1). Either this (along with nx and nz) or number_of_cells must be specified (not both)."
)
nz: int | None = Field(
default=None,
description="Integer. Number of cells along Z (number of nodes=nz+1). Either this (along with nx and ny) or number_of_cells must be specified (not both)."
)
xmin: float | None = Field(
default=None,
description="Float. Position of first node along X [m]. Either this (along with ymin and zmin) or lower_bound must be specified (not both)."
)
xmax: float | None = Field(
default=None,
description="Float. Position of last node along X [m]. Either this (along with ymax and zmax) or upper_bound must be specified (not both)."
)
ymin: float | None = Field(
default=None,
description="Float. Position of first node along Y [m]. Either this (along with xmin and zmin) or lower_bound must be specified (not both)."
)
ymax: float | None = Field(
default=None,
description="Float. Position of last node along Y [m]. Either this (along with xmax and zmax) or upper_bound must be specified (not both)."
)
zmin: float | None = Field(
default=None,
description="Float. Position of first node along Z [m]. Either this (along with xmin and ymin) or lower_bound must be specified (not both)."
)
zmax: float | None = Field(
default=None,
description="Float. Position of last node along Z [m]. Either this (along with xmax and ymax) or upper_bound must be specified (not both)."
)
bc_xmin: str | None = Field(
default=None,
description="String. Boundary condition at min X: One of periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this (along with bc_ymin and bc_zmin) or lower_boundary_conditions must be specified (not both)."
)
bc_xmax: str | None = Field(
default=None,
description="String. Boundary condition at max X: One of periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this (along with bc_ymax and bc_zmax) or upper_boundary_conditions must be specified (not both)."
)
bc_ymin: str | None = Field(
default=None,
description="String. Boundary condition at min Y: One of periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this (along with bc_xmin and bc_zmin) or lower_boundary_conditions must be specified (not both)."
)
bc_ymax: str | None = Field(
default=None,
description="String. Boundary condition at max Y: One of periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this (along with bc_xmax and bc_zmax) or upper_boundary_conditions must be specified (not both)."
)
bc_zmin: str | None = Field(
default=None,
description="String. Boundary condition at min Z: One of periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this (along with bc_xmin and bc_ymin) or lower_boundary_conditions must be specified (not both)."
)
bc_zmax: str | None = Field(
default=None,
description="String. Boundary condition at max Z: One of periodic, open, dirichlet, absorbing_silver_mueller, or neumann. Either this (along with bc_xmax and bc_ymax) or upper_boundary_conditions must be specified (not both)."
)
# Particle boundary parameters (vector form)
lower_bound_particles: Sequence[float] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of floats, optional. Position of particle lower bound [m]. By default, if not specified, particle boundary values are the same as field boundary values."
)
upper_bound_particles: Sequence[float] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of floats, optional. Position of particle upper bound [m]. By default, if not specified, particle boundary values are the same as field boundary values."
)
lower_boundary_conditions_particles: Sequence[str] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of strings, optional. Conditions at lower boundaries for particles: periodic, absorbing, reflect or thermal. By default, if not specified, particle boundary conditions are the same as field boundary conditions."
)
upper_boundary_conditions_particles: Sequence[str] | None = Field(
default=None,
min_length=2,
max_length=2,
description="Vector of strings, optional. Conditions at upper boundaries for particles: periodic, absorbing, reflect or thermal. By default, if not specified, particle boundary conditions are the same as field boundary conditions."
)
# Particle boundary parameters (individual form)
xmin_particles: float | None = Field(
default=None,
description="Float, optional. Position of min particle boundary along X [m]."
)
xmax_particles: float | None = Field(
default=None,
description="Float, optional. Position of max particle boundary along X [m]."
)
ymin_particles: float | None = Field(
default=None,
description="Float, optional. Position of min particle boundary along Y [m]."
)
ymax_particles: float | None = Field(
default=None,
description="Float, optional. Position of max particle boundary along Y [m]."
)
zmin_particles: float | None = Field(
default=None,
description="Float, optional. Position of min particle boundary along Z [m]."
)
zmax_particles: float | None = Field(
default=None,
description="Float, optional. Position of max particle boundary along Z [m]."
)
bc_xmin_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at min X for particles: One of periodic, absorbing, reflect, thermal."
)
bc_xmax_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at max X for particles: One of periodic, absorbing, reflect, thermal."
)
bc_ymin_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at min Y for particles: One of periodic, absorbing, reflect, thermal."
)
bc_ymax_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at max Y for particles: One of periodic, absorbing, reflect, thermal."
)
bc_zmin_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at min Z for particles: One of periodic, absorbing, reflect, thermal."
)
bc_zmax_particles: str | None = Field(
default=None,
description="String, optional. Boundary condition at max Z for particles: One of periodic, absorbing, reflect, thermal."
)
# Other parameters
moving_window_velocity: Sequence[float] | None = Field(
default=None,
description="Vector of floats, optional. Moving frame velocity [m/s]"
)
refined_regions: list[list] = Field(
default_factory=list,
description="List of lists, optional. List of refined regions, each element being a list of the format [level, lo, hi, refinement_factor], with level being the refinement level (1 being first, 2 being second etc), lo and hi being vectors of length 3 specifying the extent of the region, and refinement_factor defaulting to [2,2,2] (relative to next lower level)."
)
guard_cells: Sequence[int] | None = Field(
default=None,
description="Vector of integers, optional. Number of guard cells used along each direction"
)
pml_cells: Sequence[int] | None = Field(
default=None,
description="Vector of integers, optional. Number of Perfectly Matched Layer (PML) cells along each direction"
)
@model_validator(mode='before')
@classmethod
def _normalize_parameters(cls, data: Any) -> Any:
"""Normalize individual parameters to vector form before validation"""
if not isinstance(data, dict):
return data
data = dict(data)
# Validate either/or constraints for grid parameters
number_of_cells = data.get('number_of_cells')
nx = data.get('nx')
ny = data.get('ny')
nz = data.get('nz')
if (number_of_cells is None) == ((nx is not None) and (ny is not None) and (nz is not None)):
raise ValueError('Exactly one of number_of_cells or (nx, ny, and nz) must be specified')
lower_bound = data.get('lower_bound')
xmin = data.get('xmin')
ymin = data.get('ymin')
zmin = data.get('zmin')
if (lower_bound is None) == ((xmin is not None) and (ymin is not None) and (zmin is not None)):
raise ValueError('Exactly one of lower_bound or (xmin, ymin, and zmin) must be specified')
upper_bound = data.get('upper_bound')
xmax = data.get('xmax')
ymax = data.get('ymax')
zmax = data.get('zmax')
if (upper_bound is None) == ((xmax is not None) and (ymax is not None) and (zmax is not None)):
raise ValueError('Exactly one of upper_bound or (xmax, ymax, and zmax) must be specified')
lower_boundary_conditions = data.get('lower_boundary_conditions')
bc_xmin = data.get('bc_xmin')
bc_ymin = data.get('bc_ymin')
bc_zmin = data.get('bc_zmin')
if (lower_boundary_conditions is None) == ((bc_xmin is not None) and (bc_ymin is not None) and (bc_zmin is not None)):
raise ValueError('Exactly one of lower_boundary_conditions or (bc_xmin, bc_ymin, and bc_zmin) must be specified')
upper_boundary_conditions = data.get('upper_boundary_conditions')
bc_xmax = data.get('bc_xmax')
bc_ymax = data.get('bc_ymax')
bc_zmax = data.get('bc_zmax')
if (upper_boundary_conditions is None) == ((bc_xmax is not None) and (bc_ymax is not None) and (bc_zmax is not None)):
raise ValueError('Exactly one of upper_boundary_conditions or (bc_xmax, bc_ymax, and bc_zmax) must be specified')
# Convert individual parameters to vectors if vectors not provided
if number_of_cells is None and nx is not None and ny is not None and nz is not None:
data['number_of_cells'] = [nx, ny, nz]
data.pop('nx', None)
data.pop('ny', None)
data.pop('nz', None)
elif number_of_cells is not None:
data.pop('nx', None)
data.pop('ny', None)
data.pop('nz', None)
if lower_bound is None and xmin is not None and ymin is not None and zmin is not None:
data['lower_bound'] = [xmin, ymin, zmin]
data.pop('xmin', None)
data.pop('ymin', None)
data.pop('zmin', None)
elif lower_bound is not None:
data.pop('xmin', None)
data.pop('ymin', None)
data.pop('zmin', None)
if upper_bound is None and xmax is not None and ymax is not None and zmax is not None:
data['upper_bound'] = [xmax, ymax, zmax]
data.pop('xmax', None)
data.pop('ymax', None)
data.pop('zmax', None)
elif upper_bound is not None:
data.pop('xmax', None)
data.pop('ymax', None)
data.pop('zmax', None)
if lower_boundary_conditions is None and bc_xmin is not None and bc_ymin is not None and bc_zmin is not None:
data['lower_boundary_conditions'] = [bc_xmin, bc_ymin, bc_zmin]
data.pop('bc_xmin', None)
data.pop('bc_ymin', None)
data.pop('bc_zmin', None)
elif lower_boundary_conditions is not None:
data.pop('bc_xmin', None)
data.pop('bc_ymin', None)
data.pop('bc_zmin', None)
if upper_boundary_conditions is None and bc_xmax is not None and bc_ymax is not None and bc_zmax is not None:
data['upper_boundary_conditions'] = [bc_xmax, bc_ymax, bc_zmax]
data.pop('bc_xmax', None)
data.pop('bc_ymax', None)
data.pop('bc_zmax', None)
elif upper_boundary_conditions is not None:
data.pop('bc_xmax', None)
data.pop('bc_ymax', None)
data.pop('bc_zmax', None)
# Handle particle boundaries
if data.get('lower_bound_particles') is None:
xmin_p = data.get('xmin_particles')
ymin_p = data.get('ymin_particles')
zmin_p = data.get('zmin_particles')
if xmin_p is not None or ymin_p is not None or zmin_p is not None:
data['lower_bound_particles'] = [xmin_p, ymin_p, zmin_p]
data.pop('xmin_particles', None)
data.pop('ymin_particles', None)
data.pop('zmin_particles', None)
if data.get('upper_bound_particles') is None:
xmax_p = data.get('xmax_particles')
ymax_p = data.get('ymax_particles')
zmax_p = data.get('zmax_particles')
if xmax_p is not None or ymax_p is not None or zmax_p is not None:
data['upper_bound_particles'] = [xmax_p, ymax_p, zmax_p]
data.pop('xmax_particles', None)
data.pop('ymax_particles', None)
data.pop('zmax_particles', None)
if data.get('lower_boundary_conditions_particles') is None:
bc_xmin_p = data.get('bc_xmin_particles')
bc_ymin_p = data.get('bc_ymin_particles')
bc_zmin_p = data.get('bc_zmin_particles')
if bc_xmin_p is not None or bc_ymin_p is not None or bc_zmin_p is not None:
data['lower_boundary_conditions_particles'] = [bc_xmin_p, bc_ymin_p, bc_zmin_p]
data.pop('bc_xmin_particles', None)
data.pop('bc_ymin_particles', None)
data.pop('bc_zmin_particles', None)
if data.get('upper_boundary_conditions_particles') is None:
bc_xmax_p = data.get('bc_xmax_particles')
bc_ymax_p = data.get('bc_ymax_particles')
bc_zmax_p = data.get('bc_zmax_particles')
if bc_xmax_p is not None or bc_ymax_p is not None or bc_zmax_p is not None:
data['upper_boundary_conditions_particles'] = [bc_xmax_p, bc_ymax_p, bc_zmax_p]
data.pop('bc_xmax_particles', None)
data.pop('bc_ymax_particles', None)
data.pop('bc_zmax_particles', None)
return data
@model_validator(mode='after')
def _validate_and_normalize(self) -> Self:
"""Validate dimensions and normalize particle boundaries"""
# At this point, vectors should already be set by mode='before' validator
# Handle particle boundaries - default to field boundaries if not specified
if self.lower_bound_particles is None:
if self.xmin_particles is None and self.ymin_particles is None and self.zmin_particles is None:
self.lower_bound_particles = list(self.lower_bound)
else:
self.lower_bound_particles = [self.xmin_particles, self.ymin_particles, self.zmin_particles]
self.xmin_particles = None
self.ymin_particles = None
self.zmin_particles = None
if self.upper_bound_particles is None:
if self.xmax_particles is None and self.ymax_particles is None and self.zmax_particles is None:
self.upper_bound_particles = list(self.upper_bound)
else:
self.upper_bound_particles = [self.xmax_particles, self.ymax_particles, self.zmax_particles]
self.xmax_particles = None
self.ymax_particles = None
self.zmax_particles = None
if self.lower_boundary_conditions_particles is None:
if self.bc_xmin_particles is None and self.bc_ymin_particles is None and self.bc_zmin_particles is None:
self.lower_boundary_conditions_particles = list(self.lower_boundary_conditions)
else:
self.lower_boundary_conditions_particles = [self.bc_xmin_particles, self.bc_ymin_particles, self.bc_zmin_particles]
self.bc_xmin_particles = None
self.bc_ymin_particles = None
self.bc_zmin_particles = None
if self.upper_boundary_conditions_particles is None:
if self.bc_xmax_particles is None and self.bc_ymax_particles is None and self.bc_zmax_particles is None:
self.upper_boundary_conditions_particles = list(self.upper_boundary_conditions)
else:
self.upper_boundary_conditions_particles = [self.bc_xmax_particles, self.bc_ymax_particles, self.bc_zmax_particles]
self.bc_xmax_particles = None
self.bc_ymax_particles = None
self.bc_zmax_particles = None
# Dimensions are validated by Field(min_length=3, max_length=3) constraints
# Process refined regions
for region in self.refined_regions:
if len(region) == 3:
region.append([2, 2, 2])
if len(region[1]) != 3:
raise ValueError('The lo extent of the refined region must be a vector of length 3')
if len(region[2]) != 3:
raise ValueError('The hi extent of the refined region must be a vector of length 3')
if len(region[3]) != 3:
raise ValueError('The refinement factor of the refined region must be a vector of length 3')
return self
[docs]
def add_refined_region(self, level, lo, hi, refinement_factor=[2, 2, 2]):
"""Add a refined region.
Parameters
----------
level: integer
The refinement level, with 1 being the first level of refinement, 2 being the second etc.
lo, hi: vectors of floats
Each is a vector of length 3 specifying the extent of the region
refinement_factor: vector of integers, optional
Defaulting to [2,2,2] (relative to next lower level)
"""
self.refined_regions.append([level, lo, hi, refinement_factor])