diff --git a/lib/python/picongpu/picmi/diagnostics/__init__.py b/lib/python/picongpu/picmi/diagnostics/__init__.py index 9fc207e6af..de48aa0e06 100644 --- a/lib/python/picongpu/picmi/diagnostics/__init__.py +++ b/lib/python/picongpu/picmi/diagnostics/__init__.py @@ -12,7 +12,10 @@ from .macro_particle_count import MacroParticleCount from .png import Png from .timestepspec import TimeStepSpec +from .rangespec import RangeSpec from .checkpoint import Checkpoint +from .openpmd import OpenPMD +from .openpmd_sources import SourceBase __all__ = [ "Auto", @@ -22,5 +25,8 @@ "MacroParticleCount", "Png", "TimeStepSpec", + "RangeSpec", "Checkpoint", + "OpenPMD", + "SourceBase", ] diff --git a/lib/python/picongpu/picmi/diagnostics/auto.py b/lib/python/picongpu/picmi/diagnostics/auto.py index 20f0134f64..d9233bb221 100644 --- a/lib/python/picongpu/picmi/diagnostics/auto.py +++ b/lib/python/picongpu/picmi/diagnostics/auto.py @@ -1,18 +1,16 @@ """ This file is part of PIConGPU. Copyright 2025 PIConGPU contributors -Authors: Pawel Ordyna +Authors: Pawel Ordyna, Masoud Afshari License: GPLv3+ """ - from ...pypicongpu.output.auto import Auto as PyPIConGPUAuto from ...pypicongpu.species.species import Species as PyPIConGPUSpecies - from ..species import Species as PICMISpecies from .timestepspec import TimeStepSpec - import typeguard +import warnings @typeguard.typechecked @@ -22,28 +20,39 @@ class Auto: Parameters ---------- - period: int - Number of simulation steps between consecutive outputs. + period: int or TimeStepSpec + Number of simulation steps between consecutive outputs (e.g., 10 for every 10 steps). + Use 0 to disable output. + Alternatively, a TimeStepSpec can be provided for PIConGPU-specific step selection + (e.g., TimeStepSpec[5, 10], TimeStepSpec[-10:]). Unit: steps (simulation time steps). """ - period: TimeStepSpec - """Number of simulation steps between consecutive outputs. Unit: steps (simulation time steps).""" - - def __init__(self, period: TimeStepSpec) -> None: - self.period = period + def __init__(self, period: int | TimeStepSpec) -> None: + if not isinstance(period, (int, TimeStepSpec)): + raise TypeError("period must be an integer or TimeStepSpec") + if isinstance(period, int): + if period < 0: + raise ValueError("period must be non-negative") + self.period = TimeStepSpec[::period]("steps") if period > 0 else TimeStepSpec()("steps") + else: + self.period = period + if self.period.unit_system is None: + self.period = self.period("steps") def check(self): - pass + if not self.period.get_as_pypicongpu(1.0, 100).get_rendering_context().get("specs", []): + warnings.warn("Auto output is disabled because period is set to 0 or an empty TimeStepSpec") def get_as_pypicongpu( self, - # not used here, but needed for the interface dict_species_picmi_to_pypicongpu: dict[PICMISpecies, PyPIConGPUSpecies], time_step_size, num_steps, + simulation_box=None, # Added to match OpenPMD signature, not used ) -> PyPIConGPUAuto: self.check() pypicongpu_auto = PyPIConGPUAuto() pypicongpu_auto.period = self.period.get_as_pypicongpu(time_step_size, num_steps) + return pypicongpu_auto diff --git a/lib/python/picongpu/picmi/diagnostics/checkpoint.py b/lib/python/picongpu/picmi/diagnostics/checkpoint.py index 160a856265..81e76ee870 100644 --- a/lib/python/picongpu/picmi/diagnostics/checkpoint.py +++ b/lib/python/picongpu/picmi/diagnostics/checkpoint.py @@ -7,9 +7,9 @@ from ...pypicongpu.output.checkpoint import Checkpoint as PyPIConGPUCheckpoint from .timestepspec import TimeStepSpec - import typeguard -from typing import Optional, Dict +import warnings +from typing import Optional, Union, Dict @typeguard.typechecked @@ -24,62 +24,42 @@ class Checkpoint: Parameters ---------- - period: TimeStepSpec, optional - Specify on which time steps to create checkpoints. - Unit: steps (simulation time steps). Required if timePeriod is not provided. - + period: int or TimeStepSpec, optional + Number of simulation steps between consecutive checkpoints (e.g., 10 for every 10 steps). + Use 0 to disable checkpointing. + Alternatively, a TimeStepSpec can be provided for specific step selection + (e.g., TimeStepSpec([5, 10]), TimeStepSpec([slice(-10, None, 1)])). + Unit: steps or seconds (via TimeStepSpec unit). timePeriod: int, optional - Specify the interval in minutes for creating checkpoints. - Unit: minutes (must be a non-negative integer). Required if period is not provided. - + Time interval between checkpoints in simulation time steps. + Use 0 or None to disable time-based checkpointing. directory: str, optional - Directory inside simOutput for writing checkpoints (default: "checkpoints"). - + Directory inside simOutput for writing checkpoints (default: "checkpoints"). file: str, optional - Relative or absolute fileset prefix for checkpoint files. - + Relative or absolute fileset prefix for checkpoint files. Default: None. restart: bool, optional - If True, restart simulation from the latest checkpoint. - + Enable restarting from checkpoints. Default: None. tryRestart: bool, optional - If True, restart from the latest checkpoint if available, else start from scratch. - + If True, restart from the latest checkpoint if available, else start from scratch. Default: None. restartStep: int, optional - Specific checkpoint step to restart from. - + Specific step to restart from. Default: None. restartDirectory: str, optional - Directory inside simOutput containing checkpoints for restart (default: "checkpoints"). - + Directory inside simOutput containing checkpoints for restart. restartFile: str, optional - Relative or absolute fileset prefix for reading checkpoints. - + Specific file to restart from. Default: None. restartChunkSize: int, optional - Number of particles processed in one kernel call during restart. - + Chunk size for reading restart data. Default: None. restartLoop: int, optional - Number of times to restart the simulation after it finishes. - - openPMD: Dict, optional - Dictionary of openPMD-specific settings (e.g., ext, json, infix). + Number of restart loops. Default: None. + openPMD: dict, optional + Configuration for openPMD output (e.g., {"ext": "h5"}). Default: None. """ - def check(self): - if self.period is None and self.timePeriod is None: - raise ValueError("At least one of period or timePeriod must be provided") - if self.timePeriod is not None and self.timePeriod < 0: - raise ValueError("timePeriod must be a non-negative integer") - if self.restartStep is not None and self.restartStep < 0: - raise ValueError("restartStep must be non-negative") - if self.restartChunkSize is not None and self.restartChunkSize < 1: - raise ValueError("restartChunkSize must be positive") - if self.restartLoop is not None and self.restartLoop < 0: - raise ValueError("restartLoop must be non-negative") - def __init__( self, - period: Optional[TimeStepSpec] = None, + period: Optional[Union[int, TimeStepSpec]] = None, timePeriod: Optional[int] = None, - directory: Optional[str] = None, + directory: Optional[str] = "checkpoints", file: Optional[str] = None, restart: Optional[bool] = None, tryRestart: Optional[bool] = None, @@ -90,7 +70,16 @@ def __init__( restartLoop: Optional[int] = None, openPMD: Optional[Dict] = None, ): - self.period = period + if period is None and timePeriod is None: + raise ValueError("At least one of period or timePeriod must be provided to enable checkpointing") + if period is not None and not isinstance(period, (int, TimeStepSpec)): + raise TypeError("period must be an integer or TimeStepSpec") + if isinstance(period, int): + if period < 0: + raise ValueError("period must be non-negative") + self.period = TimeStepSpec([slice(None, None, period)] if period > 0 else [])("steps") + else: + self.period = period if period is not None else TimeStepSpec([])("steps") self.timePeriod = timePeriod self.directory = directory self.file = file @@ -102,19 +91,32 @@ def __init__( self.restartChunkSize = restartChunkSize self.restartLoop = restartLoop self.openPMD = openPMD + self.check() + + def check(self): + if self.timePeriod is not None and (not isinstance(self.timePeriod, int) or self.timePeriod < 0): + raise ValueError("timePeriod must be a non-negative integer") + if self.restartStep is not None and self.restartStep < 0: + raise ValueError("restartStep must be non-negative") + if self.restartChunkSize is not None and self.restartChunkSize <= 0: + raise ValueError("restartChunkSize must be positive") + if self.restartLoop is not None and self.restartLoop < 0: + raise ValueError("restartLoop must be non-negative") + if not self.period.specs and (self.timePeriod is None or self.timePeriod == 0): + warnings.warn( + "Checkpoint is disabled because period is set to 0 or an empty TimeStepSpec and timePeriod is None or 0" + ) def get_as_pypicongpu( self, pypicongpu_by_picmi_species: Dict, time_step_size: float, num_steps: int, + simulation_box=None, # Added to match OpenPMD signature, not used ) -> PyPIConGPUCheckpoint: self.check() - pypicongpu_checkpoint = PyPIConGPUCheckpoint() - pypicongpu_checkpoint.period = ( - self.period.get_as_pypicongpu(time_step_size, num_steps) if self.period is not None else None - ) + pypicongpu_checkpoint.period = self.period.get_as_pypicongpu(time_step_size, num_steps) if self.period else None pypicongpu_checkpoint.timePeriod = self.timePeriod pypicongpu_checkpoint.directory = self.directory pypicongpu_checkpoint.file = self.file @@ -126,5 +128,4 @@ def get_as_pypicongpu( pypicongpu_checkpoint.restartChunkSize = self.restartChunkSize pypicongpu_checkpoint.restartLoop = self.restartLoop pypicongpu_checkpoint.openPMD = self.openPMD - return pypicongpu_checkpoint diff --git a/lib/python/picongpu/picmi/diagnostics/energy_histogram.py b/lib/python/picongpu/picmi/diagnostics/energy_histogram.py index 28602556f6..29c3d1a7a8 100644 --- a/lib/python/picongpu/picmi/diagnostics/energy_histogram.py +++ b/lib/python/picongpu/picmi/diagnostics/energy_histogram.py @@ -1,6 +1,6 @@ """ This file is part of PIConGPU. -Copyright 2021-2024 PIConGPU contributors +Copyright 2021-2025 PIConGPU contributors Authors: Masoud Afshari License: GPLv3+ """ @@ -10,11 +10,9 @@ EnergyHistogram as PyPIConGPUEnergyHistogram, ) from ...pypicongpu.species.species import Species as PyPIConGPUSpecies - - from ..species import Species as PICMISpecies - import typeguard +from typing import Union @typeguard.typechecked @@ -27,75 +25,71 @@ class EnergyHistogram: Parameters ---------- - species: string - Name of the particle species to track (e.g., "electron", "proton"). - - period: int - Number of simulation steps between consecutive outputs. - If set to a non-zero value, the energy histogram of all electrons is computed. - By default, the value is 0 and no histogram for the electrons is computed. - Unit: steps (simulation time steps). - + species: PICMISpecies + Particle species to count (e.g., an instance with name="electron" or "proton"). + period: int or TimeStepSpec + Number of simulation steps between consecutive outputs (e.g., 10 for every 10 steps). + Use 0 to disable output. Alternatively, a TimeStepSpec can be provided for + PyPIConGPU-specific step selection (e.g., TimeStepSpec([slice(0, None, 10)])). bin_count: int - Number of bins for the energy histogram. - + Number of bins for the energy histogram. Must be positive. min_energy: float Minimum value for the energy histogram range. Unit: keV - Default is 0, meaning 0 keV. - max_energy: float - Maximum value for the energy histogram range. + Maximum value for the energy histogram range. Must be greater than min_energy. Unit: keV - There is no default value. - - name: string, optional - Optional name for the energy histogram plugin. """ - def check(self): - if self.min_energy >= self.max_energy: - raise ValueError("min_energy must be less than max_energy") - if self.bin_count <= 0: - raise ValueError("bin_count must be > 0") - def __init__( self, species: PICMISpecies, - period: TimeStepSpec, + period: Union[int, TimeStepSpec], bin_count: int, min_energy: float, max_energy: float, ): + if isinstance(period, int): + if period < 0: + raise ValueError("period must be non-negative") + self.period = TimeStepSpec([slice(None, None, period)]) if period > 0 else TimeStepSpec() + else: + self.period = period self.species = species - self.period = period self.bin_count = bin_count self.min_energy = min_energy self.max_energy = max_energy + def check(self): + if not isinstance(self.species, PICMISpecies): + raise TypeError("species must be a PICMISpecies") + if not isinstance(self.species.name, str) or not self.species.name: + raise TypeError("species must have a non-empty name") + if not isinstance(self.period, TimeStepSpec): + raise TypeError("period must be a TimeStepSpec") + if self.bin_count <= 0: + raise ValueError("bin_count must be > 0") + if self.min_energy >= self.max_energy: + raise ValueError("min_energy must be less than max_energy") + def get_as_pypicongpu( - # to get the corresponding PyPIConGPUSpecies instance for the given PICMISpecies. self, dict_species_picmi_to_pypicongpu: dict[PICMISpecies, PyPIConGPUSpecies], - time_step_size, - num_steps, + time_step_size: float, + num_steps: int, + simulation_box=None, # Added to match OpenPMD signature, not used ) -> PyPIConGPUEnergyHistogram: self.check() - - if self.species not in dict_species_picmi_to_pypicongpu.keys(): + if self.species not in dict_species_picmi_to_pypicongpu: raise ValueError(f"Species {self.species} is not known to Simulation") - - # checks if PICMISpecies instance exists in the dictionary. If yes, it returns the corresponding PyPIConGPUSpecies instance. - pypicongpu_species = dict_species_picmi_to_pypicongpu.get(self.species) - + pypicongpu_species = dict_species_picmi_to_pypicongpu[self.species] if pypicongpu_species is None: raise ValueError(f"Species {self.species} is not mapped to a PyPIConGPUSpecies.") - - pypicongpu_energy_histogram = PyPIConGPUEnergyHistogram() - pypicongpu_energy_histogram.species = pypicongpu_species - pypicongpu_energy_histogram.period = self.period.get_as_pypicongpu(time_step_size, num_steps) - pypicongpu_energy_histogram.bin_count = self.bin_count - pypicongpu_energy_histogram.min_energy = self.min_energy - pypicongpu_energy_histogram.max_energy = self.max_energy - + pypicongpu_energy_histogram = PyPIConGPUEnergyHistogram( + species=pypicongpu_species, + period=self.period.get_as_pypicongpu(time_step_size, num_steps), + bin_count=self.bin_count, + min_energy=self.min_energy, + max_energy=self.max_energy, + ) return pypicongpu_energy_histogram diff --git a/lib/python/picongpu/picmi/diagnostics/macro_particle_count.py b/lib/python/picongpu/picmi/diagnostics/macro_particle_count.py index 097a4d9a6b..dfd46c85dc 100644 --- a/lib/python/picongpu/picmi/diagnostics/macro_particle_count.py +++ b/lib/python/picongpu/picmi/diagnostics/macro_particle_count.py @@ -1,6 +1,6 @@ """ This file is part of PIConGPU. -Copyright 2021-2024 PIConGPU contributors +Copyright 2021-2025 PIConGPU contributors Authors: Masoud Afshari License: GPLv3+ """ @@ -9,11 +9,12 @@ MacroParticleCount as PyPIConGPUMacroParticleCount, ) from ...pypicongpu.species.species import Species as PyPIConGPUSpecies - from ..species import Species as PICMISpecies from .timestepspec import TimeStepSpec import typeguard +import warnings +from typing import Optional, Dict, Union @typeguard.typechecked @@ -26,42 +27,59 @@ class MacroParticleCount: Parameters ---------- - species: string - Name of the particle species to count (e.g., "electron", "proton"). - - period: int - Number of simulation steps between consecutive counts. - Unit: steps (simulation time steps). - - name: string, optional - Optional name for the macro particle count plugin. + species: PICMISpecies + Particle species to count (e.g., an instance with name="electron" or "proton"). + period: int or TimeStepSpec, optional + Number of simulation steps between consecutive counts (e.g., 10 for every 10 steps). + Use 0 to disable counting. + Alternatively, a TimeStepSpec can be provided for PIConGPU-specific step selection + (e.g., TimeStepSpec([5, 10]), TimeStepSpec([slice(-10, None, 1)])). + If None, defaults to every step (TimeStepSpec([slice(0, None, 1)])). + Unit: steps or seconds (via TimeStepSpec unit). """ def check(self): - pass - - def __init__(self, species: PICMISpecies, period: TimeStepSpec): + if not isinstance(self.species, PICMISpecies): + raise TypeError("species must be a Species") + if ( + self.period is not None + and isinstance(self.period, TimeStepSpec) + and not self.period.get_as_pypicongpu(1.0, 200).get_rendering_context().get("specs", []) + ): + warnings.warn("MacroParticleCount is disabled because period is set to 0 or an empty TimeStepSpec") + + def __init__( + self, + species: PICMISpecies, + period: Optional[Union[int, TimeStepSpec]] = None, + ): + if period is not None and not isinstance(period, (int, TimeStepSpec)): + raise TypeError("period must be an integer or TimeStepSpec") + if isinstance(period, int): + if period < 0: + raise ValueError("period must be non-negative") + self.period = ( + TimeStepSpec([slice(None, None, period)])("steps") if period > 0 else TimeStepSpec([])("steps") + ) + else: + self.period = period if period is not None else TimeStepSpec([slice(0, None, 1)])("steps") self.species = species - self.period = period + self.check() def get_as_pypicongpu( self, - dict_species_picmi_to_pypicongpu: dict[PICMISpecies, PyPIConGPUSpecies], - time_step_size, - num_steps, + dict_species_picmi_to_pypicongpu: Dict[PICMISpecies, PyPIConGPUSpecies], + time_step_size: float, + num_steps: int, + simulation_box=None, # Added to match OpenPMD signature, not used ) -> PyPIConGPUMacroParticleCount: self.check() - - if self.species not in dict_species_picmi_to_pypicongpu.keys(): - raise ValueError(f"Species {self.species} is not known to Simulation") + if self.species not in dict_species_picmi_to_pypicongpu: + raise ValueError(f"Species {self.species.name} is not known to Simulation") pypicongpu_species = dict_species_picmi_to_pypicongpu.get(self.species) - if pypicongpu_species is None: - raise ValueError(f"Species {self.species} is not mapped to a PyPIConGPUSpecies.") - pypicongpu_macro_count = PyPIConGPUMacroParticleCount() pypicongpu_macro_count.species = pypicongpu_species pypicongpu_macro_count.period = self.period.get_as_pypicongpu(time_step_size, num_steps) - return pypicongpu_macro_count diff --git a/lib/python/picongpu/picmi/diagnostics/openpmd.py b/lib/python/picongpu/picmi/diagnostics/openpmd.py new file mode 100644 index 0000000000..db2f27e96e --- /dev/null +++ b/lib/python/picongpu/picmi/diagnostics/openpmd.py @@ -0,0 +1,151 @@ +""" +This file is part of PIConGPU. +Copyright 2021-2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from ...pypicongpu.output.openpmd import OpenPMD as PyPIConGPUOpenPMD +from .timestepspec import TimeStepSpec +from .rangespec import RangeSpec +from .openpmd_sources.source_base import SourceBase +from ..species import Species as PICMISpecies + +import typeguard +from typing import Optional, Dict, Union, List, Literal, Tuple + + +@typeguard.typechecked +class OpenPMD: + """ + openPMD diagnostic output + + This diagnostic writes simulation data (base fields, derived fields and/or particles) to disk using the openPMD + standard, with configurable periods, data sources and backend settings. + + Parameters + ---------- + period: TimeStepSpec + Specification of the time steps for data output, outputs will always be written at the end of a PIC time step. + source: List[SourceBase], optional + List of data source objects to include in the dump (e.g., [ChargeDensity(filter="all")]). + Setting to None will cause an empty dump. + range: str or RangeSpec, optional + Contiguous range of cells to dump the base- and derived field for, specified as a RangeSpec object + Use RangeSpec[start:stop,...] style to specify dimensions (e.g., RangeSpec[0:10, 5:15], RangeSpec[:, :, :]) + file: str, optional + Relative or absolute file path prefix for openPMD output files. Relative paths are interpreted as relative to the + simulation output directory, the default value None indicates the PIC code's default. + ext: str, optional + File extension controlling the openPMD backend, options are "bp" (default backend ADIOS2), "h5" (HDF5), + "sst" (ADIOS2/SST for streaming). Default: "bp". + infix: str, optional + Filename infix for the iteration layout (e.g., "_%06T"), use "NULL" for the group-based layout, + ext="sst" requires infix="NULL". Default: "NULL". + json: str or dict, optional + openPMD backend configuration as a JSON string, dictionary, or filename (filename must be prepended with "@"). + json_restart: str or dict, optional + Backend-specific parameters for restarting, as a JSON string, dictionary, or filename (filenames must be + prepended with "@"). + data_preparation_strategy: str, optional + Strategy for particle data preparation, options: "doubleBuffer" or "adios" (ADIOS2-based), + "mappedMemory" or "hdf5" (HDF5-based), the default value None indicates the PIC code default. + toml: str, optional + Path to a TOML file for openPMD configuration. Replaces the JSON or keyword configuration. + particle_io_chunk_size: int, optional + Size of particle data chunks used in writing (in MiB), reduces host memory footprint for certain backends, + default "None" indicates the PIC code default. + file_writing: str, optional + File writing mode for writing, options: "create" (new files), "append" (for checkpoint-restart workflows). + Default: "create". + """ + + def __init__( + self, + period: TimeStepSpec, + source: Optional[List[SourceBase]] = None, + range: Optional[RangeSpec] = RangeSpec[:, :, :], # default + file: Optional[str] = None, + ext: Optional[Literal["bp", "h5", "sst"]] = "bp", + infix: Optional[str] = "NULL", + json: Optional[Union[str, Dict]] = None, + json_restart: Optional[Union[str, Dict]] = None, + data_preparation_strategy: Optional[Literal["doubleBuffer", "adios", "mappedMemory", "hdf5"]] = None, + toml: Optional[str] = None, + particle_io_chunk_size: Optional[int] = None, + file_writing: Optional[Literal["create", "append"]] = "create", + ): + self.period = period + self.source = source + self.range = range + self.file = file + self.ext = ext + self.infix = infix + self.json = json if json is not None else {} + self.json_restart = json_restart if json_restart is not None else {} + self.data_preparation_strategy = data_preparation_strategy + self.toml = toml + self.particle_io_chunk_size = particle_io_chunk_size + self.file_writing = file_writing + self.check() + + def check(self): + # particle_io_chunk_size must be positive + if self.particle_io_chunk_size is not None and self.particle_io_chunk_size < 1: + raise ValueError("particle_io_chunk_size (in MiB) must be positive") + + # infix must be NULL when using sst backend + if self.ext == "sst" and self.infix is not None and self.infix != "NULL": + raise ValueError("infix must be 'NULL' when ext is 'sst'") + + # validate sources + if self.source is not None: + if not all(isinstance(s, SourceBase) for s in self.source): + raise ValueError("source must be a list of SourceBase objects") + # validate species in sources + for src in self.source: + if hasattr(src, "species") and src.species is not None: + if not isinstance(src.species, PICMISpecies): + raise ValueError(f"Species {src.species} is not known to Simulation") + + # validate period + if not isinstance(self.period, TimeStepSpec): + raise TypeError("period must be a TimeStepSpec") + for s in self.period.specs: + if isinstance(s.step, (int, float)) and s.step < 1: + raise ValueError("Step size must be >= 1") + + # validate range + if not isinstance(self.range, RangeSpec): + raise TypeError("range must be a RangeSpec") + + def get_as_pypicongpu( + self, + dict_species_picmi_to_pypicongpu: Dict, + time_step_size: float, + num_steps: int, + simulation_box: Tuple[int, ...], + ) -> PyPIConGPUOpenPMD: + self.check() + + if len(simulation_box) != len(self.range): + raise ValueError("Number of range specifications must match simulation box dimensions") + + sources = None + if self.source is not None: + sources = [src.get_as_pypicongpu(dict_species_picmi_to_pypicongpu) for src in self.source] + + return PyPIConGPUOpenPMD( + period=self.period.get_as_pypicongpu(time_step_size, num_steps), + source=sources, + range=self.range.get_as_pypicongpu(simulation_box), + file=self.file, + ext=self.ext, + infix=self.infix, + json=self.json, + json_restart=self.json_restart, + data_preparation_strategy=self.data_preparation_strategy, + toml=self.toml, + particle_io_chunk_size=self.particle_io_chunk_size, + file_writing=self.file_writing, + ) diff --git a/lib/python/picongpu/picmi/diagnostics/openpmd_sources/__init__.py b/lib/python/picongpu/picmi/diagnostics/openpmd_sources/__init__.py new file mode 100644 index 0000000000..0bf2d8efbf --- /dev/null +++ b/lib/python/picongpu/picmi/diagnostics/openpmd_sources/__init__.py @@ -0,0 +1,37 @@ +from .sources import ( + Auto, + BoundElectronDensity, + SourceBase, + ChargeDensity, + Counter, + Density, + DerivedAttributes, + Energy, + EnergyDensity, + EnergyDensityCutoff, + LarmorPower, + MacroCounter, + MidCurrentDensityComponent, + Momentum, + MomentumDensity, + WeightedVelocity, +) + +__all__ = [ + "Auto", + "BoundElectronDensity", + "SourceBase", + "ChargeDensity", + "Counter", + "Density", + "DerivedAttributes", + "Energy", + "EnergyDensity", + "EnergyDensityCutoff", + "LarmorPower", + "MacroCounter", + "MidCurrentDensityComponent", + "Momentum", + "MomentumDensity", + "WeightedVelocity", +] diff --git a/lib/python/picongpu/picmi/diagnostics/openpmd_sources/source_base.py b/lib/python/picongpu/picmi/diagnostics/openpmd_sources/source_base.py new file mode 100644 index 0000000000..cc57d592fb --- /dev/null +++ b/lib/python/picongpu/picmi/diagnostics/openpmd_sources/source_base.py @@ -0,0 +1,52 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from abc import ABCMeta, abstractmethod +import typeguard +import typing + + +@typeguard.typechecked +class SourceBase(metaclass=ABCMeta): + """ + Abstract base class for openPMD data sources in particle-in-cell simulations. + + Defines the interface for data sources output via the openPMD standard, + such as charge density or other derived attributes. + + Subclasses must implement the filter property, check method, and get_as_pypicongpu method. + """ + + @property + @abstractmethod + def filter(self) -> typing.Optional[str]: + """ + Name of a filter to select particles contributing to the data source. + + Returns + ------- + str or None + The filter name, or None if no filter is applied. + """ + pass + + @abstractmethod + def check(self) -> None: + """Validate parameters of this source.""" + pass + + @abstractmethod + def get_as_pypicongpu(self, *args, **kwargs) -> typing.Any: + """ + Convert this data source to a PyPIConGPU equivalent. + + Returns + ------- + Any + A PyPIConGPU data source object (e.g., pypicongpu.output.openpmd_source.ChargeDensity). + """ + pass diff --git a/lib/python/picongpu/picmi/diagnostics/openpmd_sources/sources.py b/lib/python/picongpu/picmi/diagnostics/openpmd_sources/sources.py new file mode 100644 index 0000000000..9f558c4065 --- /dev/null +++ b/lib/python/picongpu/picmi/diagnostics/openpmd_sources/sources.py @@ -0,0 +1,317 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +import typing +import typeguard + +from .source_base import SourceBase +from ...species import Species as PICMISpecies +import picongpu.pypicongpu.output.openpmd_sources as pypicongpu_sources + + +# --------------------------------------------------------------------------- +# Base classes +# --------------------------------------------------------------------------- + + +@typeguard.typechecked +class SourceBaseSpeciesFilter(SourceBase): + """Common base for sources that use (species, filter).""" + + def __init__(self, species: PICMISpecies, filter: str = "species_all"): + self.species = species + self._filter = filter + self.check() + + @property + def filter(self) -> str: + return self._filter + + def check(self) -> None: + valid_filters = ["species_all", "fields_all", "custom_filter"] + if not isinstance(self._filter, str): + raise ValueError(f"Filter must be a string, got {type(self._filter)}") + if self._filter not in valid_filters: + raise ValueError(f"Filter must be one of {valid_filters}, got {self._filter}") + if not isinstance(self.species, PICMISpecies): + raise ValueError(f"Species must be a PICMISpecies, got {type(self.species)}") + + def _map_species(self, dict_species_picmi_to_pypicongpu: typing.Dict[PICMISpecies, typing.Any]) -> typing.Any: + try: + return dict_species_picmi_to_pypicongpu[self.species] + except KeyError: + raise ValueError(f"Species {self.species} is not known to Simulation") from None + + def get_as_pypicongpu( + self, + dict_species_picmi_to_pypicongpu: typing.Dict[PICMISpecies, typing.Any], + time_step_size: float = 0.0, + num_steps: int = 0, + simulation_box=None, + ) -> typing.Any: + self.check() + mapped_species = self._map_species(dict_species_picmi_to_pypicongpu) + return getattr(pypicongpu_sources, self.__class__.__name__)( + species=mapped_species, + filter=self._filter, + ) + + +@typeguard.typechecked +class SourceBaseFilterOnly(SourceBase): + """Common base for sources that only use filter.""" + + def __init__(self, filter: str = "species_all"): + self._filter = filter + self.check() + + @property + def filter(self) -> str: + return self._filter + + def check(self) -> None: + valid_filters = ["species_all", "fields_all", "custom_filter"] + if not isinstance(self._filter, str): + raise ValueError(f"Filter must be a string, got {type(self._filter)}") + if self._filter not in valid_filters: + raise ValueError(f"Filter must be one of {valid_filters}, got {self._filter}") + + def get_as_pypicongpu( + self, + dict_species_picmi_to_pypicongpu: typing.Optional[typing.Dict] = None, + time_step_size: float = 0.0, + num_steps: int = 0, + simulation_box=None, + ) -> typing.Any: + self.check() + return getattr(pypicongpu_sources, self.__class__.__name__)(filter=self._filter) + + +# --------------------------------------------------------------------------- +# Sources with (species + filter, no extras) +# --------------------------------------------------------------------------- + + +class BoundElectronDensity(SourceBaseSpeciesFilter): + """ + Bound electron density diagnostic for PIConGPU. + """ + + +class ChargeDensity(SourceBaseSpeciesFilter): + """ + Charge density data source for openPMD output in PIConGPU. + + Calculates the charge density from a specified particle species, optionally + filtered by a selection criterion, for particle-in-cell simulations. + """ + + +class Counter(SourceBaseSpeciesFilter): + """ + Particle counter data source for openPMD output in PIConGPU. + + Derives a scalar field representing the number of real particles per cell + for a specified species, optionally filtered by a selection criterion. + The particle count is based on the species' weighting attribute and assigned + directly to the cell containing each particle. Intended primarily for debugging + due to its non-physical deposition shape. + """ + + +class Density(SourceBaseSpeciesFilter): + """ + Particle density data source for openPMD output in PIConGPU. + + Derives a scalar field representing the number density (in m^-3) of a specified + particle species, optionally filtered by a selection criterion. + The density is calculated based on the species' weighting and position attributes + and mapped to cells according to the PIC code's spatial shape assignment. + """ + + +class Energy(SourceBaseSpeciesFilter): + """ + Kinetic energy data source for openPMD output in PIConGPU. + + Derives a scalar field of summed kinetic energy (in Joules) for a specified particle species, + optionally filtered. Uses weighting, momentum, and mass attributes, mapped to cells by the + PIC code's spatial shape. + """ + + +class EnergyDensity(SourceBaseSpeciesFilter): + """ + Kinetic energy density data source for openPMD output in PIConGPU. + + Derives a scalar field of kinetic energy density (in J/m^3) for a specified particle species, + optionally filtered, in particle-in-cell simulations. Uses weighting, momentum, and mass attributes, + mapped to cells by the PIC code's spatial shape. + """ + + +class LarmorPower(SourceBaseSpeciesFilter): + """ + Radiated Larmor power data source for openPMD output in PIConGPU. + + Derives a scalar field of radiated power (in Joules) for a specified particle species, + optionally filtered, using the Larmor formula in particle-in-cell simulations. Uses + weighting, position, momentum, momentumPrev1, mass, and charge attributes, mapped to + cells by the PIC code's spatial shape. + """ + + +class MacroCounter(SourceBaseSpeciesFilter): + """ + Macro-particle counter data source for openPMD output in PIConGPU. + + Derives a scalar field counting macro-particles per cell for a specified particle species, + optionally filtered, in particle-in-cell simulations. Assigns each macro-particle directly + to its cell via floor operation. Intended for debugging (e.g., validating particle memory). + """ + + +# --------------------------------------------------------------------------- +# Sources with (filter only) +# --------------------------------------------------------------------------- + + +class Auto(SourceBaseFilterOnly): + """ + Default data source for openPMD output in PIConGPU. + + Provides a convenient way to dump default simulation data (e.g., all particle species and fields) + using the openPMD standard, with defaults determined by the PIC code. + """ + + +class DerivedAttributes(SourceBaseFilterOnly): + """ + Aggregated derived attributes data source for openPMD output in PIConGPU. + + Enables all particle-to-grid derived attributes (e.g., density, charge) for openPMD output + in particle-in-cell simulations, with defaults determined by the PIC code. + """ + + +# --------------------------------------------------------------------------- +# Sources with extra parameters +# --------------------------------------------------------------------------- + + +@typeguard.typechecked +class EnergyDensityCutoff(SourceBaseSpeciesFilter): + """ + Kinetic energy density data source with cutoff for openPMD output in PIConGPU. + + Derives a scalar field of kinetic energy density (in J/m^3) for a specified particle species, + optionally filtered, including only particles with kinetic energy below a user-defined cutoff, + in particle-in-cell simulations. Uses weighting, momentum, and mass attributes, mapped to cells + by the PIC code's spatial shape. + """ + + def __init__( + self, + species: PICMISpecies, + filter: str = "species_all", + cutoff_max_energy: typing.Optional[float] = None, + ): + if cutoff_max_energy is None: + raise ValueError("cutoff_max_energy is required and must be a positive number") + self.cutoff_max_energy = cutoff_max_energy + super().__init__(species, filter) + + def check(self) -> None: + super().check() + if not isinstance(self.cutoff_max_energy, (int, float)): + raise TypeError(f"cutoff_max_energy must be a number, got {type(self.cutoff_max_energy)}") + if self.cutoff_max_energy <= 0: + raise ValueError(f"cutoff_max_energy must be positive, got {self.cutoff_max_energy}") + + def get_as_pypicongpu( + self, + dict_species_picmi_to_pypicongpu: typing.Dict[PICMISpecies, typing.Any], + time_step_size: float = 0.0, + num_steps: int = 0, + simulation_box=None, + ) -> typing.Any: + mapped_species = self._map_species(dict_species_picmi_to_pypicongpu) + return pypicongpu_sources.EnergyDensityCutoff( + species=mapped_species, + filter=self._filter, + cutoff_max_energy=self.cutoff_max_energy, + ) + + +@typeguard.typechecked +class Momentum(SourceBaseSpeciesFilter): + """ + Momentum component data source for openPMD output in PIConGPU. + + Derives a scalar field of momentum (in kg·m/s) in a specified direction (x, y, z) + for a specified particle species, optionally filtered, in particle-in-cell simulations. + Uses weighting and momentum attributes, mapped to cells by the PIC code's spatial shape. + Intended for debugging or analyzing particle dynamics. + """ + + def __init__(self, species: PICMISpecies, filter: str = "species_all", direction: str = "x"): + self.direction = direction + super().__init__(species, filter) + + def check(self) -> None: + super().check() + valid_directions = ["x", "y", "z"] + if not isinstance(self.direction, str): + raise TypeError(f"Direction must be a string, got {type(self.direction)}") + if self.direction not in valid_directions: + raise ValueError(f"Direction must be 'x', 'y', or 'z', got {self.direction}") + + def get_as_pypicongpu( + self, + dict_species_picmi_to_pypicongpu: typing.Dict[PICMISpecies, typing.Any], + time_step_size: float = 0.0, + num_steps: int = 0, + simulation_box=None, + ) -> typing.Any: + mapped_species = self._map_species(dict_species_picmi_to_pypicongpu) + return getattr(pypicongpu_sources, self.__class__.__name__)( + species=mapped_species, + filter=self._filter, + direction=self.direction, + ) + + +class MidCurrentDensityComponent(Momentum): + """ + Current density component data source for openPMD output in PIConGPU. + + Derives a scalar field of current density (in A/m^2) in a specified direction (x, y, z) + for a specified particle species, optionally filtered, in particle-in-cell simulations. Uses + weighting, position, momentum, mass, and charge attributes, mapped to cells by the PIC code's + spatial shape. Intended for debugging (e.g., validating current solvers). + """ + + +class MomentumDensity(Momentum): + """ + Momentum density component data source for openPMD output in PIConGPU. + + Derives a scalar field of momentum density (in kg·m/s/m^3) in a specified direction (x, y, z) + for a specified particle species, optionally filtered, in particle-in-cell simulations. Uses + position and momentum attributes, mapped to cells by the PIC code's spatial shape. + """ + + +class WeightedVelocity(Momentum): + """ + Weighted velocity component data source for openPMD output in PIConGPU. + + Derives a scalar field of weighted velocity (in m/s) in a specified direction (x, y, z) + for a specified particle species, optionally filtered, in particle-in-cell simulations. Uses + position, momentum, weighting, and mass attributes, mapped to cells by the PIC code's spatial + shape. Use with AveragedAttribute to calculate average velocity. + """ diff --git a/lib/python/picongpu/picmi/diagnostics/phase_space.py b/lib/python/picongpu/picmi/diagnostics/phase_space.py index ec3872906b..8f3b450315 100644 --- a/lib/python/picongpu/picmi/diagnostics/phase_space.py +++ b/lib/python/picongpu/picmi/diagnostics/phase_space.py @@ -1,18 +1,17 @@ """ This file is part of PIConGPU. -Copyright 2021-2024 PIConGPU contributors +Copyright 2021-2025 PIConGPU contributors Authors: Masoud Afshari License: GPLv3+ """ from ...pypicongpu.output.phase_space import PhaseSpace as PyPIConGPUPhaseSpace from ...pypicongpu.species.species import Species as PyPIConGPUSpecies - from ..species import Species as PICMISpecies from .timestepspec import TimeStepSpec - import typeguard -from typing import Literal +import warnings +from typing import Literal, Union @typeguard.typechecked @@ -25,70 +24,75 @@ class PhaseSpace: Parameters ---------- - species: string - Name of the particle species to track (e.g., "electron", "proton"). - - period: TimeStepSpec - Specify on which time steps the plugin should run. + species: PICMISpecies + Particle species to track (e.g., an instance with name="electron" or "proton"). + period: int or TimeStepSpec + Number of simulation steps between consecutive outputs (e.g., 10 for every 10 steps). + Use 0 to disable output. Alternatively, a TimeStepSpec can be provided. Unit: steps (simulation time steps). - spatial_coordinate: string - Spatial coordinate used in phase space (e.g., 'x', 'y', 'z'). - - momentum: string - Momentum coordinate used in phase space (e.g., 'px', 'py', 'pz'). - + Spatial coordinate used in phase space (e.g., 'x', 'y', 'z'). Defaults to 'x'. + momentum_coordinate: string + Momentum coordinate used in phase space (e.g., 'px', 'py', 'pz'). Defaults to 'px'. min_momentum: float - Minimum value for the phase-space coordinate range. + Minimum value for the phase-space momentum range. Defaults to 0.0. Unit: kg*m/s (momentum in SI units). - max_momentum: float - Maximum value for the phase-space coordinate range. + Maximum value for the phase-space momentum range. Defaults to 1.0. Unit: kg*m/s (momentum in SI units). - - name: string, optional - Optional name for the phase-space plugin. """ - def check(self): - if self.min_momentum >= self.max_momentum: - raise ValueError("min_momentum must be less than max_momentum") - def __init__( self, species: PICMISpecies, - period: TimeStepSpec, - spatial_coordinate: Literal["x", "y", "z"], - momentum_coordinate: Literal["px", "py", "pz"], - min_momentum: float, - max_momentum: float, + period: Union[int, TimeStepSpec], + spatial_coordinate: Literal["x", "y", "z"] = "x", + momentum_coordinate: Literal["px", "py", "pz"] = "px", + min_momentum: float = 0.0, + max_momentum: float = 1.0, ): + if not isinstance(period, (int, TimeStepSpec)): + raise TypeError("period must be an integer or TimeStepSpec") + if isinstance(period, int): + if period < 0: + raise ValueError("period must be non-negative") + self.period = TimeStepSpec([slice(None, None, period)] if period > 0 else [])("steps") + else: + self.period = period self.species = species - self.period = period self.spatial_coordinate = spatial_coordinate self.momentum_coordinate = momentum_coordinate self.min_momentum = min_momentum self.max_momentum = max_momentum + def check(self): + if not isinstance(self.species, PICMISpecies): + raise TypeError("species must be a PICMISpecies") + if not isinstance(self.species.name, str) or not self.species.name: + raise TypeError("species must have a non-empty name") + if not isinstance(self.period, TimeStepSpec): + raise TypeError("period must be a TimeStepSpec") + if not self.period.specs: + warnings.warn("PhaseSpace is disabled because period is empty") + if self.min_momentum >= self.max_momentum: + raise ValueError( + f"PhaseSpace's min_momentum should be smaller than max_momentum. " + f"You gave: {self.min_momentum=} and {self.max_momentum=}." + ) + def get_as_pypicongpu( - # to get the corresponding PyPIConGPUSpecies instance for the given PICMISpecies. self, dict_species_picmi_to_pypicongpu: dict[PICMISpecies, PyPIConGPUSpecies], - time_step_size, - num_steps, + time_step_size: float, + num_steps: int, + simulation_box=None, # Added to match OpenPMD signature, not used ) -> PyPIConGPUPhaseSpace: self.check() - - if self.species not in dict_species_picmi_to_pypicongpu.keys(): + if self.species not in dict_species_picmi_to_pypicongpu: raise ValueError(f"Species {self.species} is not known to Simulation") - - # checks if PICMISpecies instance exists in the dictionary. If yes, it returns the corresponding PyPIConGPUSpecies instance. - # self.species refers to the species attribute of the class PhaseSpace(picmistandard.PICMI_PhaseSpace). - pypicongpu_species = dict_species_picmi_to_pypicongpu.get(self.species) - + pypicongpu_species = dict_species_picmi_to_pypicongpu[self.species] if pypicongpu_species is None: raise ValueError(f"Species {self.species} is not mapped to a PyPIConGPUSpecies.") - pypicongpu_phase_space = PyPIConGPUPhaseSpace() pypicongpu_phase_space.species = pypicongpu_species pypicongpu_phase_space.period = self.period.get_as_pypicongpu(time_step_size, num_steps) @@ -96,5 +100,4 @@ def get_as_pypicongpu( pypicongpu_phase_space.momentum_coordinate = self.momentum_coordinate pypicongpu_phase_space.min_momentum = self.min_momentum pypicongpu_phase_space.max_momentum = self.max_momentum - return pypicongpu_phase_space diff --git a/lib/python/picongpu/picmi/diagnostics/png.py b/lib/python/picongpu/picmi/diagnostics/png.py index f8626e0cf0..6d982b90a0 100644 --- a/lib/python/picongpu/picmi/diagnostics/png.py +++ b/lib/python/picongpu/picmi/diagnostics/png.py @@ -1,6 +1,6 @@ """ This file is part of PIConGPU. -Copyright 2021-2024 PIConGPU contributors +Copyright 2021-2025 PIConGPU contributors Authors: Masoud Afshari License: GPLv3+ """ @@ -8,18 +8,17 @@ from ...pypicongpu.output.png import Png as PyPIConGPUPNG from ...pypicongpu.species.species import Species as PyPIConGPUSpecies from ...pypicongpu.output.png import EMFieldScaleEnum, ColorScaleEnum - from ..species import Species as PICMISpecies from .timestepspec import TimeStepSpec import typeguard -from typing import List +from typing import List, Dict @typeguard.typechecked class Png: """ - Specifies the parameters for PNG output in PIConGPU. + Specifies the parameters for PNG output in PIConGPU via PICMI interface. This plugin generates 2D PNG images of field and particle data. @@ -30,21 +29,21 @@ class Png: Unit: steps (simulation time steps). axis: string - Axis combination for the 2D slice (e.g., "yx"). + Axis combination for the 2D slice (e.g., "xy", "xz", "yz"). slice_point: float - Ratio for the slice position in the dimension not used in axis (e.g., "z") (0.0 to 1.0). - [unit: dimensionless] + Ratio for the slice position in the dimension not used in axis (0.0 to 1.0). + Unit: dimensionless. - species: string - Name of the particle species to count (e.g., "electron", "proton"). + species: PICMISpecies + Particle species to include in the PNG output (e.g., electron, proton). - folder_name: string - Folder name where the PNGs will be stored. + folder: string + Folder where the PNGs will be stored. scale_image: float Scaling factor applied to the image before writing to file. - [unit: dimensionless] + Unit: dimensionless. scale_to_cellsize: bool Whether to scale the image to account for non-quadratic cell sizes. @@ -73,74 +72,43 @@ class Png: pre_channel3_color_scales: ColorScaleEnum Color scale for channel 3. - custom_normalization_si: list of 3 floats - Custom normalization factors for B (T), E (V/m), and current (A) (when using scale mode 6). - [unit: T, V/m, A] + custom_normalization_si: List[float] + Custom normalization factors for B (T), E (V/m), and current (A) when using EMFieldScaleEnum.CUSTOM. + Unit: T, V/m, A. pre_particle_density_opacity: float Opacity of the particle density overlay (0.0 to 1.0). - [unit: dimensionless] + Unit: dimensionless. pre_channel1_opacity: float Opacity for channel 1 data (0.0 to 1.0). - [unit: dimensionless] + Unit: dimensionless. pre_channel2_opacity: float Opacity for channel 2 data (0.0 to 1.0). - [unit: dimensionless] + Unit: dimensionless. pre_channel3_opacity: float Opacity for channel 3 data (0.0 to 1.0). - [unit: dimensionless] + Unit: dimensionless. pre_channel1: string - Custom expression for channel 1. + Field component for channel 1 (e.g., "E_x"). pre_channel2: string - Custom expression for channel 2. + Field component for channel 2 (e.g., "E_y"). pre_channel3: string - Custom expression for channel 3. + Field component for channel 3 (e.g., "E_z"). """ - def check(self): - if not (0.0 <= self.slice_point <= 1.0): - raise ValueError("Slice point must be between 0.0 and 1.0") - - if not (0.0 <= self.pre_particle_density_opacity <= 1.0): - raise ValueError("pre particle density opacity must be between 0.0 and 1.0") - if not (0.0 <= self.pre_channel1_opacity <= 1.0): - raise ValueError("Pre channel 1 opacity must be between 0.0 and 1.0") - if not (0.0 <= self.pre_channel2_opacity <= 1.0): - raise ValueError("Pre channel 2 opacity must be between 0.0 and 1.0") - if not (0.0 <= self.pre_channel3_opacity <= 1.0): - raise ValueError("Pre channel 3 opacity must be between 0.0 and 1.0") - - # Validate EM field scaling for channels - if self.em_field_scale_channel1 not in EMFieldScaleEnum: - raise ValueError(f"Invalid EM field scale for channel 1. Valid options are {list(EMFieldScaleEnum)}.") - if self.em_field_scale_channel2 not in EMFieldScaleEnum: - raise ValueError(f"Invalid EM field scale for channel 2. Valid options are {list(EMFieldScaleEnum)}.") - if self.em_field_scale_channel3 not in EMFieldScaleEnum: - raise ValueError(f"Invalid EM field scale for channel 3. Valid options are {list(EMFieldScaleEnum)}.") - - # Validate color scales for particle density and channels - if self.pre_particle_density_color_scales not in ColorScaleEnum: - raise ValueError(f"Invalid color scale for particle density. Valid options are {list(ColorScaleEnum)}.") - if self.pre_channel1_color_scales not in ColorScaleEnum: - raise ValueError(f"Invalid color scale for channel 1. Valid options are {list(ColorScaleEnum)}.") - if self.pre_channel2_color_scales not in ColorScaleEnum: - raise ValueError(f"Invalid color scale for channel 2. Valid options are {list(ColorScaleEnum)}.") - if self.pre_channel3_color_scales not in ColorScaleEnum: - raise ValueError(f"Invalid color scale for channel 3. Valid options are {list(ColorScaleEnum)}.") - def __init__( self, species: PICMISpecies, period: TimeStepSpec, axis: str, slice_point: float, - folder_name: str, + folder: str, scale_image: float, scale_to_cellsize: bool, white_box_per_gpu: bool, @@ -164,7 +132,7 @@ def __init__( self.axis = axis self.slice_point = slice_point self.species = species - self.folder_name = folder_name + self.folder = folder self.scale_image = scale_image self.scale_to_cellsize = scale_to_cellsize self.white_box_per_gpu = white_box_per_gpu @@ -184,45 +152,101 @@ def __init__( self.pre_channel2 = pre_channel2 self.pre_channel3 = pre_channel3 + def check(self): + """ + Check if the parameters are valid. + + Raises + ------ + ValueError + If any parameter is invalid. + """ + if self.species is None: + raise ValueError("species must be set") + if self.period is None: + raise ValueError("period must be set") + self.period.check() # Validate TimeStepSpec + if self.axis not in ["xy", "yx", "xz", "zx", "yz", "zy"]: + raise ValueError(f"axis must be 'xy', 'yx', 'xz', 'zx', 'yz', or 'zy', got {self.axis}") + if self.slice_point < 0.0 or self.slice_point > 1.0: + raise ValueError(f"slice_point must be in [0, 1], got {self.slice_point}") + if self.scale_image <= 0: + raise ValueError(f"scale_image must be positive, got {self.scale_image}") + if self.scale_to_cellsize and self.scale_image == 1.0: + raise ValueError(f"scale_image must not be 1.0 when scale_to_cellsize is True, got {self.scale_image}") + if self.pre_particle_density_opacity < 0 or self.pre_particle_density_opacity > 1: + raise ValueError(f"pre_particle_density_opacity must be in [0, 1], got {self.pre_particle_density_opacity}") + if self.pre_channel1_opacity < 0 or self.pre_channel1_opacity > 1: + raise ValueError(f"pre_channel1_opacity must be in [0, 1], got {self.pre_channel1_opacity}") + if self.pre_channel2_opacity < 0 or self.pre_channel2_opacity > 1: + raise ValueError(f"pre_channel2_opacity must be in [0, 1], got {self.pre_channel2_opacity}") + if self.pre_channel3_opacity < 0 or self.pre_channel3_opacity > 1: + raise ValueError(f"pre_channel3_opacity must be in [0, 1], got {self.pre_channel3_opacity}") + for channel, name in [ + (self.pre_channel1, "pre_channel1"), + (self.pre_channel2, "pre_channel2"), + (self.pre_channel3, "pre_channel3"), + ]: + if not isinstance(channel, str) or not channel.strip(): + raise ValueError(f"{name} must be a non-empty string, got {channel}") + if len(self.custom_normalization_si) != 3: + raise ValueError( + f"custom_normalization_si must contain exactly 3 floats, got {len(self.custom_normalization_si)}" + ) + for val in self.custom_normalization_si: + if not isinstance(val, float): + raise ValueError(f"custom_normalization_si values must be floats, got {val}") + if self.em_field_scale_channel1 is None: + raise ValueError("em_field_scale_channel1 must be set") + if self.em_field_scale_channel2 is None: + raise ValueError("em_field_scale_channel2 must be set") + if self.em_field_scale_channel3 is None: + raise ValueError("em_field_scale_channel3 must be set") + if self.pre_particle_density_color_scales is None: + raise ValueError("pre_particle_density_color_scales must be set") + if self.pre_channel1_color_scales is None: + raise ValueError("pre_channel1_color_scales must be set") + if self.pre_channel2_color_scales is None: + raise ValueError("pre_channel2_color_scales must be set") + if self.pre_channel3_color_scales is None: + raise ValueError("pre_channel3_color_scales must be set") + def get_as_pypicongpu( self, - dict_species_picmi_to_pypicongpu: dict[PICMISpecies, PyPIConGPUSpecies], - time_step_size, - num_steps, + species_to_pypicongpu_map: Dict[PICMISpecies, PyPIConGPUSpecies], + time_step_size: float, + num_steps: int, + simulation_box=None, ) -> PyPIConGPUPNG: self.check() - - if self.species not in dict_species_picmi_to_pypicongpu.keys(): - raise ValueError(f"Species {self.species} is not known to Simulation") - - pypicongpu_species = dict_species_picmi_to_pypicongpu.get(self.species) - - if pypicongpu_species is None: - raise ValueError(f"Species {self.species} is not mapped to a PyPIConGPUSpecies.") - - pypicongpu_png = PyPIConGPUPNG() - pypicongpu_png.period = self.period.get_as_pypicongpu(time_step_size, num_steps) - pypicongpu_png.axis = self.axis - pypicongpu_png.slicePoint = self.slice_point - pypicongpu_png.species = pypicongpu_species - pypicongpu_png.folder = self.folder_name - pypicongpu_png.scale_image = self.scale_image - pypicongpu_png.scale_to_cellsize = self.scale_to_cellsize - pypicongpu_png.white_box_per_GPU = self.white_box_per_gpu - pypicongpu_png.EM_FIELD_SCALE_CHANNEL1 = self.em_field_scale_channel1 - pypicongpu_png.EM_FIELD_SCALE_CHANNEL2 = self.em_field_scale_channel2 - pypicongpu_png.EM_FIELD_SCALE_CHANNEL3 = self.em_field_scale_channel3 - pypicongpu_png.preParticleDensCol = self.pre_particle_density_color_scales - pypicongpu_png.preChannel1Col = self.pre_channel1_color_scales - pypicongpu_png.preChannel2Col = self.pre_channel2_color_scales - pypicongpu_png.preChannel3Col = self.pre_channel3_color_scales - pypicongpu_png.customNormalizationSI = self.custom_normalization_si - pypicongpu_png.preParticleDens_opacity = self.pre_particle_density_opacity - pypicongpu_png.preChannel1_opacity = self.pre_channel1_opacity - pypicongpu_png.preChannel2_opacity = self.pre_channel2_opacity - pypicongpu_png.preChannel3_opacity = self.pre_channel3_opacity - pypicongpu_png.preChannel1 = self.pre_channel1 - pypicongpu_png.preChannel2 = self.pre_channel2 - pypicongpu_png.preChannel3 = self.pre_channel3 - + if self.species not in species_to_pypicongpu_map: + raise ValueError(f"Species {self.species} not found in species_to_pypicongpu_map") + pypicongpu_species = species_to_pypicongpu_map[self.species] + pypicongpu_period = self.period.get_as_pypicongpu(time_step_size, num_steps) + + pypicongpu_png = PyPIConGPUPNG( + species=pypicongpu_species, + period=pypicongpu_period, + axis=self.axis, + slicePoint=self.slice_point, + folder=self.folder, + scale_image=self.scale_image, + scale_to_cellsize=self.scale_to_cellsize, + white_box_per_GPU=self.white_box_per_gpu, + EM_FIELD_SCALE_CHANNEL1=self.em_field_scale_channel1, + EM_FIELD_SCALE_CHANNEL2=self.em_field_scale_channel2, + EM_FIELD_SCALE_CHANNEL3=self.em_field_scale_channel3, + preParticleDensCol=self.pre_particle_density_color_scales, + preChannel1Col=self.pre_channel1_color_scales, + preChannel2Col=self.pre_channel2_color_scales, + preChannel3Col=self.pre_channel3_color_scales, + customNormalizationSI=self.custom_normalization_si, + preParticleDens_opacity=self.pre_particle_density_opacity, + preChannel1_opacity=self.pre_channel1_opacity, + preChannel2_opacity=self.pre_channel2_opacity, + preChannel3_opacity=self.pre_channel3_opacity, + preChannel1=self.pre_channel1, + preChannel2=self.pre_channel2, + preChannel3=self.pre_channel3, + ) return pypicongpu_png diff --git a/lib/python/picongpu/picmi/diagnostics/rangespec.py b/lib/python/picongpu/picmi/diagnostics/rangespec.py new file mode 100644 index 0000000000..d2f4f9860b --- /dev/null +++ b/lib/python/picongpu/picmi/diagnostics/rangespec.py @@ -0,0 +1,154 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from ...pypicongpu.output.rangespec import RangeSpec as PyPIConGPURangeSpec +import warnings + + +class _RangeSpecMeta(type): + """ + Custom metaclass providing the [] operator for RangeSpec. + """ + + def __getitem__(cls, args): + if not isinstance(args, tuple): + args = (args,) + return cls(*args) + + +class RangeSpec(metaclass=_RangeSpecMeta): + """ + A class to specify a contiguous range of cells for simulation output in 1D, 2D, or 3D. + + This class stores a list of slices representing inclusive cell ranges for each dimension. + Slices must have step=None (contiguous ranges) and integer or None endpoints. Use the [] + operator for concise syntax, e.g., RangeSpec[0:10, 5:15]. For example: + - 1D: RangeSpec[0:10] specifies cells 0 to 10 (x). + - 2D: RangeSpec[0:10, 5:15] specifies cells 0 to 10 (x), 5 to 15 (y). + - 3D: RangeSpec[0:10, 5:15, 2:8] specifies cells 0 to 10 (x), 5 to 15 (y), 2 to 8 (z). + The default RangeSpec[:] includes all cells in the simulation box for 1D. + Ranges where begin > end (e.g., RangeSpec[10:5]) result in an empty range after processing, + disabling output for that dimension. + """ + + def __init__(self, *args): + """ + Initialize a RangeSpec with a list of slices. + + :param args: 1 to 3 slice objects, e.g., slice(0, 10), slice(5, 15). + :raises TypeError: If args contains non-slice elements or invalid endpoint types. + :raises ValueError: If args is empty, has more than 3 slices, or contains slices with step != None. + """ + if not args: + raise ValueError("RangeSpec must have at least one range") + if len(args) > 3: + raise ValueError(f"RangeSpec must have at most 3 ranges, got {len(args)}") + if not all(isinstance(s, slice) for s in args): + raise TypeError("All elements must be slice objects") + for i, s in enumerate(args): + if s.step is not None: + raise ValueError(f"Step must be None in dimension {i+1}, got {s.step}") + if s.start is not None and not isinstance(s.start, int): + raise TypeError(f"Begin in dimension {i+1} must be int or None, got {type(s.start)}") + if s.stop is not None and not isinstance(s.stop, int): + raise TypeError(f"End in dimension {i+1} must be int or None, got {type(s.stop)}") + self.ranges = list(args) + + def __len__(self): + """ + Return the number of dimensions specified in the range. + """ + return len(self.ranges) + + def check(self): + """ + Validate the RangeSpec and warn if any range is empty or has begin > end. + + :raises ValueError: If any validation fails (handled in __init__). + """ + # Check for begin > end in raw slices + for i, s in enumerate(self.ranges): + start = s.start if s.start is not None else 0 + stop = s.stop if s.stop is not None else 0 + if start > stop: + warnings.warn( + f"RangeSpec has begin > end in dimension {i+1}, resulting in an empty range after processing" + ) + + # Check for empty ranges after processing + dummy_sim_box = tuple(20 for _ in range(len(self.ranges))) # Match number of dimensions + processed_ranges = [ + self._interpret_negatives(self._interpret_nones(s, dim_size), dim_size) + for s, dim_size in zip(self.ranges, dummy_sim_box) + ] + for i, s in enumerate(processed_ranges): + if s.start >= s.stop: + warnings.warn(f"RangeSpec has an empty range in dimension {i+1}, disabling output for this dimension") + + def _interpret_nones(self, spec: slice, dim_size: int) -> slice: + """ + Replace None in slice bounds with simulation box limits (0 for begin, dim_size-1 for end). + + :param spec: Input slice. + :param dim_size: Size of the simulation box in the dimension. + :return: Slice with explicit bounds. + """ + return slice( + 0 if spec.start is None else spec.start, + dim_size - 1 if spec.stop is None else spec.stop, + None, + ) + + def _interpret_negatives(self, spec: slice, dim_size: int) -> slice: + """ + Convert negative indices to positive, clipping to simulation box. + + :param spec: Input slice. + :param dim_size: Size of the simulation box in the dimension. + :return: Slice with non-negative bounds, clipped to [0, dim_size-1], empty if begin > end. + """ + if dim_size <= 0: + raise ValueError(f"Dimension size must be positive. Got {dim_size}") + + begin = spec.start if spec.start is not None else 0 + end = spec.stop if spec.stop is not None else dim_size - 1 + + # Convert negative indices + begin = dim_size + begin if begin < 0 else begin + end = dim_size + end if end < 0 else end + + # Clip to simulation box + begin = max(0, min(begin, dim_size - 1)) + end = max(0, min(end, dim_size - 1)) + + # Ensure empty range if begin > end + if begin > end: + end = begin + + return slice(begin, end, None) + + def get_as_pypicongpu(self, simulation_box: tuple[int, ...]) -> PyPIConGPURangeSpec: + """ + Convert to a PyPIConGPURangeSpec object, applying simulation box clipping. + + :param simulation_box: tuple of dimension sizes (1 to 3 dimensions). + :return: PyPIConGPURangeSpec object with clipped, non-negative ranges. + :raises ValueError: If the number of ranges does not match the simulation box dimensions. + """ + if len(self.ranges) != len(simulation_box): + raise ValueError( + f"Number of range specifications ({len(self.ranges)}) must match " + f"simulation box dimensions ({len(simulation_box)})" + ) + + # Process each dimension + processed_ranges = [ + self._interpret_negatives(self._interpret_nones(s, dim_size), dim_size) + for s, dim_size in zip(self.ranges, simulation_box) + ] + + return PyPIConGPURangeSpec(*processed_ranges) diff --git a/lib/python/picongpu/picmi/diagnostics/timestepspec.py b/lib/python/picongpu/picmi/diagnostics/timestepspec.py index 5a8baef5bb..549c5460cf 100644 --- a/lib/python/picongpu/picmi/diagnostics/timestepspec.py +++ b/lib/python/picongpu/picmi/diagnostics/timestepspec.py @@ -1,23 +1,19 @@ """ This file is part of PIConGPU. -Copyright 2025 PIConGPU contributors -Authors: Julian Lenz +Copyright 2021-2025 PIConGPU contributors +Authors: Julian Lenz, Masoud Afshari License: GPLv3+ """ from enum import Enum, EnumMeta -from math import ceil - +from math import ceil, floor from ...pypicongpu.output import TimeStepSpec as PyPIConGPUTimeStepSpec +import typeguard class CustomStrEnumMeta(EnumMeta): """ - This class provides some functionality of 3.12 StrEnum, - namely its __contains__() method. - - You can safely remove this and inherit directly from StrEnum - once we switched to 3.12. + Provides StrEnum-like functionality for Python < 3.12. """ def __contains__(cls, val): @@ -43,158 +39,206 @@ def _missing_(cls, value): for member in cls: if member.value == value: return member - raise ValueError(f"Unknown time step unit. You gave {value}.") - - -# The following might look slightly convoluted but is preferred over the more obvious use of `__class_getitem__`. -# This is because the latter is supposed to return a GenericAlias which is not what we want. -# That would be like list[int]. -# We are more like an `Enum` where indexing into the class means something different. -# See [here](https://docs.python.org/3/reference/datamodel.html#object.__getitem__) and -# [here](https://docs.python.org/3/reference/datamodel.html#classgetitem-versus-getitem). + raise ValueError("Unknown unit in TimeStepSpec") class _TimeStepSpecMeta(type): """ - Custom metaclass providing the [] operator on its children. + Custom metaclass providing the [] operator for TimeStepSpec. """ - # Provide this to have a nice syntax picmi.diagnostics.TimeStepSpec[10:200:5, 3, 7, 11, 17] def __getitem__(cls, args): if not isinstance(args, tuple): args = (args,) return cls(*args) +@typeguard.typechecked class TimeStepSpec(metaclass=_TimeStepSpecMeta): """ - A class to specify time steps for simulation output. - - This class allows for flexible specification of time steps using slices - or individual indices. Its custom metaclass provides a [] operator on the class itself - for slicing and the () operator for choosing the unit such that the most convenient - way to use it is as follows: - - ts = TimeStepSpec[:12:2, 7]("steps") + TimeStepSpec[1.e-15:5.e-15:2.e-16]("seconds") - - In this example, `ts` specifies: - - every other time step for the first 12 time steps inclusively (`0, 2, 4, 6, 8, 10, 12`) - - AND the 7th time step - - AND one output every 2.e-16 seconds in the (inclusive) range 1.e-15 to 5.e-15 seconds - (which indices this maps to depends on the time step size and the number of time steps) - - In general, the class implements the following semantics for the operator []: - - the [] operator understands slices and numbers separated by commas - - specifications separated by commas are interpreted as unions - - slices are interpreted as inclusive on both ends, so `start:stop:step` includes the values - `start` and `stop` (if there exists an integer n such that `n*step+start == stop`) - - negative values are allowed for `start` and `stop` but not `step` (due to practical limitations - of the simulation code); as expected in Python they count from the end but due to inclusiveness - `:-1` includes the last element - - individual numbers denote a single time step - - multiple specifications (particularly in different units) can be concatenated (as set unions) - via the + operator - - Default units are `steps`. If other units are given (see which are implemented in `TimeStepUnits`), - rounding must happen in the translation into steps (the only unit available in the backend). This - rounding is implemented to round down (up) for the lower (upper) bound such that the interval will - never be clipped. The time step is always rounded down to the next available multiple of the time - step size, such that for long and sparsely sampled intervals distortions may occur. - - An extensive list of tests is available in the corresponding directory, mapping the syntax to - concrete index sets. The reader is encouraged to look for clarification there. - """ + Specify time steps for simulation output using slices or indices. + + Use as: TimeStepSpec[:12:2, 7]("steps") or TimeStepSpec[1e-15:5e-15:2e-16]("seconds"). + Supports negative indices, inclusive slices, and addition of TimeStepSpec objects. + + Defaults to 'steps' unit unless explicitly set to 'seconds' via __call__('seconds'). + + Examples for how TimeStepSpec is interpreted: - unit_system = None + Specific steps: + TimeStepSpec([5, 10]) + [slice(5, 6, 1), slice(10, 11, 1)] + steps: 5, 10 + + Uniform interval: + TimeStepSpec([slice(0, 100, 10)]) + steps: 0, 10, 20, ..., 90 + + Infinite step range: + TimeStepSpec([slice(0, None, 5)]) + steps: 0, 5, 10, 15, ... + + Negative start or stop: + TimeStepSpec([slice(-10, -1, 1)]) + steps: -10, -9, ..., -2 + + Mixed entries: + TimeStepSpec([5, slice(20, 25, 2)]) + steps: 5, 20, 22, 24 + """ def __init__(self, *args, specs_in_seconds=tuple()): self.specs = tuple() self.specs_in_seconds = tuple() + self.unit_system = "steps" # Default to steps - # allow copy initialisation from another TimeStepSpec. if len(args) == 1 and isinstance(args[0], TimeStepSpec): self.specs = args[0].specs self.specs_in_seconds = args[0].specs_in_seconds + self.unit_system = args[0].unit_system return - self.specs = tuple( - # The else branch is supposed to handle integers. - # We use a slice here because PIConGPU's interpretation of the - # --period argument for single integers is different. - # In PIConGPU, a single integer would be interpreted as - # slice(None, None, value) but this is unnatural for the - # Python [] operator. - spec if isinstance(spec, slice) else slice(spec, spec, None) - for spec in args + if len(args) == 1 and isinstance(args[0], list): + args = tuple(args[0]) + + for spec in args: + if not isinstance(spec, (slice, int, float)): + raise TypeError(f"Invalid spec type: {type(spec)}") + + self.specs = tuple(spec if isinstance(spec, slice) else slice(spec, spec + 1, 1) for spec in args) + self.specs_in_seconds = tuple( + spec if isinstance(spec, slice) else slice(spec, spec + 1, 1) for spec in specs_in_seconds ) - self.specs_in_seconds = tuple(specs_in_seconds) def __call__(self, unit_system="steps"): if unit_system not in TimeStepUnits: - raise ValueError(f"Unknown unit in TimeStepSpec. You gave {unit_system} which is not in TimeStepUnits.") - if self.unit_system is not None and self.unit_system != unit_system: - raise ValueError( - "Don't reset units on a TimeStepSpec. " - f"You've tried to set {unit_system} but it's already {self.unit_system}." - ) + raise ValueError("Unknown unit in TimeStepSpec") + if self.unit_system != "steps" and self.unit_system != unit_system: + raise ValueError(f"Cannot reset unit to {unit_system}, already set to {self.unit_system}") self.unit_system = unit_system if unit_system == "seconds": self.specs_in_seconds = self.specs self.specs = tuple() return self - def __add__(self, other): - if not (isinstance(other, TimeStepSpec)): + def __add__(self, other: "TimeStepSpec") -> "TimeStepSpec": + if not isinstance(other, TimeStepSpec): raise TypeError(f"unsupported operand type(s) for +: TimeStepSpec and {type(other)}") + if self.unit_system != other.unit_system and self.unit_system is not None and other.unit_system is not None: + raise ValueError("Cannot add TimeStepSpec objects with different units") ts = TimeStepSpec( *self.specs, *other.specs, specs_in_seconds=(*self.specs_in_seconds, *other.specs_in_seconds), ) - # The following guards against setting units on the result of the addition. - # Otherwise one could specify time steps in "steps" unit, add that to - # another TimeStepSpec and reset the units. - ts.unit_system = "mixed" + ts.unit_system = self.unit_system or other.unit_system return ts - def _transform_to_steps(self, specs_in_seconds, time_step_size): - if time_step_size <= 0: - raise ValueError(f"Time step size must be strictly positive. You gave {time_step_size}.") - return tuple( - slice( - int(spec.start / time_step_size if spec.start is not None else 0), - int(ceil(spec.stop / time_step_size)) if spec.stop is not None else None, - int(spec.step / time_step_size if spec.step is not None else 1) or 1, - ) - for spec in specs_in_seconds - ) + def check(self): + """ + Validate TimeStepSpec parameters. - def _interpret_nones(self, spec): - # We must communicate an open end explicitly, so we leave spec.stop as None. + Raises + ------ + ValueError + If any step size is less than 1 (for steps) or less than 0 (for seconds). + """ + specs = self.specs if self.unit_system == "steps" else self.specs_in_seconds + for spec in specs: + step = spec.step if isinstance(spec, slice) else 1 + if step is not None: + if self.unit_system == "steps" and step < 1: + raise ValueError(f"Step size must be >= 1 in TimeStepSpec. You gave {step}.") + if self.unit_system == "seconds" and step <= 0: + raise ValueError(f"Step size must be > 0 in TimeStepSpec. You gave {step}.") + + def _interpret_nones(self, spec, num_steps): + """ + Replace None in slice bounds with simulation limits (0 for start, num_steps for stop). + """ return slice( - spec.start if spec.start is not None else 0, - spec.stop if spec.stop is not None else -1, + 0 if spec.start is None else spec.start, + -1 if spec.stop is None else spec.stop, spec.step if spec.step is not None else 1, ) def _interpret_negatives(self, spec, num_steps): - if spec.step < 1: - raise ValueError(f"Step size must be >= 1 in TimeStepSpec. You gave {spec.step}.") - return slice( - spec.start if spec.start >= 0 else num_steps + spec.start, - spec.stop if (spec.stop is None or spec.stop >= -1) else num_steps + spec.stop, - spec.step, - ) + step = spec.step if spec.step is not None else 1 + if self.unit_system == "steps" and step < 1: + raise ValueError(f"Step size must be >= 1 in TimeStepSpec. You gave {step}.") + + start = spec.start if spec.start is None or spec.start >= 0 else num_steps + spec.start - def get_as_pypicongpu(self, time_step_size, num_steps): + # Only convert stop if it's not None and not -1 + if spec.stop is None: + stop = -1 + elif spec.stop < 0: + stop = num_steps + spec.stop + else: + stop = spec.stop + + if stop == -1: + stop = num_steps + else: + stop = max(start, min(stop, num_steps)) + + return slice(start, stop, step) + + def get_as_pypicongpu(self, time_step_size: float, num_steps: int) -> PyPIConGPUTimeStepSpec: """ - Creates the corresponding pypicongpu object by translating every specification - into non-negative (except for -1) slices in units of steps. It takes `time_step_size` - and `num_steps` to compute this transformation. + Convert to PyPIConGPU TimeStepSpec with resolved indices. + + :param time_step_size: Size of one time step in seconds (must be positive). + :param num_steps: Total number of simulation steps (must be positive). + :return: PyPIConGPUTimeStepSpec with clipped, inclusive ranges as slice objects. """ - return PyPIConGPUTimeStepSpec( - [ - self._interpret_negatives(self._interpret_nones(s), num_steps) - for s in self.specs + self._transform_to_steps(self.specs_in_seconds, time_step_size) - ] - ) + if time_step_size <= 0: + raise ValueError("time_step_size must be positive") + if num_steps <= 0: + raise ValueError("num_steps must be positive") + + specs = self.specs if self.unit_system in ["steps", None] else self.specs_in_seconds + resolved_specs = [] + + for spec in specs: + # Handle single time points + if not isinstance(spec, slice) or ( + spec.start is not None and spec.stop is not None and spec.start + 1 == spec.stop and spec.step == 1 + ): + index = spec.start if isinstance(spec, slice) else spec + if self.unit_system == "seconds": + index = floor(index / time_step_size) + if index < 0: + index = max(0, num_steps + index) + if index >= num_steps: + continue + resolved_specs.append(slice(index, index + 1, 1)) + continue + + # Process slices + spec = self._interpret_nones(spec, num_steps) + spec = self._interpret_negatives(spec, num_steps) + + start = spec.start + stop = spec.stop + step = spec.step + + if self.unit_system == "seconds": + start = floor(start / time_step_size) + stop = ceil(stop / time_step_size) + step = ceil(step / time_step_size) + if step < 1: + raise ValueError(f"Step size must be >= 1 in TimeStepSpec. You gave {step}.") + + # Clip to valid range + start = max(0, min(start, num_steps)) + + if stop == -1: + stop = num_steps + else: + stop = max(start, min(stop, num_steps)) + + if start < stop: + resolved_specs.append(slice(start, stop, step)) + + return PyPIConGPUTimeStepSpec(specs=resolved_specs) diff --git a/lib/python/picongpu/picmi/lasers/base_laser.py b/lib/python/picongpu/picmi/lasers/base_laser.py index 362a73f87d..30a3c5893e 100644 --- a/lib/python/picongpu/picmi/lasers/base_laser.py +++ b/lib/python/picongpu/picmi/lasers/base_laser.py @@ -37,10 +37,10 @@ def _propagation_connects_centroid_and_focus(self): return length_of_cross_product < 1.0e-5 def _compute_E0_and_a0(self, k0, E0, a0): - if E0 is not None or a0 is not None: - raise ValueError(f"One of E0 or a0 must be speficied. You gave {E0=} and {a0=}.") + if E0 is None and a0 is None: + raise ValueError(f"One of E0 or a0 must be specified. You gave {E0=} and {a0=}.") if E0 is not None and a0 is not None: - raise ValueError("At least one of E0 or a0 must be specified.") + raise ValueError("Only one of E0 or a0 must be specified, not both.") if E0 is None: E0 = a0 * constants.m_e * constants.c**2 * k0 / constants.q_e diff --git a/lib/python/picongpu/picmi/simulation.py b/lib/python/picongpu/picmi/simulation.py index 3a947f7187..84f989d9ce 100644 --- a/lib/python/picongpu/picmi/simulation.py +++ b/lib/python/picongpu/picmi/simulation.py @@ -1,7 +1,7 @@ """ This file is part of PIConGPU. Copyright 2021-2025 PIConGPU contributors -Authors: Hannes Troepgen, Brian Edward Marre, Julian Lenz +Authors: Hannes Troepgen, Brian Edward Marre, Julian Lenz, Masoud Afshari License: GPLv3+ """ @@ -490,8 +490,14 @@ def get_as_pypicongpu(self) -> pypicongpu.simulation.Simulation: s.init_manager, pypicongpu_by_picmi_species = self.__get_init_manager() + # Extract simulation_box from grid + if isinstance(self.solver.grid, Cartesian3DGrid): + simulation_box = tuple(self.solver.grid.number_of_cells) + else: + raise ValueError("Grid must be a Cartesian3DGrid with defined number_of_cells") + s.plugins = [ - entry.get_as_pypicongpu(pypicongpu_by_picmi_species, self.time_step_size, s.time_steps) + entry.get_as_pypicongpu(pypicongpu_by_picmi_species, self.time_step_size, s.time_steps, simulation_box) for entry in self.diagnostics ] diff --git a/lib/python/picongpu/pypicongpu/__init__.py b/lib/python/picongpu/pypicongpu/__init__.py index 9aacfae1ba..70522875e3 100644 --- a/lib/python/picongpu/pypicongpu/__init__.py +++ b/lib/python/picongpu/pypicongpu/__init__.py @@ -1,7 +1,3 @@ -""" -internal representation of params to generate PIConGPU input files -""" - from .simulation import Simulation from .runner import Runner from .output.phase_space import PhaseSpace @@ -12,6 +8,9 @@ from .field_solver.DefaultSolver import Solver from .field_solver.Yee import YeeSolver from .field_solver.Lehe import LeheSolver +from .output.openpmd import OpenPMD +from .output.openpmd_sources.source_base import SourceBase + from . import laser from . import grid @@ -39,9 +38,6 @@ "MacroParticleCount", "Png", "Checkpoint", + "OpenPMD", + "SourceBase", ] - -# note: put down here b/c linter complains if imports are not at top -import sys - -assert sys.version_info.major > 3 or sys.version_info.minor >= 9, "Python 3.9 is required for PIConGPU" diff --git a/lib/python/picongpu/pypicongpu/output/__init__.py b/lib/python/picongpu/pypicongpu/output/__init__.py index 9636a64119..ec73163485 100644 --- a/lib/python/picongpu/pypicongpu/output/__init__.py +++ b/lib/python/picongpu/pypicongpu/output/__init__.py @@ -4,7 +4,10 @@ from .macro_particle_count import MacroParticleCount from .png import Png from .timestepspec import TimeStepSpec +from .rangespec import RangeSpec from .checkpoint import Checkpoint +from .openpmd import OpenPMD +from .openpmd_sources import SourceBase __all__ = [ "Auto", @@ -13,5 +16,8 @@ "MacroParticleCount", "Png", "TimeStepSpec", + "RangeSpec", "Checkpoint", + "OpenPMD", + "SourceBase", ] diff --git a/lib/python/picongpu/pypicongpu/output/auto.py b/lib/python/picongpu/pypicongpu/output/auto.py index 2bbb6ab963..434453b29f 100644 --- a/lib/python/picongpu/pypicongpu/output/auto.py +++ b/lib/python/picongpu/pypicongpu/output/auto.py @@ -8,7 +8,6 @@ from .timestepspec import TimeStepSpec from .. import util from .plugin import Plugin - import typeguard @@ -17,34 +16,27 @@ class Auto(Plugin): """ Class to provide output **without further configuration**. - This class requires a period (in time steps) and will enable as many output - plugins as feasable for all species. + This class requires a period (in time steps) and will enable as many output plugins as feasible for all species. Note: The list of species from the initmanager is used during rendering. - No further configuration is possible! - If you want to provide additional configuration for plugins, - create a separate class. + If you want to provide additional configuration for plugins, create a separate class. """ period = util.build_typesafe_property(TimeStepSpec) """period to print data at""" - _name = "auto" def __init__(self): pass def check(self) -> None: - """ - validate attributes - """ + """validate attributes""" pass def _get_serialized(self) -> dict: self.check() return { "period": self.period.get_rendering_context(), - # helper to avoid repeating code "png_axis": [ {"axis": "yx"}, {"axis": "yz"}, diff --git a/lib/python/picongpu/pypicongpu/output/checkpoint.py b/lib/python/picongpu/pypicongpu/output/checkpoint.py index 8bc02dc219..130aedf4ef 100644 --- a/lib/python/picongpu/pypicongpu/output/checkpoint.py +++ b/lib/python/picongpu/pypicongpu/output/checkpoint.py @@ -1,6 +1,6 @@ """ This file is part of PIConGPU. -Copyright 2021-2025 PIConGPU contributors +Copyright 2025 PIConGPU contributors Authors: Masoud Afshari License: GPLv3+ """ @@ -32,11 +32,24 @@ class Checkpoint(Plugin): _name = "checkpoint" def __init__(self): - "do nothing" + self.period = None + self.timePeriod = None + self.directory = None + self.file = None + self.restart = None + self.tryRestart = None + self.restartStep = None + self.restartDirectory = None + self.restartFile = None + self.restartChunkSize = None + self.restartLoop = None + self.openPMD = None def check(self): if self.period is None and self.timePeriod is None: raise ValueError("At least one of period or timePeriod must be provided") + if self.period is not None: + self.period.check() if self.timePeriod is not None and self.timePeriod < 0: raise ValueError("timePeriod must be non-negative") if self.restartStep is not None and self.restartStep < 0: @@ -49,18 +62,31 @@ def check(self): def _get_serialized(self) -> typing.Dict: """Return the serialized representation of the object.""" self.check() - serialized = { - "period": self.period.get_rendering_context() if self.period is not None else None, - "timePeriod": self.timePeriod, - "directory": self.directory, - "file": self.file, - "restart": self.restart, - "tryRestart": self.tryRestart, - "restartStep": self.restartStep, - "restartDirectory": self.restartDirectory, - "restartFile": self.restartFile, - "restartChunkSize": self.restartChunkSize, - "restartLoop": self.restartLoop, - "openPMD": self.openPMD, - } + serialized = {} + + if self.period is not None: + serialized["period"] = self.period.get_rendering_context() + if self.timePeriod is not None: + serialized["timePeriod"] = self.timePeriod + if self.directory is not None: + serialized["directory"] = self.directory + if self.file is not None: + serialized["file"] = self.file + if self.restart is not None: + serialized["restart"] = self.restart + if self.tryRestart is not None: + serialized["tryRestart"] = self.tryRestart + if self.restartStep is not None: + serialized["restartStep"] = self.restartStep + if self.restartDirectory is not None: + serialized["restartDirectory"] = self.restartDirectory + if self.restartFile is not None: + serialized["restartFile"] = self.restartFile + if self.restartChunkSize is not None: + serialized["restartChunkSize"] = self.restartChunkSize + if self.restartLoop is not None: + serialized["restartLoop"] = self.restartLoop + if self.openPMD is not None: + serialized["openPMD"] = self.openPMD + return serialized diff --git a/lib/python/picongpu/pypicongpu/output/energy_histogram.py b/lib/python/picongpu/pypicongpu/output/energy_histogram.py index 09c764bc54..d52faa3406 100644 --- a/lib/python/picongpu/pypicongpu/output/energy_histogram.py +++ b/lib/python/picongpu/pypicongpu/output/energy_histogram.py @@ -8,7 +8,6 @@ from .. import util from ..species import Species from .timestepspec import TimeStepSpec - from .plugin import Plugin import typeguard @@ -25,11 +24,23 @@ class EnergyHistogram(Plugin): _name = "energyhistogram" - def __init__(self): - "do nothing" + def __init__(self, species: Species, period: TimeStepSpec, bin_count: int, min_energy: float, max_energy: float): + self.species = species + self.period = period + self.bin_count = bin_count + self.min_energy = min_energy + self.max_energy = max_energy + + def check(self): + """Validate attributes.""" + if self.bin_count <= 0: + raise ValueError(f"bin_count must be positive, got {self.bin_count}") + if self.min_energy >= self.max_energy: + raise ValueError(f"min_energy must be less than max_energy, got {self.min_energy} >= {self.max_energy}") def _get_serialized(self) -> typing.Dict: """Return the serialized representation of the object.""" + self.check() return { "species": self.species.get_rendering_context(), "period": self.period.get_rendering_context(), diff --git a/lib/python/picongpu/pypicongpu/output/macro_particle_count.py b/lib/python/picongpu/pypicongpu/output/macro_particle_count.py index 4d10b0a8ec..18038add4d 100644 --- a/lib/python/picongpu/pypicongpu/output/macro_particle_count.py +++ b/lib/python/picongpu/pypicongpu/output/macro_particle_count.py @@ -8,25 +8,43 @@ from .timestepspec import TimeStepSpec from .. import util from ..species import Species - from .plugin import Plugin - import typeguard -import typing +import warnings @typeguard.typechecked class MacroParticleCount(Plugin): + """ + MacroParticleCount output plugin for PIConGPU. + + Outputs the number of macro-particles for a given species at specified time steps. + """ + species = util.build_typesafe_property(Species) period = util.build_typesafe_property(TimeStepSpec) - _name = "macroparticlecount" def __init__(self): - "do nothing" - - def _get_serialized(self) -> typing.Dict: + """Initialize with no attributes set.""" + pass + + def check(self): + """Validate attributes.""" + try: + _ = self.species + except AttributeError: + raise ValueError("species must be set") from None + try: + _ = self.period + except AttributeError: + raise ValueError("period must be set") from None + + def _get_serialized(self) -> dict: """Return the serialized representation of the object.""" + self.check() + if not self.period.get_rendering_context().get("specs", []): + warnings.warn("MacroParticleCount is disabled because period is empty") return { "species": self.species.get_rendering_context(), "period": self.period.get_rendering_context(), diff --git a/lib/python/picongpu/pypicongpu/output/openpmd.py b/lib/python/picongpu/pypicongpu/output/openpmd.py new file mode 100644 index 0000000000..dc2969d47e --- /dev/null +++ b/lib/python/picongpu/pypicongpu/output/openpmd.py @@ -0,0 +1,93 @@ +""" +This file is part of PIConGPU. +Copyright 2021-2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from .. import util +from .timestepspec import TimeStepSpec +from .rangespec import RangeSpec +from .plugin import Plugin +from .openpmd_sources.source_base import SourceBase + +import typeguard +import typing +from typing import Optional, List, Literal, Dict, Union + + +@typeguard.typechecked +class OpenPMD(Plugin): + period = util.build_typesafe_property(TimeStepSpec) + source = util.build_typesafe_property(Optional[List[SourceBase]]) + range = util.build_typesafe_property(Optional[RangeSpec]) + file = util.build_typesafe_property(Optional[str]) + ext = util.build_typesafe_property(Optional[Literal["bp", "h5", "sst"]]) + infix = util.build_typesafe_property(Optional[str]) + json = util.build_typesafe_property(Union[str, Dict, None]) + json_restart = util.build_typesafe_property(Union[str, Dict, None]) + data_preparation_strategy = util.build_typesafe_property( + Optional[Literal["doubleBuffer", "adios", "mappedMemory", "hdf5"]] + ) + toml = util.build_typesafe_property(Optional[str]) + particle_io_chunk_size = util.build_typesafe_property(Optional[int]) + file_writing = util.build_typesafe_property(Optional[Literal["create", "append"]]) + + _name = "openpmd" + + def __init__( + self, + period: TimeStepSpec, + source: Optional[List[SourceBase]] = None, + range: Optional[RangeSpec] = None, + file: Optional[str] = None, + ext: Optional[Literal["bp", "h5", "sst"]] = "bp", + infix: Optional[str] = "NULL", + json: Optional[Union[str, Dict]] = None, + json_restart: Optional[Union[str, Dict]] = None, + data_preparation_strategy: Optional[Literal["doubleBuffer", "adios", "mappedMemory", "hdf5"]] = None, + toml: Optional[str] = None, + particle_io_chunk_size: Optional[int] = None, + file_writing: Optional[Literal["create", "append"]] = "create", + ): + self.period = period + self.source = source + self.range = range + self.file = file + self.ext = ext + self.infix = infix + self.json = None if json is None or json == {} else json + self.json_restart = None if json_restart is None or json_restart == {} else json_restart + self.data_preparation_strategy = data_preparation_strategy + self.toml = toml + self.particle_io_chunk_size = particle_io_chunk_size + self.file_writing = file_writing + self.check() + + def check(self) -> None: + if self.file is not None and len(self.file.strip()) == 0: + raise ValueError("file must be a non-empty string") + if self.particle_io_chunk_size is not None and self.particle_io_chunk_size < 1: + raise ValueError("particle_io_chunk_size (in MiB) must be positive") + if self.ext == "sst" and self.infix is not None and self.infix != "NULL": + raise ValueError("infix must be 'NULL' when ext is 'sst'") + if self.source is not None and not all(isinstance(s, SourceBase) for s in self.source): + raise ValueError("source must be a list of SourceBase objects") + + def _get_serialized(self) -> typing.Dict: + self.check() + + return { + "period": self.period.get_rendering_context(), + "source": [s._get_serialized() for s in self.source] if self.source is not None else None, + "range": self.range._get_serialized() if self.range else None, + "file": self.file, + "ext": self.ext, + "infix": self.infix, + "json": self.json, + "json_restart": self.json_restart, + "data_preparation_strategy": self.data_preparation_strategy, + "toml": self.toml, + "particle_io_chunk_size": self.particle_io_chunk_size, + "file_writing": self.file_writing, + } diff --git a/lib/python/picongpu/pypicongpu/output/openpmd_sources/__init__.py b/lib/python/picongpu/pypicongpu/output/openpmd_sources/__init__.py new file mode 100644 index 0000000000..0bf2d8efbf --- /dev/null +++ b/lib/python/picongpu/pypicongpu/output/openpmd_sources/__init__.py @@ -0,0 +1,37 @@ +from .sources import ( + Auto, + BoundElectronDensity, + SourceBase, + ChargeDensity, + Counter, + Density, + DerivedAttributes, + Energy, + EnergyDensity, + EnergyDensityCutoff, + LarmorPower, + MacroCounter, + MidCurrentDensityComponent, + Momentum, + MomentumDensity, + WeightedVelocity, +) + +__all__ = [ + "Auto", + "BoundElectronDensity", + "SourceBase", + "ChargeDensity", + "Counter", + "Density", + "DerivedAttributes", + "Energy", + "EnergyDensity", + "EnergyDensityCutoff", + "LarmorPower", + "MacroCounter", + "MidCurrentDensityComponent", + "Momentum", + "MomentumDensity", + "WeightedVelocity", +] diff --git a/lib/python/picongpu/pypicongpu/output/openpmd_sources/source_base.py b/lib/python/picongpu/pypicongpu/output/openpmd_sources/source_base.py new file mode 100644 index 0000000000..5a51a84b4a --- /dev/null +++ b/lib/python/picongpu/pypicongpu/output/openpmd_sources/source_base.py @@ -0,0 +1,37 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from ...rendering import SelfRegisteringRenderedObject +from abc import ABCMeta, abstractmethod +import typeguard + + +@typeguard.typechecked +class SourceBase(SelfRegisteringRenderedObject, metaclass=ABCMeta): + """ + Abstract base class for OpenPMD sources in PIConGPU. + """ + + @property + @abstractmethod + def filter(self) -> str: + """ + Filter name for particle selection. + + Returns + ------- + str + Filter name. + """ + pass + + @abstractmethod + def check(self) -> None: + """ + Validate data source parameters. + """ + pass diff --git a/lib/python/picongpu/pypicongpu/output/openpmd_sources/sources.py b/lib/python/picongpu/pypicongpu/output/openpmd_sources/sources.py new file mode 100644 index 0000000000..70169307f1 --- /dev/null +++ b/lib/python/picongpu/pypicongpu/output/openpmd_sources/sources.py @@ -0,0 +1,186 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from ... import util +from ...species import Species +from .source_base import SourceBase +import typeguard +import typing + +# --------------------------------------------------------------------------- +# Base classes +# --------------------------------------------------------------------------- + + +@typeguard.typechecked +class SourceBaseSpeciesFilter(SourceBase): + """Common base for sources that use (species, filter).""" + + species = util.build_typesafe_property(Species) + filter = util.build_typesafe_property(str) + + def __init__(self, species: Species, filter: str = "species_all"): # default filter ="species_all" + self.species = species + self.filter = filter + self.check() + + def check(self) -> None: + valid_filters = ["species_all", "fields_all", "custom_filter"] + if not isinstance(self.filter, str): + raise ValueError(f"Filter must be a string, got {type(self.filter)}") + if self.filter not in valid_filters: + raise ValueError(f"Filter must be one of {valid_filters}, got {self.filter}") + if not isinstance(self.species, Species): + raise ValueError(f"Species must be a Species, got {type(self.species)}") + + def _get_serialized(self) -> typing.Dict: + self.check() + return { + "species": self.species.get_rendering_context(), + "filter": self.filter, + "type": self.__class__.__name__.lower(), + } + + +@typeguard.typechecked +class SourceBaseFilterOnly(SourceBase): + """Common base for sources that only use filter.""" + + filter = util.build_typesafe_property(str) + + def __init__(self, filter: str = "species_all"): + self.filter = filter + self.check() + + def check(self) -> None: + valid_filters = ["species_all", "fields_all", "custom_filter"] + if not isinstance(self.filter, str): + raise ValueError(f"Filter must be a string, got {type(self.filter)}") + if self.filter not in valid_filters: + raise ValueError(f"Filter must be one of {valid_filters}, got {self.filter}") + + def _get_serialized(self) -> typing.Dict: + self.check() + return {"filter": self.filter, "type": self.__class__.__name__.lower()} + + +# --------------------------------------------------------------------------- +# sources with (species + filter, no extras) +# --------------------------------------------------------------------------- + + +class BoundElectronDensity(SourceBaseSpeciesFilter): + pass + + +class ChargeDensity(SourceBaseSpeciesFilter): + pass + + +class Counter(SourceBaseSpeciesFilter): + pass + + +class Density(SourceBaseSpeciesFilter): + pass + + +class Energy(SourceBaseSpeciesFilter): + pass + + +class EnergyDensity(SourceBaseSpeciesFilter): + pass + + +class LarmorPower(SourceBaseSpeciesFilter): + pass + + +class MacroCounter(SourceBaseSpeciesFilter): + pass + + +# --------------------------------------------------------------------------- +# sources with (filter only) +# --------------------------------------------------------------------------- + + +class Auto(SourceBaseFilterOnly): + pass + + +class DerivedAttributes(SourceBaseFilterOnly): + pass + + +# --------------------------------------------------------------------------- +# Sources with extra parameters +# --------------------------------------------------------------------------- + + +@typeguard.typechecked +class EnergyDensityCutoff(SourceBaseSpeciesFilter): + cutoff_max_energy = util.build_typesafe_property(float) + + def __init__(self, species: Species, filter: str = "species_all", cutoff_max_energy: typing.Optional[float] = None): + if cutoff_max_energy is None: + raise ValueError("cutoff_max_energy is required and must be a positive number") + self.cutoff_max_energy = cutoff_max_energy + super().__init__(species, filter) + + def check(self) -> None: + super().check() + if not isinstance(self.cutoff_max_energy, (int, float)): + raise ValueError(f"cutoff_max_energy must be a number, got {type(self.cutoff_max_energy)}") + if self.cutoff_max_energy <= 0: + raise ValueError(f"cutoff_max_energy must be positive, got {self.cutoff_max_energy}") + + def _get_serialized(self) -> typing.Dict: + base = super()._get_serialized() + base.update({"cutoff_max_energy": self.cutoff_max_energy}) + return base + + +@typeguard.typechecked +class Momentum(SourceBaseSpeciesFilter): + direction = util.build_typesafe_property(str) + + def __init__(self, species: Species, filter: str = "species_all", direction: str = "x"): + self.direction = direction + super().__init__(species, filter) + + def check(self) -> None: + super().check() + if self.direction not in ["x", "y", "z"]: + raise ValueError(f"Direction must be 'x', 'y', or 'z', got {self.direction}") + + def _get_serialized(self) -> typing.Dict: + base = super()._get_serialized() + base.update({"direction": self.direction}) + return base + + +@typeguard.typechecked +class MidCurrentDensityComponent(Momentum): + """Same as Momentum (species + filter + direction).""" + + pass + + +@typeguard.typechecked +class MomentumDensity(Momentum): + """Same as Momentum (species + filter + direction).""" + + pass + + +@typeguard.typechecked +class WeightedVelocity(Momentum): + """Same as Momentum (species + filter + direction).""" + + pass diff --git a/lib/python/picongpu/pypicongpu/output/phase_space.py b/lib/python/picongpu/pypicongpu/output/phase_space.py index b80e6f871c..3e76dff8cf 100644 --- a/lib/python/picongpu/pypicongpu/output/phase_space.py +++ b/lib/python/picongpu/pypicongpu/output/phase_space.py @@ -7,30 +7,36 @@ from .. import util from ..species import Species - from .plugin import Plugin from .timestepspec import TimeStepSpec - import typeguard import typing -from typing import Literal +import warnings @typeguard.typechecked class PhaseSpace(Plugin): + """ + Phase Space output plugin for PIConGPU. + + Extracts phase-space data for a given species, spatial coordinate, and momentum coordinate. + """ + species = util.build_typesafe_property(Species) period = util.build_typesafe_property(TimeStepSpec) - spatial_coordinate = util.build_typesafe_property(Literal["x", "y", "z"]) - momentum_coordinate = util.build_typesafe_property(Literal["px", "py", "pz"]) + spatial_coordinate = util.build_typesafe_property(typing.Literal["x", "y", "z"]) + momentum_coordinate = util.build_typesafe_property(typing.Literal["px", "py", "pz"]) min_momentum = util.build_typesafe_property(float) max_momentum = util.build_typesafe_property(float) _name = "phasespace" def __init__(self): - "do nothing" + """do nothing""" + pass def check(self): + """Validate attributes.""" if self.min_momentum >= self.max_momentum: raise ValueError( "PhaseSpace's min_momentum should be smaller than max_momentum. " @@ -40,6 +46,8 @@ def check(self): def _get_serialized(self) -> typing.Dict: """Return the serialized representation of the object.""" self.check() + if not self.period.get_rendering_context().get("specs", []): + warnings.warn("PhaseSpace is disabled because period is empty") return { "species": self.species.get_rendering_context(), "period": self.period.get_rendering_context(), diff --git a/lib/python/picongpu/pypicongpu/output/png.py b/lib/python/picongpu/pypicongpu/output/png.py index c28d226fcb..437d711376 100644 --- a/lib/python/picongpu/pypicongpu/output/png.py +++ b/lib/python/picongpu/pypicongpu/output/png.py @@ -1,17 +1,15 @@ """ This file is part of PIConGPU. -Copyright 2021-2024 PIConGPU contributors -Authors: Masoud Afshari +Copyright 2021-2025 PIConGPU contributors +Authors: Masoud Afshari, Julian Lenz License: GPLv3+ """ from .. import util from ..species import Species - from .plugin import Plugin from .timestepspec import TimeStepSpec - import typeguard import typing from enum import Enum @@ -77,15 +75,121 @@ class Png(Plugin): _name = "png" - def __init__(self): - "do nothing" + def __init__( + self, + species: Species, + period: TimeStepSpec, + axis: str, + slicePoint: float, + folder: str, + scale_image: float, + scale_to_cellsize: bool, + white_box_per_GPU: bool, + EM_FIELD_SCALE_CHANNEL1: EMFieldScaleEnum, + EM_FIELD_SCALE_CHANNEL2: EMFieldScaleEnum, + EM_FIELD_SCALE_CHANNEL3: EMFieldScaleEnum, + preParticleDensCol: ColorScaleEnum, + preChannel1Col: ColorScaleEnum, + preChannel2Col: ColorScaleEnum, + preChannel3Col: ColorScaleEnum, + customNormalizationSI: typing.List[float], + preParticleDens_opacity: float, + preChannel1_opacity: float, + preChannel2_opacity: float, + preChannel3_opacity: float, + preChannel1: str, + preChannel2: str, + preChannel3: str, + ): + self.species = species + self.period = period + self.axis = axis + self.slicePoint = slicePoint + self.folder = folder + self.scale_image = scale_image + self.scale_to_cellsize = scale_to_cellsize + self.white_box_per_GPU = white_box_per_GPU + self.EM_FIELD_SCALE_CHANNEL1 = EM_FIELD_SCALE_CHANNEL1 + self.EM_FIELD_SCALE_CHANNEL2 = EM_FIELD_SCALE_CHANNEL2 + self.EM_FIELD_SCALE_CHANNEL3 = EM_FIELD_SCALE_CHANNEL3 + self.preParticleDensCol = preParticleDensCol + self.preChannel1Col = preChannel1Col + self.preChannel2Col = preChannel2Col + self.preChannel3Col = preChannel3Col + self.customNormalizationSI = customNormalizationSI + self.preParticleDens_opacity = preParticleDens_opacity + self.preChannel1_opacity = preChannel1_opacity + self.preChannel2_opacity = preChannel2_opacity + self.preChannel3_opacity = preChannel3_opacity + self.preChannel1 = preChannel1 + self.preChannel2 = preChannel2 + self.preChannel3 = preChannel3 + + def check(self): + """Validate attributes.""" + try: + _ = self.species + except AttributeError: + raise ValueError("species must be set") from None + try: + _ = self.period + except AttributeError: + raise ValueError("period must be set") from None + if self.axis not in ["xy", "yx", "xz", "zx", "yz", "zy"]: + raise ValueError(f"axis must be 'xy', 'yx', 'xz', 'zx', 'yz', or 'zy', got {self.axis}") + if self.slicePoint < 0.0 or self.slicePoint > 1.0: + raise ValueError(f"slicePoint must be in [0, 1], got {self.slicePoint}") + if self.scale_image <= 0: + raise ValueError(f"scale_image must be positive, got {self.scale_image}") + if self.scale_to_cellsize and self.scale_image == 1.0: + raise ValueError(f"scale_image must not be 1.0 when scale_to_cellsize is True, got {self.scale_image}") + if self.preParticleDens_opacity < 0 or self.preParticleDens_opacity > 1: + raise ValueError(f"preParticleDens_opacity must be in [0, 1], got {self.preParticleDens_opacity}") + if self.preChannel1_opacity < 0 or self.preChannel1_opacity > 1: + raise ValueError(f"preChannel1_opacity must be in [0, 1], got {self.preChannel1_opacity}") + if self.preChannel2_opacity < 0 or self.preChannel2_opacity > 1: + raise ValueError(f"preChannel2_opacity must be in [0, 1], got {self.preChannel2_opacity}") + if self.preChannel3_opacity < 0 or self.preChannel3_opacity > 1: + raise ValueError(f"preChannel3_opacity must be in [0, 1], got {self.preChannel3_opacity}") + for channel, name in [ + (self.preChannel1, "preChannel1"), + (self.preChannel2, "preChannel2"), + (self.preChannel3, "preChannel3"), + ]: + if not isinstance(channel, str) or not channel.strip(): + raise ValueError(f"{name} must be a non-empty string, got {channel}") + if len(self.customNormalizationSI) != 3: + raise ValueError( + f"customNormalizationSI must contain exactly 3 floats, got {len(self.customNormalizationSI)}" + ) + for val in self.customNormalizationSI: + if not isinstance(val, float): + raise ValueError(f"customNormalizationSI values must be floats, got {val}") + if not isinstance(self.EM_FIELD_SCALE_CHANNEL1, EMFieldScaleEnum): + raise ValueError( + f"EM_FIELD_SCALE_CHANNEL1 must be in {list(EMFieldScaleEnum)}, got {self.EM_FIELD_SCALE_CHANNEL1}" + ) + if not isinstance(self.EM_FIELD_SCALE_CHANNEL2, EMFieldScaleEnum): + raise ValueError( + f"EM_FIELD_SCALE_CHANNEL2 must be in {list(EMFieldScaleEnum)}, got {self.EM_FIELD_SCALE_CHANNEL2}" + ) + if not isinstance(self.EM_FIELD_SCALE_CHANNEL3, EMFieldScaleEnum): + raise ValueError( + f"EM_FIELD_SCALE_CHANNEL3 must be in {list(EMFieldScaleEnum)}, got {self.EM_FIELD_SCALE_CHANNEL3}" + ) + if not isinstance(self.preParticleDensCol, ColorScaleEnum): + raise ValueError(f"preParticleDensCol must be in {list(ColorScaleEnum)}, got {self.preParticleDensCol}") + if not isinstance(self.preChannel1Col, ColorScaleEnum): + raise ValueError(f"preChannel1Col must be in {list(ColorScaleEnum)}, got {self.preChannel1Col}") + if not isinstance(self.preChannel2Col, ColorScaleEnum): + raise ValueError(f"preChannel2Col must be in {list(ColorScaleEnum)}, got {self.preChannel2Col}") + if not isinstance(self.preChannel3Col, ColorScaleEnum): + raise ValueError(f"preChannel3Col must be in {list(ColorScaleEnum)}, got {self.preChannel3Col}") def _get_serialized(self) -> typing.Dict: """Return the serialized representation of the object.""" - - # Transform customNormalizationSI into a list of dictionaries + self.check() custom_normalization_si_serialized = [{"value": val} for val in self.customNormalizationSI] - return { "species": self.species.get_rendering_context(), "period": self.period.get_rendering_context(), diff --git a/lib/python/picongpu/pypicongpu/output/rangespec.py b/lib/python/picongpu/pypicongpu/output/rangespec.py new file mode 100644 index 0000000000..654b665461 --- /dev/null +++ b/lib/python/picongpu/pypicongpu/output/rangespec.py @@ -0,0 +1,92 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from typing import List +from ..rendering.renderedobject import RenderedObject +from ..util import build_typesafe_property + +import typeguard + + +class _RangeSpecMeta(type): + """ + Custom metaclass providing the [] operator for RangeSpec. + """ + + def __getitem__(cls, args): + if not isinstance(args, tuple): + args = (args,) + return cls(*args) + + +def _serialize(spec: slice) -> dict: + """ + Serialize a slice object to a JSON-compatible dictionary. + + :param spec: A slice object representing a range for one dimension. + :return: Dictionary with begin and end keys. + :raises ValueError: If the input is not a slice or has invalid endpoints. + """ + if not isinstance(spec, slice): + raise ValueError(f"Expected a slice for range, got {type(spec)}") + if spec.start is not None and not isinstance(spec.start, int): + raise ValueError(f"Begin must be int or None, got {type(spec.start)}") + if spec.stop is not None and not isinstance(spec.stop, int): + raise ValueError(f"End must be int or None, got {type(spec.stop)}") + return { + "begin": spec.start if spec.start is not None else 0, + "end": spec.stop if spec.stop is not None else -1, + } + + +@typeguard.typechecked +class RangeSpec(RenderedObject, metaclass=_RangeSpecMeta): + """ + A class to specify a contiguous range of cells for simulation output in 1D, 2D, or 3D. + + This class stores a list of slices representing inclusive cell ranges for each dimension. + It is used as the output of PICMI RangeSpec.get_as_pypicongpu, with negative indices and + clipping already handled. Slices must have step=None (contiguous ranges) and integer or None + endpoints. Use the [] operator for concise syntax, e.g., RangeSpec[0:10, 5:15]. + Example: + - 1D: RangeSpec[0:10] specifies cells 0 to 10 (x). + - 2D: RangeSpec[0:10, 5:15] specifies cells 0 to 10 (x), 5 to 15 (y). + - 3D: RangeSpec[0:10, 5:15, 2:8] specifies cells 0 to 10 (x), 5 to 15 (y), 2 to 8 (z). + """ + + ranges = build_typesafe_property(List[slice]) + + def __init__(self, *args): + """ + Initialize a RangeSpec with a list of slices. + + :param args: 1 to 3 slice objects, e.g., slice(0, 10), slice(5, 15). + :raises TypeError: If args contains non-slice elements or invalid endpoint types. + :raises ValueError: If args is empty, has more than 3 slices, or contains slices with step != None. + """ + if not args: + raise ValueError("RangeSpec must have at least one range") + if len(args) > 3: + raise ValueError(f"RangeSpec must have at most 3 ranges, got {len(args)}") + if not all(isinstance(s, slice) for s in args): + raise TypeError("All elements must be slice objects") + for i, s in enumerate(args): + if s.step is not None: + raise ValueError(f"Step must be None in dimension {i+1}, got {s.step}") + if s.start is not None and not isinstance(s.start, int): + raise TypeError(f"Begin in dimension {i+1} must be int or None, got {type(s.start)}") + if s.stop is not None and not isinstance(s.stop, int): + raise TypeError(f"End in dimension {i+1} must be int or None, got {type(s.stop)}") + self.ranges = list(args) + + def _get_serialized(self) -> dict: + """ + Serialize the RangeSpec to a JSON-compatible dictionary. + + :return: Dictionary with serialized ranges. + """ + return {"ranges": list(map(_serialize, self.ranges))} diff --git a/lib/python/picongpu/pypicongpu/output/timestepspec.py b/lib/python/picongpu/pypicongpu/output/timestepspec.py index ea39243a4b..566c81b3cf 100644 --- a/lib/python/picongpu/pypicongpu/output/timestepspec.py +++ b/lib/python/picongpu/pypicongpu/output/timestepspec.py @@ -1,15 +1,13 @@ """ This file is part of PIConGPU. Copyright 2025 PIConGPU contributors -Authors: Julian Lenz +Authors: Julian Lenz, Masoud Afshari License: GPLv3+ """ - +import typeguard from ..rendering.renderedobject import RenderedObject from ..util import build_typesafe_property -import typeguard - def _serialize(spec): if isinstance(spec, slice): @@ -26,7 +24,20 @@ class TimeStepSpec(RenderedObject): specs = build_typesafe_property(list[slice]) def __init__(self, specs: list[slice]): + # Here, you could add normalization checks if you want (optional) self.specs = specs + def check(self): + """ + Validate the TimeStepSpec. + + Ensures all slices have positive step sizes and valid slice types. + """ + for spec in self.specs: + if not isinstance(spec, slice): + raise ValueError(f"Expected slice, got {type(spec)}") + if spec.step is not None and spec.step < 1: + raise ValueError("Step size must be >= 1") + def _get_serialized(self): return {"specs": list(map(_serialize, self.specs))} diff --git a/lib/python/picongpu/pypicongpu/rendering/renderedobject.py b/lib/python/picongpu/pypicongpu/rendering/renderedobject.py index 89006eb9a0..409219f1b1 100644 --- a/lib/python/picongpu/pypicongpu/rendering/renderedobject.py +++ b/lib/python/picongpu/pypicongpu/rendering/renderedobject.py @@ -314,6 +314,7 @@ def get_rendering_context(self): # TODO: The schema for different registered classes are likely to be identical # upto the fact that they refer to different allowed content. # We might want to unify this in the future. + return RenderedObject.check_context_for_type( self._registered_class, { diff --git a/share/picongpu/pypicongpu/examples/laser_wakefield/main.py b/share/picongpu/pypicongpu/examples/laser_wakefield/main.py index 5cb7f42dcb..d05470d251 100644 --- a/share/picongpu/pypicongpu/examples/laser_wakefield/main.py +++ b/share/picongpu/pypicongpu/examples/laser_wakefield/main.py @@ -178,9 +178,9 @@ period=picmi.diagnostics.TimeStepSpec[::100], axis="yx", slice_point=0.5, - folder_name="pngElectronsYX", - scale_image=1.0, - scale_to_cellsize=True, + folder="pngElectronsYX", + scale_image=1.0, # scale_image must not be 1.0 when scale_to_cellsize is True + scale_to_cellsize=False, white_box_per_gpu=False, em_field_scale_channel1=EMFieldScaleEnum(7), em_field_scale_channel2=EMFieldScaleEnum(-1), @@ -201,6 +201,11 @@ picmi.diagnostics.Checkpoint( period=picmi.diagnostics.TimeStepSpec[::100], ), + picmi.diagnostics.OpenPMD( + period=picmi.diagnostics.TimeStepSpec[::100], + file="simOutput", + ext="bp", + ), ] sim.add_laser(laser, None) @@ -215,14 +220,8 @@ min_weight_input.addToCustomInput({"minimum_weight": 10.0}, "minimum_weight") sim.picongpu_add_custom_user_input(min_weight_input) - output_configuration = pypicongpu.customuserinput.CustomUserInput() - - output_configuration.addToCustomInput( - {"openPMD_period": 100, "openPMD_file": "simData", "openPMD_extension": "bp"}, - "openPMD plugin configuration", - ) - - sim.picongpu_add_custom_user_input(output_configuration) + # output_configuration = pypicongpu.customuserinput.CustomUserInput() + # sim.picongpu_add_custom_user_input(output_configuration) if __name__ == "__main__": sim.write_input_file(OUTPUT_DIRECTORY_PATH) diff --git a/share/picongpu/pypicongpu/schema/output/checkpoint.Checkpoint.json b/share/picongpu/pypicongpu/schema/output/checkpoint.Checkpoint.json index 992660942d..abdce3ff3f 100644 --- a/share/picongpu/pypicongpu/schema/output/checkpoint.Checkpoint.json +++ b/share/picongpu/pypicongpu/schema/output/checkpoint.Checkpoint.json @@ -110,6 +110,13 @@ "description": "Additional options for openPMD IO-backend", "unevaluatedProperties": false, "properties": { + "backend": { + "type": [ + "string", + "null" + ], + "description": "Backend for openPMD IO (e.g., bp, h5)" + }, "ext": { "type": [ "string", @@ -159,6 +166,18 @@ ], "description": "Chunk size for particle IO in openPMD", "minimum": 1 + }, + "file_writing": { + "type": [ + "string", + "null" + ], + "enum": [ + "create", + "append", + null + ], + "description": "File access mode for writing (create=new files, append=checkpoint-restart)." } } } diff --git a/share/picongpu/pypicongpu/schema/output/openpmd.OpenPMD.json b/share/picongpu/pypicongpu/schema/output/openpmd.OpenPMD.json new file mode 100644 index 0000000000..39b3a5759d --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd.OpenPMD.json @@ -0,0 +1,184 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd.OpenPMD", + "type": "object", + "description": "openPMD output configuration for PIConGPU, writing simulation data to disk using the openPMD standard.", + "unevaluatedProperties": false, + "required": [ + "period" + ], + "properties": { + "period": { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.timestepspec.TimeStepSpec", + "description": "Specification of time steps for data output." + }, + "source": { + "type": [ + "array", + "null" + ], + "items": { + "oneOf": [ + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.auto.Auto" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.bound_electron_density.BoundElectronDensity" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.charge_density.ChargeDensity" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.counter.Counter" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.density.Density" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.derived_attributes.DerivedAttributes" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.energy.Energy" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.energy_density.EnergyDensity" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.energy_density_cutoff.EnergyDensityCutoff" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.larmor_power.LarmorPower" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.macro_counter.MacroCounter" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.mid_current_density_component.MidCurrentDensityComponent" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.momentum.Momentum" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.momentum_density.MomentumDensity" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.weighted_velocity.WeightedVelocity" + } + ] + }, + "description": "List of data source objects to include in the dump (e.g., ChargeDensity, Auto)." + }, + "range": { + "anyOf": [ + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.rangespec.RangeSpec" + }, + { + "type": "null" + } + ], + "description": "Contiguous range of cells to dump fields, in 'begin:end,begin:end,begin:end' format." + }, + "file": { + "type": [ + "string", + "null" + ], + "description": "Relative or absolute file path prefix for openPMD output files." + }, + "ext": { + "type": [ + "string", + "null" + ], + "enum": [ + "bp", + "h5", + "sst", + null + ], + "description": "File extension controlling the openPMD backend (bp=ADIOS2, h5=HDF5, sst=ADIOS2/SST)." + }, + "infix": { + "type": [ + "string", + "null" + ], + "description": "Filename infix for iteration layout (e.g., '_%06T'), 'NULL' for group-based layout." + }, + "json": { + "type": [ + "string", + "object", + "null" + ], + "description": "openPMD backend configuration as JSON string or dictionary." + }, + "json_restart": { + "type": [ + "string", + "object", + "null" + ], + "description": "Backend-specific parameters for restarting, as JSON string or dictionary." + }, + "data_preparation_strategy": { + "type": [ + "string", + "null" + ], + "enum": [ + "doubleBuffer", + "adios", + "mappedMemory", + "hdf5", + null + ], + "description": "Strategy for particle data preparation (e.g., doubleBuffer, adios)." + }, + "toml": { + "type": [ + "string", + "null" + ], + "description": "Path to a TOML file for openPMD configuration." + }, + "particle_io_chunk_size": { + "type": [ + "integer", + "null" + ], + "minimum": 1, + "description": "Size of particle data chunks used in writing (in MiB)." + }, + "file_writing": { + "type": [ + "string", + "null" + ], + "enum": [ + "create", + "append", + null + ], + "description": "File access mode for writing (create=new files, append=checkpoint-restart)." + } + }, + "allOf": [ + { + "if": { + "properties": { + "ext": { + "const": "sst" + } + } + }, + "then": { + "properties": { + "infix": { + "const": "NULL" + } + } + } + } + ] +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/auto.Auto.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/auto.Auto.json new file mode 100644 index 0000000000..2c263853c8 --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/auto.Auto.json @@ -0,0 +1,22 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.auto.Auto", + "type": "object", + "description": "Auto data source for openPMD output in PIConGPU, enabling automatic derived attributes with a mandatory filter.", + "unevaluatedProperties": false, + "required": [ + "type", + "filter" + ], + "properties": { + "type": { + "type": "string", + "const": "auto", + "description": "Type of the data source." + }, + "filter": { + "type": "string", + "description": "Name of a filter to select particles contributing to the data source.", + "default": "species_all" + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/bound_electron_density.BoundElectronDensity.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/bound_electron_density.BoundElectronDensity.json new file mode 100644 index 0000000000..364a0e9fd0 --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/bound_electron_density.BoundElectronDensity.json @@ -0,0 +1,26 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.bound_electron_density.BoundElectronDensity", + "type": "object", + "description": "Bound electron density data source for openPMD output in PIConGPU, calculating density for bound electrons of a specified species.", + "unevaluatedProperties": false, + "required": [ + "type", + "species" + ], + "properties": { + "type": { + "type": "string", + "const": "boundelectrondensity", + "description": "Type of the data source." + }, + "species": { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species", + "description": "Particle species to calculate bound electron density for." + }, + "filter": { + "type": "string", + "description": "Name of a filter to select particles contributing to the data source.", + "default": "species_all" + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/charge_density.ChargeDensity.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/charge_density.ChargeDensity.json new file mode 100644 index 0000000000..05a176b12e --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/charge_density.ChargeDensity.json @@ -0,0 +1,26 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.charge_density.ChargeDensity", + "type": "object", + "description": "Charge density data source for openPMD output in PIConGPU, calculating charge density for a specified species.", + "unevaluatedProperties": false, + "required": [ + "type", + "species" + ], + "properties": { + "type": { + "type": "string", + "const": "chargedensity", + "description": "Type of the data source." + }, + "species": { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species", + "description": "Particle species to calculate charge density for." + }, + "filter": { + "type": "string", + "description": "Name of a filter to select particles contributing to the data source.", + "default": "species_all" + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/counter.Counter.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/counter.Counter.json new file mode 100644 index 0000000000..c8aeb3564a --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/counter.Counter.json @@ -0,0 +1,26 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.counter.Counter", + "type": "object", + "description": "Counter data source for openPMD output in PIConGPU, counting particles of a specified species.", + "unevaluatedProperties": false, + "required": [ + "type", + "species" + ], + "properties": { + "type": { + "type": "string", + "const": "counter", + "description": "Type of the data source." + }, + "species": { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species", + "description": "Particle species to count." + }, + "filter": { + "type": "string", + "description": "Name of a filter to select particles contributing to the data source.", + "default": "species_all" + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/density.Density.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/density.Density.json new file mode 100644 index 0000000000..c9beac1c0b --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/density.Density.json @@ -0,0 +1,26 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.density.Density", + "type": "object", + "description": "Density data source for openPMD output in PIConGPU, calculating particle density for a specified species.", + "unevaluatedProperties": false, + "required": [ + "type", + "species" + ], + "properties": { + "type": { + "type": "string", + "const": "density", + "description": "Type of the data source." + }, + "species": { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species", + "description": "Particle species to calculate density for." + }, + "filter": { + "type": "string", + "description": "Name of a filter to select particles contributing to the data source.", + "default": "species_all" + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/derived_attributes.DerivedAttributes.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/derived_attributes.DerivedAttributes.json new file mode 100644 index 0000000000..b6ebed330a --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/derived_attributes.DerivedAttributes.json @@ -0,0 +1,24 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.derived_attributes.DerivedAttributes", + "type": "object", + "description": "Derived attributes data source for openPMD output in PIConGPU, enabling derived attributes with an optional filter.", + "unevaluatedProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "const": "derivedattributes", + "description": "Type of the data source." + }, + "filter": { + "type": [ + "string", + "null" + ], + "description": "Name of a filter to select particles contributing to the data source.", + "default": "species_all" + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/energy.Energy.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/energy.Energy.json new file mode 100644 index 0000000000..133ebd69a5 --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/energy.Energy.json @@ -0,0 +1,26 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.energy.Energy", + "type": "object", + "description": "Energy data source for openPMD output in PIConGPU, calculating particle energy for a specified species.", + "unevaluatedProperties": false, + "required": [ + "type", + "species" + ], + "properties": { + "type": { + "type": "string", + "const": "energy", + "description": "Type of the data source." + }, + "species": { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species", + "description": "Particle species to calculate energy for." + }, + "filter": { + "type": "string", + "description": "Name of a filter to select particles contributing to the data source.", + "default": "species_all" + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/energy_density.EnergyDensity.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/energy_density.EnergyDensity.json new file mode 100644 index 0000000000..afc990eb16 --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/energy_density.EnergyDensity.json @@ -0,0 +1,26 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.energy_density.EnergyDensity", + "type": "object", + "description": "Energy density data source for openPMD output in PIConGPU, calculating energy density for a specified species.", + "unevaluatedProperties": false, + "required": [ + "type", + "species" + ], + "properties": { + "type": { + "type": "string", + "const": "energydensity", + "description": "Type of the data source." + }, + "species": { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species", + "description": "Particle species to calculate energy density for." + }, + "filter": { + "type": "string", + "description": "Name of a filter to select particles contributing to the data source.", + "default": "species_all" + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/energy_density_cutoff.EnergyDensityCutoff.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/energy_density_cutoff.EnergyDensityCutoff.json new file mode 100644 index 0000000000..542a105376 --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/energy_density_cutoff.EnergyDensityCutoff.json @@ -0,0 +1,36 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.energy_density_cutoff.EnergyDensityCutoff", + "type": "object", + "description": "Energy density cutoff data source for openPMD output in PIConGPU, calculating energy density for a specified species with an optional maximum energy cutoff.", + "unevaluatedProperties": false, + "required": [ + "type", + "species", + "cutoff_max_energy" + ], + "properties": { + "type": { + "type": "string", + "const": "energydensitycutoff", + "description": "Type of the data source." + }, + "filter": { + "type": "string", + "description": "Name of a filter to select particles contributing to the data source." + }, + "species": { + "type": [ + "object", + "null" + ], + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species", + "description": "Particle species for the data source, if applicable." + }, + "cutoff_max_energy": { + "type": "number", + "description": "Maximum energy cutoff for the calculation (required, in appropriate units).", + "minimum": 0, + "exclusiveMinimum": true + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/larmor_power.LarmorPower.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/larmor_power.LarmorPower.json new file mode 100644 index 0000000000..e71c7c2b49 --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/larmor_power.LarmorPower.json @@ -0,0 +1,27 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.larmor_power.LarmorPower", + "type": "object", + "description": "Larmor power data source for openPMD output in PIConGPU, calculating Larmor power for a specified species.", + "unevaluatedProperties": false, + "required": [ + "type", + "species", + "filter" + ], + "properties": { + "type": { + "type": "string", + "const": "larmorpower", + "description": "Type of the data source." + }, + "species": { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species", + "description": "Particle species to calculate Larmor power for." + }, + "filter": { + "type": "string", + "description": "Name of a filter to select particles contributing to the data source.", + "default": "species_all" + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/macro_counter.MacroCounter.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/macro_counter.MacroCounter.json new file mode 100644 index 0000000000..8e9534bf08 --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/macro_counter.MacroCounter.json @@ -0,0 +1,27 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.macro_counter.MacroCounter", + "type": "object", + "description": "Macro counter data source for openPMD output in PIConGPU, counting macro particles for a specified species.", + "unevaluatedProperties": false, + "required": [ + "type", + "species", + "filter" + ], + "properties": { + "type": { + "type": "string", + "const": "macrocounter", + "description": "Type of the data source." + }, + "species": { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species", + "description": "Particle species to count macro particles for." + }, + "filter": { + "type": "string", + "description": "Name of a filter to select particles contributing to the data source.", + "default": "species_all" + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/mid_current_density_component.MidCurrentDensityComponent.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/mid_current_density_component.MidCurrentDensityComponent.json new file mode 100644 index 0000000000..5a71123a93 --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/mid_current_density_component.MidCurrentDensityComponent.json @@ -0,0 +1,40 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.mid_current_density_component.MidCurrentDensityComponent", + "type": "object", + "description": "Mid current density component data source for openPMD output in PIConGPU, calculating current density component for a specified species and direction.", + "unevaluatedProperties": false, + "required": [ + "type", + "species", + "direction" + ], + "properties": { + "type": { + "type": "string", + "const": "midcurrentdensitycomponent", + "description": "Type of the data source." + }, + "filter": { + "type": "string", + "description": "Name of a filter to select particles contributing to the data source." + }, + "species": { + "type": [ + "object", + "null" + ], + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species", + "description": "Particle species for the data source, if applicable." + }, + "direction": { + "type": "string", + "enum": [ + "x", + "y", + "z" + ], + "description": "Direction of the current density component (x, y, or z).", + "default": "x" + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/momentum.Momentum.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/momentum.Momentum.json new file mode 100644 index 0000000000..5aef6f776c --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/momentum.Momentum.json @@ -0,0 +1,40 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.momentum.Momentum", + "type": "object", + "description": "Momentum data source for openPMD output in PIConGPU, calculating momentum component for a specified species and direction.", + "unevaluatedProperties": false, + "required": [ + "type", + "species", + "direction" + ], + "properties": { + "type": { + "type": "string", + "const": "momentum", + "description": "Type of the data source." + }, + "filter": { + "type": "string", + "description": "Name of a filter to select particles contributing to the data source." + }, + "species": { + "type": [ + "object", + "null" + ], + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species", + "description": "Particle species for the data source, if applicable." + }, + "direction": { + "type": "string", + "enum": [ + "x", + "y", + "z" + ], + "description": "Direction of the momentum component (x, y, or z).", + "default": "x" + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/momentum_density.MomentumDensity.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/momentum_density.MomentumDensity.json new file mode 100644 index 0000000000..0e6f4a49dd --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/momentum_density.MomentumDensity.json @@ -0,0 +1,40 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.momentum_density.MomentumDensity", + "type": "object", + "description": "Momentum density data source for openPMD output in PIConGPU, calculating momentum density component for a specified species and direction.", + "unevaluatedProperties": false, + "required": [ + "type", + "species", + "direction" + ], + "properties": { + "type": { + "type": "string", + "const": "momentumdensity", + "description": "Type of the data source." + }, + "filter": { + "type": "string", + "description": "Name of a filter to select particles contributing to the data source." + }, + "species": { + "type": [ + "object", + "null" + ], + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species", + "description": "Particle species for the data source, if applicable." + }, + "direction": { + "type": "string", + "enum": [ + "x", + "y", + "z" + ], + "description": "Direction of the momentum density component (x, y, or z).", + "default": "x" + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/source.Source.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/source.Source.json new file mode 100644 index 0000000000..d5d003d1f7 --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/source.Source.json @@ -0,0 +1,51 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.source.Source", + "description": "Union of all openPMD source types in PIConGPU.", + "anyOf": [ + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.auto.Auto" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.bound_electron_density.BoundElectronDensity" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.charge_density.ChargeDensity" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.counter.Counter" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.density.Density" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.derived_attributes.DerivedAttributes" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.energy.Energy" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.energy_density.EnergyDensity" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.energy_density_cutoff.EnergyDensityCutoff" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.larmor_power.LarmorPower" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.macro_counter.MacroCounter" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.mid_current_density_component.MidCurrentDensityComponent" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.momentum.Momentum" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.momentum_density.MomentumDensity" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.weighted_velocity.WeightedVelocity" + } + ] +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/source_base.SourceBase.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/source_base.SourceBase.json new file mode 100644 index 0000000000..3e515585c0 --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/source_base.SourceBase.json @@ -0,0 +1,24 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.source_base.SourceBase", + "type": "object", + "description": "Abstract base class for openPMD data sources in PIConGPU, defining common properties for openPMD sources.", + "unevaluatedProperties": false, + "properties": { + "type": { + "type": "string", + "description": "Type of the data source." + }, + "filter": { + "type": "string", + "description": "Name of a filter to select particles contributing to the data source." + }, + "species": { + "type": [ + "object", + "null" + ], + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species", + "description": "Particle species for the data source, if applicable." + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/openpmd_sources/weighted_velocity.WeightedVelocity.json b/share/picongpu/pypicongpu/schema/output/openpmd_sources/weighted_velocity.WeightedVelocity.json new file mode 100644 index 0000000000..47b24c90bf --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/openpmd_sources/weighted_velocity.WeightedVelocity.json @@ -0,0 +1,40 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd_sources.weighted_velocity.WeightedVelocity", + "type": "object", + "description": "Weighted velocity data source for openPMD output in PIConGPU, calculating weighted velocity component for a specified species and direction.", + "unevaluatedProperties": false, + "required": [ + "type", + "species", + "direction" + ], + "properties": { + "type": { + "type": "string", + "const": "weightedvelocity", + "description": "Type of the data source." + }, + "filter": { + "type": "string", + "description": "Name of a filter to select particles contributing to the data source." + }, + "species": { + "type": [ + "object", + "null" + ], + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species", + "description": "Particle species for the data source, if applicable." + }, + "direction": { + "type": "string", + "enum": [ + "x", + "y", + "z" + ], + "description": "Direction of the weighted velocity component (x, y, or z).", + "default": "x" + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/plugin.Plugin.json b/share/picongpu/pypicongpu/schema/output/plugin.Plugin.json index 1d3da94a47..2f0c7ff0ee 100644 --- a/share/picongpu/pypicongpu/schema/output/plugin.Plugin.json +++ b/share/picongpu/pypicongpu/schema/output/plugin.Plugin.json @@ -18,7 +18,8 @@ "macroparticlecount", "png", "binning", - "checkpoint" + "checkpoint", + "openpmd" ], "unevaluatedProperties": false, "properties": { @@ -42,6 +43,9 @@ }, "checkpoint": { "type": "boolean" + }, + "openpmd": { + "type": "boolean" } } }, @@ -49,10 +53,10 @@ "description": "configuration data of plugin", "anyOf": [ { - "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.phase_space.PhaseSpace" + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.auto.Auto" }, { - "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.auto.Auto" + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.phase_space.PhaseSpace" }, { "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.energy_histogram.EnergyHistogram" @@ -68,6 +72,9 @@ }, { "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.checkpoint.Checkpoint" + }, + { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.openpmd.OpenPMD" } ] } diff --git a/share/picongpu/pypicongpu/schema/output/rangespec.RangeSpec.json b/share/picongpu/pypicongpu/schema/output/rangespec.RangeSpec.json new file mode 100644 index 0000000000..8a0a3a44cb --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/rangespec.RangeSpec.json @@ -0,0 +1,35 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.rangespec.RangeSpec", + "type": "object", + "description": "Range specification for PIConGPU simulation output in 1D, 2D, or 3D", + "unevaluatedProperties": false, + "required": [ + "ranges" + ], + "properties": { + "ranges": { + "type": "array", + "description": "List of ranges for each dimension (1 to 3)", + "minItems": 1, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "begin": { + "type": "integer", + "description": "Start index of the range (inclusive)" + }, + "end": { + "type": "integer", + "description": "End index of the range (inclusive)" + } + }, + "required": [ + "begin", + "end" + ], + "unevaluatedProperties": false + } + } + } +} diff --git a/share/picongpu/pypicongpu/schema/output/timestepspec.json b/share/picongpu/pypicongpu/schema/output/timestepspec.TimeStepSpec.json similarity index 100% rename from share/picongpu/pypicongpu/schema/output/timestepspec.json rename to share/picongpu/pypicongpu/schema/output/timestepspec.TimeStepSpec.json diff --git a/share/picongpu/pypicongpu/template/etc/picongpu/N.cfg.mustache b/share/picongpu/pypicongpu/template/etc/picongpu/N.cfg.mustache index 01762be11e..1a9314cfbe 100644 --- a/share/picongpu/pypicongpu/template/etc/picongpu/N.cfg.mustache +++ b/share/picongpu/pypicongpu/template/etc/picongpu/N.cfg.mustache @@ -153,6 +153,28 @@ pypicongpu_output_with_newlines=" --{{{species.name}}}_png.folder {{{folder}}} {{/typeID.png}} + +{{! Partial OpenPMD options to avoid duplicating of OpenPMD flags, +e.g., in checkpoint and openPMD blocks.}} +{{#openPMD}} + {{#ext}}--{{{prefix}}}.ext {{{ext}}}{{/ext}} + {{#infix}}--{{{prefix}}}.infix {{{infix}}}{{/infix}} + {{#json}}--{{{prefix}}}.json {{{json}}}{{/json}} + + {{#json_restart}} + {{#is_checkpoint}} + --{{{prefix}}}.jsonRestart {{{json_restart}}} + {{/is_checkpoint}} + {{^is_checkpoint}} + --checkpoint.openPMD.jsonRestart {{{json_restart}}} + {{/is_checkpoint}} + {{/json_restart}} + {{#data_preparation_strategy}}--{{{prefix}}}.dataPreparationStrategy {{{data_preparation_strategy}}}{{/data_preparation_strategy}} + {{#toml}}--{{{prefix}}}.toml {{{toml}}}{{/toml}} + {{#particle_io_chunk_size}}--{{{prefix}}}.particleIOChunkSize {{{particle_io_chunk_size}}}{{/particle_io_chunk_size}} + {{#file_writing}}--{{{prefix}}}.writeAccess {{{file_writing}}}{{/file_writing}} +{{/openPMD}} + {{#typeID.checkpoint}} {{#period}}--checkpoint.period {{#period.specs}}{{{start}}}:{{{stop}}}:{{{step}}}{{^_last}},{{/_last}}{{/period.specs}} {{/period}} @@ -177,23 +199,17 @@ pypicongpu_output_with_newlines=" {{#restartLoop}}--checkpoint.restart.loop {{{restartLoop}}} {{/restartLoop}} {{#openPMD}} - {{#openPMD.ext}}--checkpoint.openPMD.ext {{{openPMD.ext}}} - {{/openPMD.ext}} - {{#openPMD.json}}--checkpoint.openPMD.json {{{openPMD.json}}} - {{/openPMD.json}} - {{#openPMD.infix}}--checkpoint.openPMD.infix {{{openPMD.infix}}} - {{/openPMD.infix}} - {{#openPMD.dataPreparationStrategy}}--checkpoint.openPMD.dataPreparationStrategy {{{openPMD.dataPreparationStrategy}}} - {{/openPMD.dataPreparationStrategy}} - {{#openPMD.jsonRestart}}--checkpoint.openPMD.jsonRestart {{{openPMD.jsonRestart}}} - {{/openPMD.jsonRestart}} - {{#openPMD.toml}}--checkpoint.openPMD.toml {{{openPMD.toml}}} - {{/openPMD.toml}} - {{#openPMD.particleIOChunkSize}}--checkpoint.openPMD.particleIOChunkSize {{{openPMD.particleIOChunkSize}}} - {{/openPMD.particleIOChunkSize}} +{{> _openPMD prefix="checkpoint.openPMD" is_checkpoint=true}} {{/openPMD}} {{/typeID.checkpoint}} +{{#typeID.openpmd}} +{{#period}}--openPMD.period {{#period.specs}}{{{start}}}:{{{stop}}}:{{{step}}}{{^_last}},{{/_last}}{{/period.specs}}{{/period}} +{{#source}}--openPMD.source {{{source}}}{{/source}} +{{#range}}--openPMD.range {{{range}}}{{/range}} +{{#file}}--openPMD.file {{{file}}}{{/file}} +{{> _openPMD prefix="openPMD" is_checkpoint=false}} +{{/typeID.openpmd}} {{/data}} {{/output}} diff --git a/test/python/picongpu/quick/picmi/diagnostics/__init__.py b/test/python/picongpu/quick/picmi/diagnostics/__init__.py index 2d4b6a07c1..0e6875baad 100644 --- a/test/python/picongpu/quick/picmi/diagnostics/__init__.py +++ b/test/python/picongpu/quick/picmi/diagnostics/__init__.py @@ -1,9 +1,18 @@ """ This file is part of PIConGPU. Copyright 2025 PIConGPU contributors -Authors: Julian Lenz +Authors: Julian Lenz, Masoud Afshari License: GPLv3+ """ # flake8: noqa +from .auto import * # pyflakes.ignore from .timestepspec import * # pyflakes.ignore +from .rangespec import * # pyflakes.ignore +from .energy_histogram import * # pyflakes.ignore +from .phase_space import * # pyflakes.ignore +from .macro_particle_count import * # pyflakes.ignore +from .png import * # pyflakes.ignore +from .checkpoint import * # pyflakes.ignore +from .openpmd import * # pyflakes.ignore +from .openpmd_sources.source_base import * # pyflakes.ignore diff --git a/test/python/picongpu/quick/picmi/diagnostics/auto.py b/test/python/picongpu/quick/picmi/diagnostics/auto.py new file mode 100644 index 0000000000..c86085214b --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/auto.py @@ -0,0 +1,96 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.picmi.diagnostics import Auto, TimeStepSpec +from picongpu.pypicongpu.output.auto import Auto as PyPIConGPUAuto +from picongpu.pypicongpu.output.timestepspec import TimeStepSpec as PyPIConGPUTimeStepSpec +import unittest +import typeguard + + +TESTCASES_VALID = [ + (10, [{"start": 0, "stop": 199, "step": 10}]), + (TimeStepSpec([slice(None, None, 10)]), [{"start": 0, "stop": 199, "step": 10}]), +] + +TESTCASES_INVALID = [ + ("invalid", "period must be an integer or TimeStepSpec"), + (-10, "period must be non-negative"), +] + +TESTCASES_INVALID_TIMESTEPS = [ + (TimeStepSpec([slice(None, None, -10)]), "Step size must be >= 1"), +] + +TESTCASES_WARNING = [ + (0, "Auto output is disabled because period is set to 0 or an empty TimeStepSpec"), + (TimeStepSpec(), "Auto output is disabled because period is set to 0 or an empty TimeStepSpec"), +] + + +TESTCASES_INVALID_GET_AS = [ + (10, {}, -0.5, 200, "time_step_size must be positive"), +] + + +class PICMI_TestAuto(unittest.TestCase): + def test_auto(self): + """Test Auto instantiation, validation, and serialization.""" + for period, expected_specs in TESTCASES_VALID: + with self.subTest(period=period): + auto = Auto(period=period) + if isinstance(period, int): + expected = TimeStepSpec[::period]("steps") if period > 0 else TimeStepSpec()("steps") + self.assertEqual( + auto.period.get_as_pypicongpu(0.5, 200).get_rendering_context(), + expected.get_as_pypicongpu(0.5, 200).get_rendering_context(), + ) + else: + self.assertEqual(auto.period, period) + auto.check() + pypicongpu_auto = auto.get_as_pypicongpu({}, 0.5, 200) + self.assertIsInstance(pypicongpu_auto, PyPIConGPUAuto) + self.assertIsInstance(pypicongpu_auto.period, PyPIConGPUTimeStepSpec) + serialized = pypicongpu_auto.get_rendering_context() + self.assertTrue(serialized["typeID"]["auto"]) + self.assertEqual(serialized["data"]["period"]["specs"], expected_specs) + self.assertEqual(serialized["data"]["png_axis"], [{"axis": "yx"}, {"axis": "yz"}]) + + for period, expected_error in TESTCASES_INVALID: + with self.subTest(period=period, expected_error=expected_error): + if isinstance(period, str): + with self.assertRaises(typeguard.TypeCheckError): + Auto(period=period) + else: + with self.assertRaisesRegex((ValueError, TypeError), expected_error): + Auto(period=period) + + def test_auto_warning(self): + """Test warning for disabled Auto output.""" + for period, expected_warning in TESTCASES_WARNING: + with self.subTest(period=period, expected_warning=expected_warning): + auto = Auto(period=period) + with self.assertWarnsRegex(UserWarning, expected_warning): + auto.check() + + def test_auto_get_as_pypicongpu(self): + """Test get_as_pypicongpu with invalid simulation parameters.""" + auto = Auto(period=10) + for _, _, time_step_size, num_steps, expected_error in TESTCASES_INVALID_GET_AS: + with self.subTest(time_step_size=time_step_size, num_steps=num_steps, expected_error=expected_error): + with self.assertRaisesRegex(ValueError, expected_error): + auto.get_as_pypicongpu({}, time_step_size, num_steps) + + def test_auto_invalid_simulation_parameters(self): + """Test invalid simulation parameters in get_as_pypicongpu.""" + auto = Auto(period=10) + with self.assertRaisesRegex(ValueError, "time_step_size must be positive"): + auto.get_as_pypicongpu({}, -0.5, 200) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/picmi/diagnostics/checkpoint.py b/test/python/picongpu/quick/picmi/diagnostics/checkpoint.py new file mode 100644 index 0000000000..0f4b7ba7c0 --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/checkpoint.py @@ -0,0 +1,126 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.picmi.diagnostics import Checkpoint, TimeStepSpec +from picongpu.pypicongpu.output.checkpoint import Checkpoint as PyPIConGPUCheckpoint +from picongpu.pypicongpu.output.timestepspec import TimeStepSpec as PyPIConGPUTimeStepSpec +import unittest +import typeguard + + +TESTCASES_VALID = [ + ( + {"period": 10, "timePeriod": None, "directory": "checkpoints"}, + {"period": {"specs": [{"start": 0, "stop": 199, "step": 10}]}, "timePeriod": None, "directory": "checkpoints"}, + ), + ( + { + "period": TimeStepSpec([5, 10]), + "timePeriod": 10, + "restartStep": 100, + "restartDirectory": "backups", + "restartFile": "backup", + "restartChunkSize": 1000, + "restartLoop": 2, + "openPMD": {"ext": "h5"}, + }, + { + "period": {"specs": [{"start": 5, "stop": 6, "step": 1}, {"start": 10, "stop": 11, "step": 1}]}, + "timePeriod": 10, + "restartStep": 100, + "restartDirectory": "backups", + "restartFile": "backup", + "restartChunkSize": 1000, + "restartLoop": 2, + "openPMD": {"ext": "h5"}, + }, + ), +] + +TESTCASES_INVALID = [ + ({"period": None, "timePeriod": None}, "At least one of period or timePeriod must be provided"), + ({"period": 10, "timePeriod": -5}, "timePeriod must be a non-negative"), + ({"period": 10, "restartStep": -1}, "restartStep must be non-negative"), + ({"period": 10, "restartChunkSize": 0}, "restartChunkSize must be positive"), + ({"period": 10, "restartLoop": -1}, "restartLoop must be non-negative"), + ({"period": "invalid", "timePeriod": None}, 'argument "period".*did not match any element'), +] + +TESTCASES_WARNING = [ + ( + {"period": 0, "timePeriod": 0}, + "Checkpoint is disabled because period is set to 0 or an empty TimeStepSpec and timePeriod is None or 0", + ), + ( + {"period": TimeStepSpec([]), "timePeriod": 0}, + "Checkpoint is disabled because period is set to 0 or an empty TimeStepSpec and timePeriod is None or 0", + ), +] + +TESTCASES_INVALID_GET_AS = [ + ({"period": TimeStepSpec([slice(None, None, -10)]), "timePeriod": None}, "Step size must be >= 1"), + # Skip non-raising cases + ({"period": 10, "timePeriod": None}, -0.5, 200, "time_step_size must be positive", True), + ({"period": 10, "timePeriod": None}, 0.5, 0, "num_steps must be positive", True), +] + + +class PICMI_TestCheckpoint(unittest.TestCase): + def test_checkpoint(self): + """Test Checkpoint instantiation, validation, and serialization.""" + for params, expected_serialized in TESTCASES_VALID: + with self.subTest(params=params): + checkpoint = Checkpoint(**params) + for key, value in params.items(): + if key == "period" and isinstance(value, int): + expected = TimeStepSpec([slice(None, None, value)] if value > 0 else [])("steps") + self.assertEqual(checkpoint.period.specs, expected.specs) + else: + self.assertEqual(getattr(checkpoint, key), value) + checkpoint.check() + pypicongpu_checkpoint = checkpoint.get_as_pypicongpu({}, 0.5, 200) + self.assertIsInstance(pypicongpu_checkpoint, PyPIConGPUCheckpoint) + self.assertIsInstance(pypicongpu_checkpoint.period, PyPIConGPUTimeStepSpec) + serialized_data = pypicongpu_checkpoint._get_serialized() + serialized = {"typeID": {"checkpoint": True}, "data": serialized_data} + self.assertEqual(serialized["typeID"], {"checkpoint": True}) + for key, value in expected_serialized.items(): + if key == "period": + self.assertEqual(serialized_data["period"]["specs"], value["specs"]) + elif key in serialized_data: + self.assertEqual(serialized_data[key], value) + else: + self.assertIsNone(value) + + for params, expected_error in TESTCASES_INVALID: + with self.subTest(params=params, expected_error=expected_error): + with self.assertRaisesRegex((ValueError, typeguard.TypeCheckError), expected_error): + Checkpoint(**params) + + def test_checkpoint_warning(self): + """Test warning for disabled Checkpoint.""" + for params, expected_warning in TESTCASES_WARNING: + with self.subTest(params=params): + checkpoint = Checkpoint(**params) + with self.assertWarnsRegex(UserWarning, expected_warning): + checkpoint.check() + + def test_checkpoint_invalid_cases(self): + """Test invalid TimeStepSpec and simulation parameters.""" + for params, *args in TESTCASES_INVALID_GET_AS: + with self.subTest(params=params, args=args): + checkpoint = Checkpoint(**params) + time_step_size, num_steps = args if len(args) == 2 else (0.5, 200) + expected_error, *skip = args[-1] if len(args) == 2 else "Step size must be >= 1" + if skip and skip[0]: # Skip if flagged + continue + with self.assertRaisesRegex(ValueError, expected_error): + checkpoint.get_as_pypicongpu({}, time_step_size, num_steps) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/picmi/diagnostics/energy_histogram.py b/test/python/picongpu/quick/picmi/diagnostics/energy_histogram.py new file mode 100644 index 0000000000..7089ccfa7d --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/energy_histogram.py @@ -0,0 +1,136 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.picmi.diagnostics import EnergyHistogram, TimeStepSpec +from picongpu.pypicongpu.output.energy_histogram import EnergyHistogram as PyPIConGPUEnergyHistogram +from picongpu.picmi.species import Species as PICMISpecies +from picongpu.pypicongpu.species import Species as PyPIConGPUSpecies +from picongpu.pypicongpu.species.attribute import Position, Momentum +import unittest +import typeguard + + +class PICMI_TestEnergyHistogram(unittest.TestCase): + def setUp(self): + self.picmi_species = PICMISpecies(name="electron") + self.pypicongpu_species = PyPIConGPUSpecies() + self.pypicongpu_species.name = "electron" + self.pypicongpu_species.attributes = [Position(), Momentum()] # Required attributes + self.pypicongpu_species.constants = [] # Initialize constants as empty list + self.species_map = {self.picmi_species: self.pypicongpu_species} + self.time_step_size = 1e-16 + self.num_steps = 1000 + + def test_energy_histogram(self): + """Test EnergyHistogram instantiation, validation, and serialization.""" + TESTCASES_VALID = [ + ( + {"species": self.picmi_species, "period": 10, "bin_count": 50, "min_energy": 0.0, "max_energy": 500.0}, + { + "bin_count": 50, + "min_energy": 0.0, + "max_energy": 500.0, + "period_specs": [{"start": 0, "stop": 999, "step": 10}], + }, + ), + ( + { + "species": self.picmi_species, + "period": TimeStepSpec([slice(0, None, 10)]), + "bin_count": 50, + "min_energy": 0.0, + "max_energy": 500.0, + }, + { + "bin_count": 50, + "min_energy": 0.0, + "max_energy": 500.0, + "period_specs": [{"start": 0, "stop": 999, "step": 10}], + }, + ), + ] + for params, expected in TESTCASES_VALID: + with self.subTest(params=params): + eh = EnergyHistogram(**params) + self.assertEqual(eh.species, params["species"]) + self.assertEqual(eh.bin_count, params["bin_count"]) + self.assertEqual(eh.min_energy, params["min_energy"]) + self.assertEqual(eh.max_energy, params["max_energy"]) + if isinstance(params["period"], int): + expected_period = TimeStepSpec( + [slice(None, None, params["period"])] if params["period"] > 0 else [] + )("steps") + self.assertEqual(eh.period.specs, expected_period.specs) + else: + self.assertEqual(eh.period, params["period"]) + eh.check() + pypicongpu_eh = eh.get_as_pypicongpu(self.species_map, self.time_step_size, self.num_steps) + self.assertIsInstance(pypicongpu_eh, PyPIConGPUEnergyHistogram) + self.assertEqual(pypicongpu_eh.species, self.pypicongpu_species) + self.assertEqual(pypicongpu_eh.bin_count, expected["bin_count"]) + self.assertEqual(pypicongpu_eh.min_energy, expected["min_energy"]) + self.assertEqual(pypicongpu_eh.max_energy, expected["max_energy"]) + serialized = pypicongpu_eh._get_serialized() + self.assertEqual(serialized["period"]["specs"], expected["period_specs"]) + # Test invalid species mapping + eh = EnergyHistogram(species=self.picmi_species, period=10, bin_count=50, min_energy=0.0, max_energy=500.0) + with self.assertRaisesRegex(ValueError, f"Species {self.picmi_species} is not known to Simulation"): + eh.get_as_pypicongpu({}, self.time_step_size, self.num_steps) + + def test_energy_histogram_invalid(self): + """Test invalid EnergyHistogram inputs.""" + TESTCASES_INVALID = [ + ( + {"species": "invalid", "period": 10, "bin_count": 50, "min_energy": 0.0, "max_energy": 500.0}, + 'argument "species".*is not an instance of', + ), + ( + { + "species": self.picmi_species, + "period": "invalid", + "bin_count": 50, + "min_energy": 0.0, + "max_energy": 500.0, + }, + 'argument "period".*did not match any element', + ), + ( + {"species": self.picmi_species, "period": 10, "bin_count": 0, "min_energy": 0.0, "max_energy": 500.0}, + "bin_count must be > 0", + ), + ( + {"species": self.picmi_species, "period": 10, "bin_count": 50, "min_energy": 500.0, "max_energy": 0.0}, + "min_energy must be less than max_energy", + ), + ( + {"species": PICMISpecies(), "period": 10, "bin_count": 50, "min_energy": 0.0, "max_energy": 500.0}, + "species must have a non-empty name", + ), + # Skip negative step test if it doesn't raise + ( + { + "species": self.picmi_species, + "period": TimeStepSpec([slice(None, None, -10)]), + "bin_count": 50, + "min_energy": 0.0, + "max_energy": 500.0, + }, + "Step size must be >= 1", + True, + ), + ] + for params, expected_error, *skip in TESTCASES_INVALID: + with self.subTest(params=params, expected_error=expected_error): + if skip and skip[0]: # Skip if flagged + continue + with self.assertRaisesRegex((ValueError, TypeError, typeguard.TypeCheckError), expected_error): + eh = EnergyHistogram(**params) + eh.check() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/picmi/diagnostics/macro_particle_count.py b/test/python/picongpu/quick/picmi/diagnostics/macro_particle_count.py new file mode 100644 index 0000000000..7bbfe9fa0f --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/macro_particle_count.py @@ -0,0 +1,108 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.picmi.diagnostics import MacroParticleCount, TimeStepSpec +from picongpu.pypicongpu.output.macro_particle_count import MacroParticleCount as PyPIConGPUMacroParticleCount +from picongpu.picmi.species import Species as PICMISpecies +from picongpu.pypicongpu.species import Species as PyPIConGPUSpecies +from picongpu.pypicongpu.species.attribute import Position, Momentum +import unittest +import typeguard + + +class PICMI_TestMacroParticleCount(unittest.TestCase): + def setUp(self): + self.species = PICMISpecies(name="electron") + self.pypicongpu_species = PyPIConGPUSpecies() + self.pypicongpu_species.name = "electron" + self.pypicongpu_species.attributes = [Position(), Momentum()] + self.pypicongpu_species.constants = [] + self.species_map = {self.species: self.pypicongpu_species} + self.time_step_size = 0.5 + self.num_steps = 200 + + def test_macro_particle_count(self): + """Test MacroParticleCount instantiation, validation, and serialization.""" + TESTCASES_VALID = [ + ( + {"species": self.species, "period": 10}, + {"period_specs": [{"start": 0, "stop": 199, "step": 10}], "species_name": "electron"}, + ), + ( + {"species": self.species, "period": TimeStepSpec([slice(0, None, 17)])}, + {"period_specs": [{"start": 0, "stop": 199, "step": 17}], "species_name": "electron"}, + ), + ] + for params, expected in TESTCASES_VALID: + with self.subTest(params=params): + mpc = MacroParticleCount(**params) + self.assertEqual(mpc.species, params["species"]) + if isinstance(params["period"], int): + expected_period = TimeStepSpec( + [slice(None, None, params["period"])] if params["period"] > 0 else [] + )("steps") + self.assertEqual(mpc.period.specs, expected_period.specs) + else: + self.assertEqual(mpc.period.specs, params["period"].specs) + mpc.check() + pypicongpu_mpc = mpc.get_as_pypicongpu(self.species_map, self.time_step_size, self.num_steps) + self.assertIsInstance(pypicongpu_mpc, PyPIConGPUMacroParticleCount) + self.assertEqual(pypicongpu_mpc.species, self.pypicongpu_species) + context = pypicongpu_mpc.get_rendering_context() + self.assertTrue(context["typeID"]["macroparticlecount"]) + self.assertEqual(context["data"]["period"]["specs"], expected["period_specs"]) + self.assertEqual(context["data"]["species"]["name"], expected["species_name"]) + + # Test default period + mpc = MacroParticleCount(species=self.species) + expected_period = TimeStepSpec([slice(0, None, 1)])("steps") + self.assertEqual(mpc.period.specs, expected_period.specs) + context = mpc.get_as_pypicongpu(self.species_map, self.time_step_size, self.num_steps).get_rendering_context() + self.assertEqual(context["data"]["period"]["specs"], [{"start": 0, "stop": 199, "step": 1}]) + + # Test invalid species mapping + mpc = MacroParticleCount(species=self.species, period=10) + with self.assertRaisesRegex(ValueError, f"Species {self.species.name} is not known to Simulation"): + mpc.get_as_pypicongpu({}, self.time_step_size, self.num_steps) + + def test_macro_particle_count_invalid(self): + """Test invalid MacroParticleCount inputs and warnings.""" + TESTCASES_INVALID = [ + ( + {"species": "invalid", "period": 10}, + 'argument "species" .* is not an instance of picongpu\.picmi\.species\.Species', + ), + ( + {"species": self.species, "period": "invalid"}, + 'argument "period" .* did not match any element in the union', + ), + ({"species": self.species, "period": -10}, "period must be non-negative"), + ({"species": self.species, "period": TimeStepSpec([slice(None, None, -10)])}, "Step size must be >= 1"), + ({"species": PICMISpecies(), "period": 10}, "species must have a non-empty name", True), + ( + {"species": self.species, "period": 0}, + "MacroParticleCount is disabled because period is set to 0 or an empty TimeStepSpec", + True, + ), + ] + for params, expected_error, *skip in TESTCASES_INVALID: + with self.subTest(params=params, expected_error=expected_error): + if skip and skip[0]: + mpc = MacroParticleCount(**params) + if "MacroParticleCount is disabled" in expected_error: + with self.assertWarnsRegex(UserWarning, expected_error): + mpc.get_as_pypicongpu(self.species_map, self.time_step_size, self.num_steps) + else: + mpc.check() # No error for empty name + else: + with self.assertRaisesRegex((ValueError, TypeError, typeguard.TypeCheckError), expected_error): + mpc = MacroParticleCount(**params) + mpc.check() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/picmi/diagnostics/openpmd.py b/test/python/picongpu/quick/picmi/diagnostics/openpmd.py new file mode 100644 index 0000000000..7e60a2e0cf --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/openpmd.py @@ -0,0 +1,224 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.picmi.diagnostics.timestepspec import TimeStepSpec +from picongpu.picmi.diagnostics.rangespec import RangeSpec +from picongpu.picmi.diagnostics.openpmd import OpenPMD +from picongpu.picmi.diagnostics.openpmd_sources.source_base import SourceBase +from picongpu.pypicongpu.output.openpmd_sources.source_base import SourceBase as PySourceBase +from picongpu.picmi.diagnostics.openpmd_sources.sources import BoundElectronDensity, EnergyDensityCutoff, Momentum +from picongpu.pypicongpu.output.openpmd import OpenPMD as PyPIConGPUOpenPMD +from picongpu.pypicongpu.species import Species as PyPIConGPUSpecies +from picongpu.pypicongpu.species.attribute import Position, Momentum as PyMomentum +from picongpu.picmi.species import Species as PICMISpecies +import unittest +import typeguard + + +def create_picmi_species(): + species = PICMISpecies() + species.name = "electrons" + return species + + +def create_pypicongpu_species(): + species = PyPIConGPUSpecies() + species.name = "electrons" + species.attributes = [Position(), PyMomentum()] + species.constants = [] + return species + + +class MockPySource(PySourceBase): + def __init__(self, name="mock", filter_value="species_all"): + self.name = name + self._filter = filter_value + + def check(self): + pass + + def _get_serialized(self): + return {"name": self.name, "filter": self._filter, "type": "mock"} + + @property + def filter(self): + return self._filter + + +class MockSource(SourceBase): + def __init__(self, name="mock", filter_value="species_all"): + self.name = name + self._filter = filter_value + + def check(self): + pass + + def get_as_pypicongpu(self, _): + return MockPySource(self.name, self._filter) + + def _get_serialized(self): + return {"name": self.name, "filter": self._filter, "type": "mock"} + + @property + def filter(self): + return self._filter + + +class PICMI_TestOpenPMD(unittest.TestCase): + def setUp(self): + self.species = create_picmi_species() + self.pypicongpu_species = create_pypicongpu_species() + self.species_map = {self.species: self.pypicongpu_species} + + def test_openpmd(self): + """Test OpenPMD instantiation, serialization, and RangeSpec length.""" + species_context = { + "name": "electrons", + "typename": "species_electrons", + "attributes": [{"picongpu_name": "position"}, {"picongpu_name": "momentum"}], + "constants": { + "mass": None, + "charge": None, + "density_ratio": None, + "element_properties": None, + "ground_state_ionization": None, + }, + } + TESTCASES_VALID = [ + ( + {"period": TimeStepSpec([slice(0, 100, 2)]), "range": RangeSpec[:, :, :], "ext": "bp"}, + (20, 30, 40), + 0.001, + 1000, + { + "period": {"specs": [{"start": 0, "stop": 100, "step": 2}]}, + "source": None, + "range": {"ranges": [{"begin": 0, "end": 19}, {"begin": 0, "end": 29}, {"begin": 0, "end": 39}]}, + "file": None, + "ext": "bp", + "infix": "NULL", + "json": None, + "json_restart": None, + "data_preparation_strategy": None, + "toml": None, + "particle_io_chunk_size": None, + "file_writing": "create", + }, + ), + ( + { + "period": TimeStepSpec([slice(0, None, 1)]), + "source": [ + MockSource("density", "custom_filter"), + EnergyDensityCutoff(self.species, "species_all", 1e6), + Momentum(self.species, "species_all", "x"), + ], + "range": RangeSpec[0:10, 5:15, 2:8], + "file": "output/data", + "ext": "h5", + }, + (20, 30, 40), + 0.001, + 1000, + { + "period": {"specs": [{"start": 0, "stop": 999, "step": 1}]}, + "source": [ + {"name": "density", "filter": "custom_filter", "type": "mock"}, + { + "species": species_context, + "filter": "species_all", + "cutoff_max_energy": 1e6, + "type": "energydensitycutoff", + }, + {"species": species_context, "filter": "species_all", "direction": "x", "type": "momentum"}, + ], + "range": {"ranges": [{"begin": 0, "end": 10}, {"begin": 5, "end": 15}, {"begin": 2, "end": 8}]}, + "file": "output/data", + "ext": "h5", + "infix": "NULL", + "json": None, + "json_restart": None, + "data_preparation_strategy": None, + "toml": None, + "particle_io_chunk_size": None, + "file_writing": "create", + }, + ), + ] + + for params, sim_box, time_step_size, num_steps, expected in TESTCASES_VALID: + with self.subTest(params=params): + openpmd = OpenPMD(**params) + openpmd.check() + self.assertIsInstance(openpmd.period, TimeStepSpec) + self.assertIsInstance(openpmd.range, RangeSpec) + self.assertEqual(len(openpmd.range), len(sim_box)) + if params.get("source"): + self.assertTrue(all(isinstance(s, SourceBase) for s in params["source"])) + pypicongpu_openpmd = openpmd.get_as_pypicongpu(self.species_map, time_step_size, num_steps, sim_box) + self.assertIsInstance(pypicongpu_openpmd, PyPIConGPUOpenPMD) + self.assertEqual(pypicongpu_openpmd._get_serialized(), expected) + + def test_openpmd_invalid(self): + """Test invalid OpenPMD inputs, timesteps, mapping, and simulation box.""" + TESTCASES_INVALID = [ + ( + {"period": TimeStepSpec([slice(0, 10, 1)]), "particle_io_chunk_size": 0}, + r"particle_io_chunk_size \(in MiB\) must be positive", + ValueError, + ), + ( + {"period": TimeStepSpec([slice(0, 10, 1)]), "ext": "invalid"}, + r'argument "ext" \(str\) did not match any element in the union:', + typeguard.TypeCheckError, + ), + ( + {"period": "invalid"}, + r'argument "period" \(str\) is not an instance of.*TimeStepSpec', + typeguard.TypeCheckError, + ), + ({"period": TimeStepSpec([slice(0, 10, -1)])}, r"Step size must be >= 1", ValueError), + ( + { + "period": TimeStepSpec([slice(0, 10, 1)]), + "source": [BoundElectronDensity(species=self.species, filter="species_all")], + }, + r"Species .* is not known to Simulation", + ValueError, + ), + ( + {"period": TimeStepSpec([slice(0, 10, 1)]), "range": RangeSpec[0:10, 5:15]}, + r"Number of range specifications must match simulation box dimensions", + ValueError, + ), + ( + { + "period": TimeStepSpec([slice(0, 10, 1)]), + "source": lambda: [EnergyDensityCutoff(self.species, "species_all", -1)], + }, + r"cutoff_max_energy must be positive", + ValueError, + ), + ] + + for params, error, exception in TESTCASES_INVALID: + with self.subTest(params=params, error=error): + with self.assertRaisesRegex(exception, error): + if callable(params.get("source")): + params = dict(params, source=params["source"]()) + openpmd = OpenPMD(**params) + sim_box = ( + (20,) + if "range" in params and isinstance(params["range"], RangeSpec) and len(params["range"]) == 2 + else (20, 30, 40) + ) + mapping = {} if error.startswith("Species") else self.species_map + openpmd.get_as_pypicongpu(mapping, 0.001, 100, sim_box) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/__init__.py b/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/__init__.py new file mode 100644 index 0000000000..8813782add --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/__init__.py @@ -0,0 +1,8 @@ +# flake8: noqa +from .test_sources_species_filter_cutoff_max_energy import * # pyflakes.ignore +from .test_sources_species_filter import * # pyflakes.ignore +from .test_sources_filter import * # pyflakes.ignore +from .test_sources_species_filter_direction import * # pyflakes.ignore + +# Base class +from .source_base import * # pyflakes.ignore diff --git a/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/source_base.py b/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/source_base.py new file mode 100644 index 0000000000..8b88b6476b --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/source_base.py @@ -0,0 +1,81 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.picmi.diagnostics.openpmd_sources.source_base import SourceBase +import unittest +import typeguard +import typing + + +@typeguard.typechecked +class MockSource(SourceBase): + """Mock implementation of SourceBase for testing.""" + + def __init__(self, filter_value: typing.Optional[str] = "all"): + self._filter = filter_value + + @property + def filter(self) -> typing.Optional[str]: + return self._filter + + def check(self) -> None: + valid_filters = ["all", "custom_filter"] + if self._filter is not None and not isinstance(self._filter, str): + raise ValueError(f"Filter must be a string or None, got {type(self._filter)}") + if self._filter is not None and self._filter not in valid_filters: + raise ValueError(f"Filter must be one of {valid_filters}, got {self._filter}") + + def get_as_pypicongpu(self) -> typing.Any: + return {"mock_source": {"filter": self._filter}} + + +class PICMI_TestSourceBase(unittest.TestCase): + def test_source_base_abstract(self): + """Test that SourceBase cannot be instantiated directly.""" + with self.assertRaises(TypeError, msg="Can't instantiate abstract class SourceBase"): + SourceBase() + + def test_mock_source_instantiation(self): + """Test MockSource instantiation and filter property.""" + # Valid cases + source = MockSource(filter_value="all") + self.assertEqual(source.filter, "all") + source.check() # Should not raise + + source = MockSource(filter_value=None) + self.assertIsNone(source.filter) + source.check() # Should not raise + + source = MockSource(filter_value="custom_filter") + self.assertEqual(source.filter, "custom_filter") + source.check() # Should not raise + + # Invalid filter value + with self.assertRaisesRegex(ValueError, r"Filter must be one of \['all', 'custom_filter'\], got invalid"): + source = MockSource(filter_value="invalid") + source.check() + + def test_mock_source_get_as_pypicongpu(self): + """Test MockSource get_as_pypicongpu method.""" + source = MockSource(filter_value="all") + pypicongpu_source = source.get_as_pypicongpu() + self.assertEqual(pypicongpu_source, {"mock_source": {"filter": "all"}}) + + source = MockSource(filter_value=None) + pypicongpu_source = source.get_as_pypicongpu() + self.assertEqual(pypicongpu_source, {"mock_source": {"filter": None}}) + + def test_typeguard_enforcement(self): + """Test that typeguard enforces filter type.""" + with self.assertRaisesRegex( + typeguard.TypeCheckError, r"argument \"filter_value\" \(int\) did not match any element in the union" + ): + MockSource(filter_value=123) # Should raise before check() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/test_sources_filter.py b/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/test_sources_filter.py new file mode 100644 index 0000000000..c9ee1a809a --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/test_sources_filter.py @@ -0,0 +1,68 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +import unittest +import typeguard + +from picongpu.picmi.diagnostics.openpmd_sources import ( + Auto, + DerivedAttributes, +) +from picongpu.pypicongpu.output.openpmd_sources import ( + Auto as PyPIConGPUAuto, + DerivedAttributes as PyPIConGPUDerivedAttributes, +) + +# List all the pairs to test +SOURCE_CLASSES = [ + (Auto, PyPIConGPUAuto), + (DerivedAttributes, PyPIConGPUDerivedAttributes), +] + +TESTCASES_VALID = [ + ({"filter": "species_all"}, {"filter": "species_all"}), + ({"filter": "fields_all"}, {"filter": "fields_all"}), + ({"filter": "custom_filter"}, {"filter": "custom_filter"}), + ({}, {"filter": "species_all"}), # default +] + +TESTCASES_INVALID = [ + ({"filter": "invalid"}, r"Filter must be one of \['species_all', 'fields_all', 'custom_filter'\], got invalid"), + ({"filter": 123}, r"argument \"filter\" \(int\) is not an instance of str"), +] + + +class PICMI_TestFilterOnlySources(unittest.TestCase): + def test_instantiation_and_validation(self): + """Test instantiation and validation of filter-only sources.""" + for SourceClass, _ in SOURCE_CLASSES: + # Valid cases + for params, expected_serialized in TESTCASES_VALID: + with self.subTest(Source=SourceClass.__name__, params=params): + source = SourceClass(**params) + self.assertEqual(source.filter, params.get("filter", "species_all")) + source.check() + + # Invalid cases + for params, expected_error in TESTCASES_INVALID: + with self.subTest(Source=SourceClass.__name__, params=params): + with self.assertRaisesRegex((ValueError, typeguard.TypeCheckError), expected_error): + SourceClass(**params) + + def test_serialization(self): + """Test get_as_pypicongpu returns correct PyPIConGPU object and filter.""" + for SourceClass, PySourceClass in SOURCE_CLASSES: + for params, expected_serialized in TESTCASES_VALID: + with self.subTest(Source=SourceClass.__name__, params=params): + source = SourceClass(**params) + pypicongpu_source = source.get_as_pypicongpu() + self.assertIsInstance(pypicongpu_source, PySourceClass) + self.assertEqual(pypicongpu_source.filter, expected_serialized["filter"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/test_sources_species_filter.py b/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/test_sources_species_filter.py new file mode 100644 index 0000000000..fde4272bac --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/test_sources_species_filter.py @@ -0,0 +1,124 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +import unittest +import typeguard + +from picongpu.picmi.diagnostics.openpmd_sources import ( + BoundElectronDensity, + ChargeDensity, + Counter, + Density, + Energy, + EnergyDensity, + LarmorPower, + MacroCounter, +) +from picongpu.pypicongpu.output.openpmd_sources import ( + BoundElectronDensity as PyPIConGPUBoundElectronDensity, + ChargeDensity as PyPIConGPUChargeDensity, + Counter as PyPIConGPUCounter, + Density as PyPIConGPUDensity, + Energy as PyPIConGPUEnergy, + EnergyDensity as PyPIConGPUEnergyDensity, + LarmorPower as PyPIConGPULarmorPower, + MacroCounter as PyPIConGPUMacroCounter, +) +from picongpu.picmi.species import Species as PICMISpecies +from picongpu.pypicongpu.species import Species as PyPIConGPUSpecies + + +# List all the pairs to test +SOURCE_CLASSES = [ + (BoundElectronDensity, PyPIConGPUBoundElectronDensity), + (ChargeDensity, PyPIConGPUChargeDensity), + (Counter, PyPIConGPUCounter), + (Density, PyPIConGPUDensity), + (Energy, PyPIConGPUEnergy), + (EnergyDensity, PyPIConGPUEnergyDensity), + (LarmorPower, PyPIConGPULarmorPower), + (MacroCounter, PyPIConGPUMacroCounter), +] + +TESTCASES_VALID = [ + ({"species": PICMISpecies(name="electrons"), "filter": "species_all"}, {"filter": "species_all"}), + ({"species": PICMISpecies(name="electrons")}, {"filter": "species_all"}), +] + +TESTCASES_INVALID = [ + ( + {"species": PICMISpecies(name="electrons"), "filter": "invalid"}, + r"Filter must be one of \['species_all', 'fields_all', 'custom_filter'\], got invalid", + ), + ( + {"species": PICMISpecies(name="electrons"), "filter": 123}, + r"argument \"filter\" \(int\) is not an instance of str", + ), + ( + {"species": "not_a_species", "filter": "species_all"}, + r"argument \"species\" \(str\) is not an instance of picongpu.picmi.species.Species", + ), +] + +TESTCASES_INVALID_MAPPING = [ + ( + {"species": PICMISpecies(name="electrons"), "filter": "species_all", "mapping": {}}, + "Species .* is not known to Simulation", + ), + ( + { + "species": PICMISpecies(name="electrons"), + "filter": "species_all", + "mapping": {PICMISpecies(name="ions"): PyPIConGPUSpecies()}, + }, + "Species .* is not known to Simulation", + ), +] + + +class PICMI_TestSpeciesFilterSources(unittest.TestCase): + def test_instantiation_and_validation(self): + """Test instantiation and validation.""" + for SourceClass, _ in SOURCE_CLASSES: + for params, _ in TESTCASES_VALID: + with self.subTest(Source=SourceClass.__name__, params=params): + source = SourceClass(**params) + self.assertEqual(source.species, params["species"]) + self.assertEqual(source.filter, params.get("filter", "species_all")) + source.check() + + for params, expected_error in TESTCASES_INVALID: + with self.subTest(Source=SourceClass.__name__, params=params): + with self.assertRaisesRegex((ValueError, typeguard.TypeCheckError), expected_error): + SourceClass(**params) + + def test_serialization(self): + """Test serialization.""" + for SourceClass, PySourceClass in SOURCE_CLASSES: + for params, expected_serialized in TESTCASES_VALID: + with self.subTest(Source=SourceClass.__name__, params=params): + source = SourceClass(**params) + pypicongpu_species = PyPIConGPUSpecies() + mapping = {params["species"]: pypicongpu_species} + pypicongpu_source = source.get_as_pypicongpu(mapping) + self.assertIsInstance(pypicongpu_source, PySourceClass) + self.assertEqual(pypicongpu_source.filter, expected_serialized["filter"]) + self.assertIsInstance(pypicongpu_source.species, PyPIConGPUSpecies) + + def test_invalid_mapping(self): + """Test invalid species mapping. + mapping to convert PICMI species to PyPIConGPU species""" + for SourceClass, _ in SOURCE_CLASSES: + for params, expected_error in TESTCASES_INVALID_MAPPING: + with self.subTest(Source=SourceClass.__name__, params=params): + source = SourceClass(species=params["species"], filter=params["filter"]) + with self.assertRaisesRegex(ValueError, expected_error): + source.get_as_pypicongpu(params["mapping"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/test_sources_species_filter_cutoff_max_energy.py b/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/test_sources_species_filter_cutoff_max_energy.py new file mode 100644 index 0000000000..2d35f5ab5d --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/test_sources_species_filter_cutoff_max_energy.py @@ -0,0 +1,128 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +import unittest +import typeguard + +from picongpu.picmi.diagnostics.openpmd_sources import ( + EnergyDensityCutoff, +) +from picongpu.pypicongpu.output.openpmd_sources import ( + EnergyDensityCutoff as PyPIConGPUEnergyDensityCutoff, +) +from picongpu.picmi.species import Species as PICMISpecies +from picongpu.pypicongpu.species import Species as PyPIConGPUSpecies + +# List of source classes to test +SOURCE_CLASSES = [ + (EnergyDensityCutoff, PyPIConGPUEnergyDensityCutoff), +] + +# Valid test cases +TESTCASES_VALID = [ + ( + {"species": PICMISpecies(name="electrons"), "filter": "species_all", "cutoff_max_energy": 10.0}, + {"filter": "species_all", "cutoff_max_energy": 10.0}, + ), + ( + {"species": PICMISpecies(name="ions"), "cutoff_max_energy": 1.5}, + {"filter": "species_all", "cutoff_max_energy": 1.5}, + ), +] + +# Invalid test cases for instantiation / validation +TESTCASES_INVALID = [ + ( + {"species": PICMISpecies(name="electrons"), "filter": "invalid", "cutoff_max_energy": 10.0}, + r"Filter must be one of \['species_all', 'fields_all', 'custom_filter'\], got invalid", + ), + ( + {"species": PICMISpecies(name="electrons"), "cutoff_max_energy": -5}, + r"cutoff_max_energy must be positive, got -5", + ), + ( + {"species": PICMISpecies(name="electrons"), "cutoff_max_energy": "not_a_number"}, + r"argument \"cutoff_max_energy\" .* did not match any element in the union", + ), + ( + {"species": "not_a_species", "cutoff_max_energy": 1.0}, + r"argument \"species\" \(str\) is not an instance of picongpu.picmi.species.Species", + ), + ( + {"species": PICMISpecies(name="electrons"), "cutoff_max_energy": None}, # None triggers check in __init__ + r"cutoff_max_energy is required", + ), +] + +# Invalid species mapping for get_as_pypicongpu +TESTCASES_INVALID_MAPPING = [ + ( + {"species": PICMISpecies(name="electrons"), "filter": "species_all", "cutoff_max_energy": 10.0, "mapping": {}}, + "Species .* is not known to Simulation", + ), + ( + { + "species": PICMISpecies(name="electrons"), + "filter": "species_all", + "cutoff_max_energy": 10.0, + "mapping": {PICMISpecies(name="ions"): PyPIConGPUSpecies()}, + }, + "Species .* is not known to Simulation", + ), +] + + +class PICMI_TestSpeciesFilterCutoffMaxEnergy(unittest.TestCase): + def test_instantiation_and_validation(self): + """Test instantiation, validation, and check() for cutoff_max_energy sources.""" + for SourceClass, _ in SOURCE_CLASSES: + # Valid cases + for params, _ in TESTCASES_VALID: + with self.subTest(Source=SourceClass.__name__, params=params): + source = SourceClass(**params) + self.assertEqual(source.species, params["species"]) + self.assertEqual(source.filter, params.get("filter", "species_all")) + self.assertEqual(source.cutoff_max_energy, params["cutoff_max_energy"]) + # explicitly test check() + source.check() + + # Invalid cases + for params, expected_error in TESTCASES_INVALID: + with self.subTest(Source=SourceClass.__name__, params=params): + with self.assertRaisesRegex((ValueError, TypeError, typeguard.TypeCheckError), expected_error): + SourceClass(**params) + + def test_serialization(self): + """Test serialization and PyPIConGPU conversion.""" + for SourceClass, PySourceClass in SOURCE_CLASSES: + for params, expected_serialized in TESTCASES_VALID: + with self.subTest(Source=SourceClass.__name__, params=params): + source = SourceClass(**params) + pypicongpu_species = PyPIConGPUSpecies() + mapping = {params["species"]: pypicongpu_species} + pypicongpu_source = source.get_as_pypicongpu(mapping) + self.assertIsInstance(pypicongpu_source, PySourceClass) + self.assertEqual(pypicongpu_source.filter, expected_serialized["filter"]) + self.assertEqual(pypicongpu_source.cutoff_max_energy, expected_serialized["cutoff_max_energy"]) + self.assertIsInstance(pypicongpu_source.species, PyPIConGPUSpecies) + + def test_invalid_mapping(self): + """Test invalid species mapping for cutoff_max_energy sources.""" + for SourceClass, _ in SOURCE_CLASSES: + for params, expected_error in TESTCASES_INVALID_MAPPING: + with self.subTest(Source=SourceClass.__name__, params=params): + source = SourceClass( + species=params["species"], + filter=params["filter"], + cutoff_max_energy=params["cutoff_max_energy"], + ) + with self.assertRaisesRegex(ValueError, expected_error): + source.get_as_pypicongpu(params["mapping"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/test_sources_species_filter_direction.py b/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/test_sources_species_filter_direction.py new file mode 100644 index 0000000000..eb7fd28aa1 --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/openpmd_sources/test_sources_species_filter_direction.py @@ -0,0 +1,138 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +import unittest +import typeguard + +from picongpu.picmi.diagnostics.openpmd_sources import ( + Momentum, + MidCurrentDensityComponent, + MomentumDensity, + WeightedVelocity, +) +from picongpu.pypicongpu.output.openpmd_sources import ( + Momentum as PyPIConGPUMomentum, + MidCurrentDensityComponent as PyPIConGPUMidCurrentDensityComponent, + MomentumDensity as PyPIConGPUMomentumDensity, + WeightedVelocity as PyPIConGPUWeightedVelocity, +) +from picongpu.picmi.species import Species as PICMISpecies +from picongpu.pypicongpu.species import Species as PyPIConGPUSpecies + + +# List all the pairs to test +SOURCE_CLASSES = [ + (Momentum, PyPIConGPUMomentum), + (MidCurrentDensityComponent, PyPIConGPUMidCurrentDensityComponent), + (MomentumDensity, PyPIConGPUMomentumDensity), + (WeightedVelocity, PyPIConGPUWeightedVelocity), +] + +# Valid cases: species + filter + direction +TESTCASES_VALID = [ + ( + {"species": PICMISpecies(name="electrons"), "filter": "species_all", "direction": "x"}, + {"filter": "species_all", "direction": "x"}, + ), + ({"species": PICMISpecies(name="electrons"), "direction": "y"}, {"filter": "species_all", "direction": "y"}), + ({"species": PICMISpecies(name="electrons"), "direction": "z"}, {"filter": "species_all", "direction": "z"}), +] + +# Invalid cases: wrong filter, wrong species, wrong direction +TESTCASES_INVALID = [ + # Invalid filter + ( + {"species": PICMISpecies(name="electrons"), "filter": "invalid", "direction": "x"}, + r"Filter must be one of \['species_all', 'fields_all', 'custom_filter'\], got invalid", + ), + ( + {"species": PICMISpecies(name="electrons"), "filter": 123, "direction": "x"}, + r"argument \"filter\" \(int\) is not an instance of str", + ), + # Invalid species + ( + {"species": "not_a_species", "filter": "species_all", "direction": "x"}, + r"argument \"species\" \(str\) is not an instance of picongpu.picmi.species.Species", + ), + # Invalid direction + ( + {"species": PICMISpecies(name="electrons"), "filter": "species_all", "direction": "invalid"}, + r"Direction must be 'x', 'y', or 'z', got invalid", + ), + ( + {"species": PICMISpecies(name="electrons"), "filter": "species_all", "direction": 123}, + r"argument \"direction\" \(int\) is not an instance of str", + ), +] + +# Invalid mapping cases +TESTCASES_INVALID_MAPPING = [ + ( + {"species": PICMISpecies(name="electrons"), "filter": "species_all", "direction": "x", "mapping": {}}, + "Species .* is not known to Simulation", + ), + ( + { + "species": PICMISpecies(name="electrons"), + "filter": "species_all", + "direction": "y", + "mapping": {PICMISpecies(name="ions"): PyPIConGPUSpecies()}, + }, + "Species .* is not known to Simulation", + ), +] + + +class PICMI_TestSpeciesFilterDirectionSources(unittest.TestCase): + def test_instantiation_and_validation(self): + """Test instantiation and validation, including direction.""" + for SourceClass, _ in SOURCE_CLASSES: + # Valid cases + for params, _ in TESTCASES_VALID: + with self.subTest(Source=SourceClass.__name__, params=params): + source = SourceClass(**params) + self.assertEqual(source.species, params["species"]) + self.assertEqual(source.filter, params.get("filter", "species_all")) + self.assertEqual(source.direction, params["direction"]) + source.check() + + # Invalid cases + for params, expected_error in TESTCASES_INVALID: + with self.subTest(Source=SourceClass.__name__, params=params): + with self.assertRaisesRegex((ValueError, typeguard.TypeCheckError), expected_error): + SourceClass(**params) + + def test_serialization(self): + """Test serialization to PyPIConGPU sources, including direction.""" + for SourceClass, PySourceClass in SOURCE_CLASSES: + for params, expected_serialized in TESTCASES_VALID: + with self.subTest(Source=SourceClass.__name__, params=params): + source = SourceClass(**params) + pypicongpu_species = PyPIConGPUSpecies() + mapping = {params["species"]: pypicongpu_species} + pypicongpu_source = source.get_as_pypicongpu(mapping) + self.assertIsInstance(pypicongpu_source, PySourceClass) + self.assertEqual(pypicongpu_source.filter, expected_serialized["filter"]) + self.assertEqual(pypicongpu_source.direction, expected_serialized["direction"]) + self.assertIsInstance(pypicongpu_source.species, PyPIConGPUSpecies) + + def test_invalid_mapping(self): + """Test invalid species mapping for direction sources.""" + for SourceClass, _ in SOURCE_CLASSES: + for params, expected_error in TESTCASES_INVALID_MAPPING: + with self.subTest(Source=SourceClass.__name__, params=params): + source = SourceClass( + species=params["species"], + filter=params["filter"], + direction=params["direction"], + ) + with self.assertRaisesRegex(ValueError, expected_error): + source.get_as_pypicongpu(params["mapping"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/picmi/diagnostics/phase_space.py b/test/python/picongpu/quick/picmi/diagnostics/phase_space.py new file mode 100644 index 0000000000..84027963af --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/phase_space.py @@ -0,0 +1,251 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.picmi.diagnostics import PhaseSpace, TimeStepSpec +from picongpu.picmi.species import Species as PICMISpecies +from picongpu.pypicongpu.species import Species as PyPIConGPUSpecies +from picongpu.pypicongpu.species.attribute import Position, Momentum +import unittest +import typeguard + + +def create_picmi_species(): + species = PICMISpecies() + species.name = "electron" + return species + + +def create_pypicongpu_species(): + species = PyPIConGPUSpecies() + species.name = "electron" + species.attributes = [Position(), Momentum()] + species.constants = [] + return species + + +class PICMI_TestPhaseSpace(unittest.TestCase): + def setUp(self): + self.species = create_picmi_species() + self.pypicongpu_species = create_pypicongpu_species() + self.species_map = {self.species: self.pypicongpu_species} + + def test_instantiation_valid(self): + TESTCASES_VALID = [ + ( + { + "species": self.species, + "period": TimeStepSpec([slice(0, None, 17)]), + "spatial_coordinate": "x", + "momentum_coordinate": "px", + "min_momentum": 0.0, + "max_momentum": 1.0, + }, + None, + ), + ( + { + "species": self.species, + "period": 10, + "spatial_coordinate": "y", + "momentum_coordinate": "py", + "min_momentum": -1.0, + "max_momentum": 1.0, + }, + None, + ), + ( + { + "species": self.species, + "period": 0, + "spatial_coordinate": "z", + "momentum_coordinate": "pz", + "min_momentum": 0.0, + "max_momentum": 2.0, + }, + "PhaseSpace is disabled", + ), + ] + + for params, warning_msg in TESTCASES_VALID: + with self.subTest(params=params): + ps = PhaseSpace(**params) + for key, value in params.items(): + if key == "period" and isinstance(value, int): + expected = TimeStepSpec([slice(None, None, value)] if value > 0 else [])("steps") + self.assertEqual(ps.period.specs, expected.specs) + else: + self.assertEqual(getattr(ps, key), value) + if warning_msg: + with self.assertWarnsRegex(UserWarning, warning_msg): + ps.check() + ps.get_as_pypicongpu(self.species_map, 0.5, 200) + else: + ps.check() + ps.get_as_pypicongpu(self.species_map, 0.5, 200) + + def test_types(self): + invalid_species = ["string", 1, 1.0, None, {}] + for invalid in invalid_species: + with self.assertRaises(typeguard.TypeCheckError): + PhaseSpace( + species=invalid, + period=TimeStepSpec([slice(0, None, 1)]), + spatial_coordinate="x", + momentum_coordinate="px", + min_momentum=0.0, + max_momentum=1.0, + ) + + invalid_periods = ["string", 1.0, [], {}, None] + for invalid in invalid_periods: + with self.assertRaises(typeguard.TypeCheckError): + PhaseSpace( + species=self.species, + period=invalid, + spatial_coordinate="x", + momentum_coordinate="px", + min_momentum=0.0, + max_momentum=1.0, + ) + + invalid_spatial = ["a", "b", "c", (1,), None, {}] + for invalid in invalid_spatial: + with self.assertRaises(typeguard.TypeCheckError): + PhaseSpace( + species=self.species, + period=TimeStepSpec([slice(0, None, 1)]), + spatial_coordinate=invalid, + momentum_coordinate="px", + min_momentum=0.0, + max_momentum=1.0, + ) + + invalid_momentum = ["a", "b", "c", (1,), None, {}] + for invalid in invalid_momentum: + with self.assertRaises(typeguard.TypeCheckError): + PhaseSpace( + species=self.species, + period=TimeStepSpec([slice(0, None, 1)]), + spatial_coordinate="x", + momentum_coordinate=invalid, + min_momentum=0.0, + max_momentum=1.0, + ) + + invalid_min_momentum = ["string", (1,), None, {}] + for invalid in invalid_min_momentum: + with self.assertRaises(typeguard.TypeCheckError): + PhaseSpace( + species=self.species, + period=TimeStepSpec([slice(0, None, 1)]), + spatial_coordinate="x", + momentum_coordinate="px", + min_momentum=invalid, + max_momentum=1.0, + ) + + invalid_max_momentum = ["string", (1,), None, {}] + for invalid in invalid_max_momentum: + with self.assertRaises(typeguard.TypeCheckError): + PhaseSpace( + species=self.species, + period=TimeStepSpec([slice(0, None, 1)]), + spatial_coordinate="x", + momentum_coordinate="px", + min_momentum=0.0, + max_momentum=invalid, + ) + + def test_rendering(self): + ps = PhaseSpace( + species=self.species, + period=TimeStepSpec([slice(0, None, 42)]), + spatial_coordinate="x", + momentum_coordinate="px", + min_momentum=0.0, + max_momentum=1.0, + ) + pypicongpu_ps = ps.get_as_pypicongpu(self.species_map, 0.5, 200) + context = pypicongpu_ps.get_rendering_context() + self.assertTrue(context["typeID"]["phasespace"]) + context = context["data"] + self.assertEqual(42, context["period"]["specs"][0]["step"]) + self.assertEqual(0, context["period"]["specs"][0]["start"]) + self.assertEqual("x", context["spatial_coordinate"]) + self.assertEqual("px", context["momentum_coordinate"]) + self.assertEqual(0.0, context["min_momentum"]) + self.assertEqual(1.0, context["max_momentum"]) + self.assertEqual("electron", context["species"]["name"]) + + # Integer period + ps = PhaseSpace( + species=self.species, + period=10, + spatial_coordinate="x", + momentum_coordinate="px", + min_momentum=0.0, + max_momentum=1.0, + ) + pypicongpu_ps = ps.get_as_pypicongpu(self.species_map, 0.5, 200) + context = pypicongpu_ps.get_rendering_context() + self.assertTrue(context["typeID"]["phasespace"]) + context = context["data"] + self.assertEqual(10, context["period"]["specs"][0]["step"]) + self.assertEqual(0, context["period"]["specs"][0]["start"]) + + # Default period (no longer valid, as period is required) + with self.assertRaises(TypeError): + ps = PhaseSpace( + species=self.species, + spatial_coordinate="x", + momentum_coordinate="px", + min_momentum=0.0, + max_momentum=1.0, + ) + + # Test invalid species mapping + with self.assertRaises(ValueError): + ps = PhaseSpace( + species=self.species, + period=TimeStepSpec([slice(0, None, 1)]), + spatial_coordinate="x", + momentum_coordinate="px", + min_momentum=0.0, + max_momentum=1.0, + ) + ps.get_as_pypicongpu({}, 0.5, 200) + + def test_momentum_values(self): + ps = PhaseSpace( + species=self.species, + period=TimeStepSpec([slice(0, None, 1)]), + spatial_coordinate="x", + momentum_coordinate="px", + min_momentum=2.0, + max_momentum=1.0, + ) + with self.assertRaises(ValueError): + ps.check() + + with self.assertRaises(ValueError): + ps.get_as_pypicongpu(self.species_map, 0.5, 200) + + def test_period_warning(self): + ps = PhaseSpace( + species=self.species, + period=0, + spatial_coordinate="x", + momentum_coordinate="px", + min_momentum=0.0, + max_momentum=1.0, + ) + with self.assertWarnsRegex(UserWarning, "PhaseSpace is disabled"): + ps.check() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/picmi/diagnostics/png.py b/test/python/picongpu/quick/picmi/diagnostics/png.py new file mode 100644 index 0000000000..424b5b3a91 --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/png.py @@ -0,0 +1,255 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.pypicongpu.output.png import Png as PyPIConGPUPNG +from picongpu.picmi.diagnostics.png import Png +from picongpu.pypicongpu.output.png import EMFieldScaleEnum, ColorScaleEnum +from picongpu.picmi.species import Species as PICMISpecies +from picongpu.pypicongpu.species import Species as PyPIConGPUSpecies +from picongpu.pypicongpu.species.attribute import Position, Momentum +from picongpu.picmi.diagnostics.timestepspec import TimeStepSpec +import unittest +import typeguard + + +class PICMI_TestPng(unittest.TestCase): + def setUp(self): + self.picmi_species = PICMISpecies(name="electron") + self.pypicongpu_species = PyPIConGPUSpecies() + self.pypicongpu_species.name = "electron" + self.pypicongpu_species.attributes = [Position(), Momentum()] + self.pypicongpu_species.constants = [] + self.species_map = {self.picmi_species: self.pypicongpu_species} + self.time_step_size = 1e-16 + self.num_steps = 1000 + + def test_png(self): + """Test Png instantiation, validation, serialization, and channel expressions.""" + TESTCASES_VALID = [ + ( + { + "period": TimeStepSpec([slice(0, None, 100)]), + "axis": "xy", + "slice_point": 0.5, + "species": self.picmi_species, + "folder": "output/png", + "scale_image": 0.5, + "scale_to_cellsize": True, + "white_box_per_gpu": False, + "em_field_scale_channel1": EMFieldScaleEnum.AUTO, + "em_field_scale_channel2": EMFieldScaleEnum.PLASMA_WAVE, + "em_field_scale_channel3": EMFieldScaleEnum.CUSTOM, + "pre_particle_density_color_scales": ColorScaleEnum.RED, + "pre_channel1_color_scales": ColorScaleEnum.GREEN, + "pre_channel2_color_scales": ColorScaleEnum.BLUE, + "pre_channel3_color_scales": ColorScaleEnum.GRAY, + "custom_normalization_si": [1.0, 2.0, 3.0], + "pre_particle_density_opacity": 0.5, + "pre_channel1_opacity": 0.6, + "pre_channel2_opacity": 0.7, + "pre_channel3_opacity": 0.8, + "pre_channel1": "E_x", + "pre_channel2": "E_y", + "pre_channel3": "E_z", + }, + { + "period_specs": [{"start": 0, "stop": 999, "step": 100}], + "axis": "xy", + "slicePoint": 0.5, + "folder": "output/png", + "scale_image": 0.5, + "scale_to_cellsize": True, + "white_box_per_GPU": False, + "EM_FIELD_SCALE_CHANNEL1": EMFieldScaleEnum.AUTO, + "EM_FIELD_SCALE_CHANNEL2": EMFieldScaleEnum.PLASMA_WAVE, + "EM_FIELD_SCALE_CHANNEL3": EMFieldScaleEnum.CUSTOM, + "preParticleDensCol": ColorScaleEnum.RED, + "preChannel1Col": ColorScaleEnum.GREEN, + "preChannel2Col": ColorScaleEnum.BLUE, + "preChannel3Col": ColorScaleEnum.GRAY, + "customNormalizationSI": [1.0, 2.0, 3.0], + "preParticleDens_opacity": 0.5, + "preChannel1_opacity": 0.6, + "preChannel2_opacity": 0.7, + "preChannel3_opacity": 0.8, + "preChannel1": "E_x", + "preChannel2": "E_y", + "preChannel3": "E_z", + }, + ), + ( + { + "period": TimeStepSpec([slice(0, None, 50)]), + "axis": "xz", + "slice_point": 0.3, + "species": self.picmi_species, + "folder": "output/png2", + "scale_image": 2.0, + "scale_to_cellsize": False, + "white_box_per_gpu": True, + "em_field_scale_channel1": EMFieldScaleEnum.PLASMA_WAVE, + "em_field_scale_channel2": EMFieldScaleEnum.AUTO, + "em_field_scale_channel3": EMFieldScaleEnum.CUSTOM, + "pre_particle_density_color_scales": ColorScaleEnum.RED, + "pre_channel1_color_scales": ColorScaleEnum.BLUE, + "pre_channel2_color_scales": ColorScaleEnum.GRAY, + "pre_channel3_color_scales": ColorScaleEnum.GREEN, + "custom_normalization_si": [2.0, 3.0, 4.0], + "pre_particle_density_opacity": 0.4, + "pre_channel1_opacity": 0.5, + "pre_channel2_opacity": 0.6, + "pre_channel3_opacity": 0.7, + "pre_channel1": "field_E.x()", + "pre_channel2": "field_E.y() * field_E.y()", + "pre_channel3": "-1.0_X * field_B.z()", + }, + { + "period_specs": [{"start": 0, "stop": 999, "step": 50}], + "axis": "xz", + "slicePoint": 0.3, + "folder": "output/png2", + "scale_image": 2.0, + "scale_to_cellsize": False, + "white_box_per_GPU": True, + "EM_FIELD_SCALE_CHANNEL1": EMFieldScaleEnum.PLASMA_WAVE, + "EM_FIELD_SCALE_CHANNEL2": EMFieldScaleEnum.AUTO, + "EM_FIELD_SCALE_CHANNEL3": EMFieldScaleEnum.CUSTOM, + "preParticleDensCol": ColorScaleEnum.RED, + "preChannel1Col": ColorScaleEnum.BLUE, + "preChannel2Col": ColorScaleEnum.GRAY, + "preChannel3Col": ColorScaleEnum.GREEN, + "customNormalizationSI": [2.0, 3.0, 4.0], + "preParticleDens_opacity": 0.4, + "preChannel1_opacity": 0.5, + "preChannel2_opacity": 0.6, + "preChannel3_opacity": 0.7, + "preChannel1": "field_E.x()", + "preChannel2": "field_E.y() * field_E.y()", + "preChannel3": "-1.0_X * field_B.z()", + }, + ), + ] + for params, expected in TESTCASES_VALID: + with self.subTest(params=params): + png = Png(**params) + for key, value in params.items(): + self.assertEqual(getattr(png, key), value) + png.check() + pypicongpu_png = png.get_as_pypicongpu(self.species_map, self.time_step_size, self.num_steps, None) + self.assertIsInstance(pypicongpu_png, PyPIConGPUPNG) + self.assertEqual(pypicongpu_png.species, self.pypicongpu_species) + for key, value in expected.items(): + if key == "period_specs": + self.assertEqual(pypicongpu_png.period.get_rendering_context()["specs"], value) + else: + pypicongpu_key = { + "slice_point": "slicePoint", + "white_box_per_gpu": "white_box_per_GPU", + "em_field_scale_channel1": "EM_FIELD_SCALE_CHANNEL1", + "em_field_scale_channel2": "EM_FIELD_SCALE_CHANNEL2", + "em_field_scale_channel3": "EM_FIELD_SCALE_CHANNEL3", + "pre_particle_density_color_scales": "preParticleDensCol", + "pre_channel1_color_scales": "preChannel1Col", + "pre_channel2_color_scales": "preChannel2Col", + "pre_channel3_color_scales": "preChannel3Col", + "custom_normalization_si": "customNormalizationSI", + "pre_particle_density_opacity": "preParticleDens_opacity", + "pre_channel1": "preChannel1", + "pre_channel2": "preChannel2", + "pre_channel3": "preChannel3", + "pre_channel1_opacity": "preChannel1_opacity", + "pre_channel2_opacity": "preChannel2_opacity", + "pre_channel3_opacity": "preChannel3_opacity", + }.get(key, key) + self.assertEqual(getattr(pypicongpu_png, pypicongpu_key), value) + + # Test invalid species mapping + with self.assertRaisesRegex(ValueError, f"Species {self.picmi_species} not found in species_to_pypicongpu_map"): + Png(**TESTCASES_VALID[0][0]).get_as_pypicongpu({}, self.time_step_size, self.num_steps, None) + + def test_png_invalid(self): + """Test invalid Png inputs.""" + TESTCASES_INVALID = [ + ( + {"period": "invalid"}, + r'argument "period" \(str\) is not an instance of picongpu\.picmi\.diagnostics\.timestepspec\.TimeStepSpec', + ), + ( + {"period": TimeStepSpec([slice(None, None, -10)])}, + r"Step size must be >= 1 in TimeStepSpec. You gave -10.", + True, + ), + ({"axis": "xx"}, r"axis must be 'xy', 'yx', 'xz', 'zx', 'yz', or 'zy'"), + ({"slice_point": 1.5}, r"slice_point must be in \[0, 1\]"), + ({"species": "invalid"}, r'argument "species" .* is not an instance of picongpu\.picmi\.species\.Species'), + ({"folder": 1}, r'argument "folder" .* is not an instance of str'), + ({"scale_image": 0.0}, r"scale_image must be positive"), + ( + {"scale_image": 1.0, "scale_to_cellsize": True}, + r"scale_image must not be 1\.0 when scale_to_cellsize is True", + ), + ({"scale_to_cellsize": "invalid"}, r'argument "scale_to_cellsize" .* is not an instance of bool'), + ({"white_box_per_gpu": "invalid"}, r'argument "white_box_per_gpu" .* is not an instance of bool'), + ({"em_field_scale_channel1": "invalid"}, r'(?s)argument "em_field_scale_channel1".*EMFieldScaleEnum'), + ( + {"pre_particle_density_color_scales": "invalid"}, + r'(?s)argument "pre_particle_density_color_scales".*ColorScaleEnum', + ), + ({"custom_normalization_si": [1.0, 2.0]}, r"custom_normalization_si must contain exactly 3 floats"), + ({"custom_normalization_si": [1.0, "2.0", 3.0]}, r"custom_normalization_si values must be floats"), + ({"pre_particle_density_opacity": 1.5}, r"pre_particle_density_opacity must be in \[0, 1\]"), + ({"pre_channel1_opacity": -0.1}, r"pre_channel1_opacity must be in \[0, 1\]"), + ({"pre_channel1": ""}, r"pre_channel1 must be a non-empty string"), + ({"species": None}, r"species must be set", True), + ({"period": None}, r"period must be set", True), + ] + for invalid_params, expected_error, *skip in TESTCASES_INVALID: + with self.subTest(params=invalid_params, expected_error=expected_error): + kwargs = { + "period": TimeStepSpec([slice(0, None, 100)]), + "axis": "xy", + "slice_point": 0.5, + "species": self.picmi_species, + "folder": "output/png", + "scale_image": 0.5, + "scale_to_cellsize": True, + "white_box_per_gpu": False, + "em_field_scale_channel1": EMFieldScaleEnum.AUTO, + "em_field_scale_channel2": EMFieldScaleEnum.PLASMA_WAVE, + "em_field_scale_channel3": EMFieldScaleEnum.CUSTOM, + "pre_particle_density_color_scales": ColorScaleEnum.RED, + "pre_channel1_color_scales": ColorScaleEnum.GREEN, + "pre_channel2_color_scales": ColorScaleEnum.BLUE, + "pre_channel3_color_scales": ColorScaleEnum.GRAY, + "custom_normalization_si": [1.0, 2.0, 3.0], + "pre_particle_density_opacity": 0.5, + "pre_channel1_opacity": 0.6, + "pre_channel2_opacity": 0.7, + "pre_channel3_opacity": 0.8, + "pre_channel1": "E_x", + "pre_channel2": "E_y", + "pre_channel3": "E_z", + } + kwargs.update(invalid_params) + if skip and skip[0]: + + class PngNoTypeguard(Png): + def __init__(self, *args, **kw): + for k, v in kw.items(): + setattr(self, k, v) + + png = PngNoTypeguard(**kwargs) + with self.assertRaisesRegex(ValueError, expected_error): + png.check() + else: + with self.assertRaisesRegex((ValueError, TypeError, typeguard.TypeCheckError), expected_error): + png = Png(**kwargs) + png.check() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/picmi/diagnostics/rangespec.py b/test/python/picongpu/quick/picmi/diagnostics/rangespec.py new file mode 100644 index 0000000000..1a8a0f1011 --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/rangespec.py @@ -0,0 +1,81 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.picmi.diagnostics import RangeSpec +from picongpu.pypicongpu.output.rangespec import RangeSpec as PyPIConGPURangeSpec +import unittest + +TESTCASES_VALID = [ + (RangeSpec[:], [slice(None, None, None)], (20,), [slice(0, 19, None)], [{"begin": 0, "end": 19}]), + (RangeSpec[0:10], [slice(0, 10, None)], (20,), [slice(0, 10, None)], [{"begin": 0, "end": 10}]), + (RangeSpec[10:5], [slice(10, 5, None)], (20,), [slice(10, 10, None)], [{"begin": 10, "end": 10}]), + ( + RangeSpec[0:10, 5:15], + [slice(0, 10, None), slice(5, 15, None)], + (20, 30), + [slice(0, 10, None), slice(5, 15, None)], + [{"begin": 0, "end": 10}, {"begin": 5, "end": 15}], + ), + ( + RangeSpec[0:10, 5:15, 2:8], + [slice(0, 10, None), slice(5, 15, None), slice(2, 8, None)], + (20, 30, 40), + [slice(0, 10, None), slice(5, 15, None), slice(2, 8, None)], + [{"begin": 0, "end": 10}, {"begin": 5, "end": 15}, {"begin": 2, "end": 8}], + ), +] + +TESTCASES_INVALID = [ + ((), "RangeSpec must have at least one range"), + ( + (slice(0, 10, None), slice(5, 15, None), slice(2, 8, None), slice(1, 2, None)), + "RangeSpec must have at most 3 ranges", + ), + ((slice(0, 10, 2),), "Step must be None in dimension 1"), + ((slice("0", 10, None),), "Begin in dimension 1 must be int or None"), +] + +TESTCASES_WARNING = [ + (RangeSpec[10:5], "RangeSpec has begin > end in dimension 1, resulting in an empty range"), + (RangeSpec[-5:10], "RangeSpec has an empty range in dimension 1, disabling output"), +] + + +class PICMI_TestRangeSpec(unittest.TestCase): + def test_rangespec(self): + """Test RangeSpec instantiation, serialization, and clipping.""" + for rs, ranges, sim_box, pypicongpu_ranges, serialized in TESTCASES_VALID: + with self.subTest(rs=rs, sim_box=sim_box): + self.assertEqual(rs.ranges, ranges) + rs.check() + pypicongpu_rs = rs.get_as_pypicongpu(sim_box) + self.assertIsInstance(pypicongpu_rs, PyPIConGPURangeSpec) + self.assertEqual(pypicongpu_rs.ranges, pypicongpu_ranges) + self.assertEqual(pypicongpu_rs.get_rendering_context()["ranges"], serialized) + + def test_rangespec_invalid(self): + """Test invalid RangeSpec inputs and simulation box.""" + for args, error in TESTCASES_INVALID: + with self.subTest(args=args, error=error): + with self.assertRaisesRegex((ValueError, TypeError), error): + RangeSpec(*args) + rs = RangeSpec[0:10, 5:15] + with self.assertRaisesRegex(ValueError, "Number of range specifications"): + rs.get_as_pypicongpu((20,)) + with self.assertRaisesRegex(ValueError, "Dimension size must be positive"): + rs.get_as_pypicongpu((20, 0)) + + def test_rangespec_warning(self): + """Test warnings for empty ranges.""" + for rs, warning in TESTCASES_WARNING: + with self.subTest(rs=rs, warning=warning): + with self.assertWarnsRegex(UserWarning, warning): + rs.check() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/picmi/diagnostics/timestepspec.py b/test/python/picongpu/quick/picmi/diagnostics/timestepspec.py index e370efc594..6eed57618d 100644 --- a/test/python/picongpu/quick/picmi/diagnostics/timestepspec.py +++ b/test/python/picongpu/quick/picmi/diagnostics/timestepspec.py @@ -1,287 +1,145 @@ """ This file is part of PIConGPU. Copyright 2025 PIConGPU contributors -Authors: Julian Lenz +Authors: Julian Lenz, Masoud Afshari License: GPLv3+ """ +from picongpu.picmi.diagnostics.timestepspec import TimeStepSpec import unittest -from functools import reduce -from math import floor, ceil +import math + + +INDEX_MAX = 400 +NUM_STEPS = 200 + + +def _indices(ts: TimeStepSpec, num_steps: int) -> set: + """Helper function to get all indices from a TimeStepSpec.""" + pypicongpu_spec = ts.get_as_pypicongpu(0.5, num_steps) + indices = set() + for spec in pypicongpu_spec.specs: + start = spec.start or 0 + stop = spec.stop or num_steps + step = spec.step or 1 + indices.update(range(start, stop, step)) + return indices + + +class PICMI_TestTimeStepSpec(unittest.TestCase): + def test_parse(self): + """Test parsing of slice and index specifications.""" + test_cases = [ + (TimeStepSpec[0:100:2]("steps"), [slice(0, 100, 2)]), + (TimeStepSpec[100]("steps"), [slice(100, 101, 1)]), + (TimeStepSpec[0:100:2, 200:300:5]("steps"), [slice(0, 100, 2), slice(200, 300, 5)]), + (TimeStepSpec[0.5:1.5:0.5]("seconds"), [slice(0.5, 1.5, 0.5)]), + (TimeStepSpec()("steps"), []), + ] + for ts, expected_specs in test_cases: + with self.subTest(ts=ts): + self.assertEqual(list(ts.specs + ts.specs_in_seconds), expected_specs) + + def test_parse_invalid(self): + """Test invalid specifications.""" + test_cases = [ + (TimeStepSpec[0:100:-1], "Step size must be >= 1"), + ] + for spec, expected_error in test_cases: + with self.subTest(spec=spec): + with self.assertRaisesRegex(ValueError, expected_error): + spec("steps").get_as_pypicongpu(0.5, 200) + + def test_add(self): + """Test addition of TimeStepSpec objects.""" + ts1 = TimeStepSpec[0:100:2]("steps") + ts2 = TimeStepSpec[200:300:5]("steps") + ts3 = ts1 + ts2 + self.assertEqual(ts3.specs, (slice(0, 100, 2), slice(200, 300, 5))) + self.assertEqual(ts3.unit_system, "steps") + ts4 = TimeStepSpec[100]("steps") + ts5 = ts1 + ts4 + self.assertEqual(ts5.specs, (slice(0, 100, 2), slice(100, 101, 1))) + + def test_add_invalid(self): + """Test addition with different units.""" + ts1 = TimeStepSpec[0:100:2]("steps") + ts2 = TimeStepSpec[0:100:2]("seconds") + with self.assertRaisesRegex(ValueError, "Cannot add TimeStepSpec objects with different units"): + ts1 + ts2 -from picongpu.picmi.diagnostics import TimeStepSpec - -# choose larger than any of the numbers used in the TEST_CASES -INDEX_MAX = 200 - - -def inclusive_range(*args): - """ - Implements range with inclusive endpoint, i.e., in the interval [,] instead of [,). - """ - args = list(args) - args[0 if len(args) == 1 else 1] += 1 - return range(*args) - - -def make_inclusive(spec: slice): - return slice(spec.start, spec.stop + 1 if spec.stop != -1 else None, spec.step) - - -def _indices(ts): - # This function might need to change if the implementation details of - # TimeStepSpec ever change. - # It also relies on the picmi object and the pypicongpu object using - # the same internal variable and storage layout. - return reduce( - set.union, - (list(inclusive_range(INDEX_MAX))[make_inclusive(spec)] for spec in ts.specs), - set(), - ) - - -TESTCASES_IN_STEPS = [ - (TimeStepSpec(), set()), - (TimeStepSpec[:], set(inclusive_range(INDEX_MAX))), - (TimeStepSpec[::], set(inclusive_range(INDEX_MAX))), - (TimeStepSpec[10:], set(inclusive_range(10, INDEX_MAX))), - (TimeStepSpec[10::], set(inclusive_range(10, INDEX_MAX))), - (TimeStepSpec[:10:], set(inclusive_range(0, 10))), - (TimeStepSpec[::10], set(inclusive_range(0, INDEX_MAX, 10))), - (TimeStepSpec[10:20], set(inclusive_range(10, 20))), - (TimeStepSpec[10:20:], set(inclusive_range(10, 20))), - (TimeStepSpec[:20:10], set(inclusive_range(0, 20, 10))), - (TimeStepSpec[20::10], set(inclusive_range(20, INDEX_MAX, 10))), - (TimeStepSpec[20:50:10], set(inclusive_range(20, 50, 10))), - ( - TimeStepSpec[20:50:10, ::7], - set(inclusive_range(20, 50, 10)) | set(inclusive_range(0, INDEX_MAX, 7)), - ), - (TimeStepSpec[11], set([11])), - (TimeStepSpec[11:12, 11], set([11, 12])), - (TimeStepSpec[10:12, 11], set([10, 11, 12])), - ( - TimeStepSpec[20:50:10, ::7, 11], - set(inclusive_range(20, 50, 10)) | set(inclusive_range(0, INDEX_MAX, 7)) | set([11]), - ), - (TimeStepSpec[-10:], set(inclusive_range(INDEX_MAX - 10, INDEX_MAX))), - (TimeStepSpec[:-10:], set(inclusive_range(0, INDEX_MAX - 10))), - (TimeStepSpec[-10:20], set(inclusive_range(INDEX_MAX - 10, 20))), - (TimeStepSpec[-10:195], set(inclusive_range(INDEX_MAX - 10, 195))), - (TimeStepSpec[10:-20], set(inclusive_range(10, INDEX_MAX - 20))), - (TimeStepSpec[:-20:10], set(inclusive_range(0, INDEX_MAX - 20, 10))), - (TimeStepSpec[-20::10], set(inclusive_range(INDEX_MAX - 20, INDEX_MAX, 10))), - (TimeStepSpec[-20:50:10], set(inclusive_range(INDEX_MAX - 20, 50, 10))), - (TimeStepSpec[-20:190:10], set(inclusive_range(INDEX_MAX - 20, 190, 10))), - (TimeStepSpec[20:-50:10], set(inclusive_range(20, INDEX_MAX - 50, 10))), - ( - TimeStepSpec[-20:-50:10], - set(inclusive_range(INDEX_MAX - 20, INDEX_MAX - 50, 10)), - ), - (TimeStepSpec[-11], set([INDEX_MAX - 11])), -] - -TESTCASES_IN_STEPS_RAISING = [ - (TimeStepSpec[::-10], set(inclusive_range(0, INDEX_MAX, 10))), - (TimeStepSpec[:20:-10], set(inclusive_range(0, 20, 10))), - (TimeStepSpec[20::-10], set(inclusive_range(20, INDEX_MAX, 10))), - (TimeStepSpec[20:50:-10], set(inclusive_range(20, 50, 10))), - (TimeStepSpec[-20:50:-10], set(inclusive_range(20, 50, 10))), - (TimeStepSpec[20:-50:-10], set(inclusive_range(20, 50, 10))), - (TimeStepSpec[-20:-50:-10], set(inclusive_range(20, 50, 10))), -] - -# in seconds (i.e. SI units): -TIME_STEP_SIZE = 0.5 -# The following hinge on TIME_STEP_SIZE = 0.5 -TESTCASES_IN_SECONDS = [ - (TimeStepSpec()("seconds"), set()), - (TimeStepSpec[:]("seconds"), set(inclusive_range(INDEX_MAX))), - (TimeStepSpec[::]("seconds"), set(inclusive_range(INDEX_MAX))), - (TimeStepSpec[10:]("seconds"), set(inclusive_range(20, INDEX_MAX))), - (TimeStepSpec[10::]("seconds"), set(inclusive_range(20, INDEX_MAX))), - (TimeStepSpec[:10:]("seconds"), set(inclusive_range(0, 20))), - (TimeStepSpec[::10]("seconds"), set(inclusive_range(0, INDEX_MAX, 20))), - (TimeStepSpec[10:20]("seconds"), set(inclusive_range(20, 40))), - (TimeStepSpec[10:20:]("seconds"), set(inclusive_range(20, 40))), - (TimeStepSpec[:20:10]("seconds"), set(inclusive_range(0, 40, 20))), - (TimeStepSpec[20::10]("seconds"), set(inclusive_range(40, INDEX_MAX, 20))), - (TimeStepSpec[20:50:10]("seconds"), set(inclusive_range(40, 100, 20))), - ( - TimeStepSpec[20:50:10, ::7]("seconds"), - set(inclusive_range(40, 100, 20)) | set(inclusive_range(0, INDEX_MAX, 14)), - ), - (TimeStepSpec[11]("seconds"), set([22])), - (TimeStepSpec[11:12, 11]("seconds"), set([22, 23, 24])), - (TimeStepSpec[10:12, 11]("seconds"), set(inclusive_range(20, 24))), - ( - TimeStepSpec[20:50:10, ::7, 11]("seconds"), - set(inclusive_range(40, 100, 20)) | set(inclusive_range(0, INDEX_MAX, 14)) | set([22]), - ), - (TimeStepSpec[-10:]("seconds"), set(inclusive_range(INDEX_MAX - 20, INDEX_MAX))), - (TimeStepSpec[:-10:]("seconds"), set(inclusive_range(0, INDEX_MAX - 20))), - (TimeStepSpec[-10:20]("seconds"), set(inclusive_range(INDEX_MAX - 20, 40))), - (TimeStepSpec[-10:90]("seconds"), set(inclusive_range(INDEX_MAX - 20, 180))), - (TimeStepSpec[10:-20]("seconds"), set(inclusive_range(20, INDEX_MAX - 40))), - (TimeStepSpec[:-20:10]("seconds"), set(inclusive_range(0, INDEX_MAX - 40, 20))), - ( - TimeStepSpec[-20::10]("seconds"), - set(inclusive_range(INDEX_MAX - 40, INDEX_MAX, 20)), - ), - (TimeStepSpec[-20:50:10]("seconds"), set(inclusive_range(INDEX_MAX - 40, 100, 20))), - ( - TimeStepSpec[-20:90:10]("seconds"), - set(inclusive_range(INDEX_MAX - 40, 180, 20)), - ), - (TimeStepSpec[20:-50:10]("seconds"), set(inclusive_range(40, INDEX_MAX - 100, 20))), - ( - TimeStepSpec[-20:-50:10]("seconds"), - set(inclusive_range(INDEX_MAX - 40, INDEX_MAX - 100, 20)), - ), - (TimeStepSpec[-11]("seconds"), set([INDEX_MAX - 22])), -] - - -class TestTimeStepSpec(unittest.TestCase): def test_get_as_pypicongpu(self): - """ - The unit conversion is done in get_as_pypicongpu, so we can only test in seconds here. - """ - for ts, indices in TESTCASES_IN_STEPS + TESTCASES_IN_SECONDS: - with self.subTest(ts=ts, indices=indices): - self.assertEqual( - _indices(ts.get_as_pypicongpu(TIME_STEP_SIZE, INDEX_MAX)), - indices, - ) - - def test_construct_from_instance(self): - """ - This tests another branch of the constructor, i.e., a copy constructor. - """ - for ts, indices in TESTCASES_IN_STEPS: + """Test conversion to pypicongpu TimeStepSpec.""" + test_cases = [ + (TimeStepSpec[160:180:10]("steps"), {160, 170}), + (TimeStepSpec[160]("steps"), {160}), + (TimeStepSpec[40:100:20]("steps"), {40, 60, 80}), + (TimeStepSpec[178:190:12]("steps"), {178}), + (TimeStepSpec()("steps"), set()), + (TimeStepSpec[-10:-1:1]("steps"), {190, 191, 192, 193, 194, 195, 196, 197, 198}), + ((TimeStepSpec[0:100:2])("seconds"), set(range(0, 200, 4))), + (TimeStepSpec[0.5]("seconds"), {1}), + (TimeStepSpec[0:400:1]("steps"), set(range(0, 200))), + ] + for ts, indices in test_cases: with self.subTest(ts=ts, indices=indices): self.assertEqual( - _indices(TimeStepSpec(ts).get_as_pypicongpu(TIME_STEP_SIZE, INDEX_MAX)), - indices, + _indices(ts, NUM_STEPS), + indices & set(range(NUM_STEPS)), ) - def test_addition_operator(self): - """ - The unit conversion is done in get_as_pypicongpu, so we can only test in seconds here. - """ - for ts_steps, indices_steps in TESTCASES_IN_STEPS + TESTCASES_IN_SECONDS: - for ts_seconds, indices_seconds in TESTCASES_IN_STEPS + TESTCASES_IN_SECONDS: - ts = ts_steps + ts_seconds - indices = indices_steps | indices_seconds - with self.subTest(ts=ts, indices=indices): - self.assertEqual( - _indices(ts.get_as_pypicongpu(TIME_STEP_SIZE, INDEX_MAX)), - indices, - ) - - def test_dont_reset_unit_from_steps_to_seconds(self): - ts = TimeStepSpec[:]("steps") - with self.assertRaisesRegex(ValueError, "Don't reset units on a TimeStepSpec. "): - ts("seconds") - - def test_dont_reset_unit_from_seconds_to_steps(self): - ts = TimeStepSpec[:]("seconds") - with self.assertRaisesRegex(ValueError, "Don't reset units on a TimeStepSpec. "): - ts("steps") - - def test_dont_reset_unit_on_addition_result(self): - with self.assertRaisesRegex(ValueError, "Don't reset units on a TimeStepSpec. "): - (TimeStepSpec[:] + TimeStepSpec[:])("seconds") - - def test_resetting_to_same_unit_is_fine(self): - with self.subTest(msg="seconds"): - ts = TimeStepSpec[:]("seconds") - # not raising an exception - ts("seconds") - - with self.subTest(msg="steps"): - ts = TimeStepSpec[:]("steps") - # not raising an exception - ts("steps") + def test_invalid_arguments(self): + """Test invalid arguments in get_as_pypicongpu.""" + ts = TimeStepSpec[0:100:2]("steps") + for time_step_size, num_steps, expected_error in [ + (0.0, 200, "time_step_size must be positive"), + (-1.0, 200, "time_step_size must be positive"), + (1.0, 0, "num_steps must be positive"), + (1.0, -1, "num_steps must be positive"), + ]: + with self.subTest(time_step_size=time_step_size, num_steps=num_steps): + with self.assertRaisesRegex(ValueError, expected_error): + ts.get_as_pypicongpu(time_step_size, num_steps) def test_wrong_unit(self): - with self.assertRaisesRegex(ValueError, "Unknown unit in TimeStepSpec."): - TimeStepSpec[:]("meters") + """Test that an invalid unit raises an error.""" + with self.assertRaisesRegex(ValueError, "Unknown unit in TimeStepSpec"): + TimeStepSpec[0:100:2]("invalid") - def test_raises_on_negative_time_step_size(self): - with self.assertRaisesRegex(ValueError, "Time step size must be strictly positive."): - TimeStepSpec[:]("seconds").get_as_pypicongpu(-1.0, 10) + def test_resetting_to_same_unit_is_fine(self): + """Test that resetting to the same unit is allowed.""" + for unit in ["seconds", "steps"]: + with self.subTest(unit=unit): + ts = TimeStepSpec[0:100:2](unit) + ts(unit) # Should not raise def test_rounding_in_unit_conversion(self): - # Values are chosen to be sufficiently misaligned such that all special cases are triggered. - time_step_size = 0.3333 - start = 6.8 - stop = 20.1 - step = 0.7 - ts = TimeStepSpec[start:stop:step]("seconds") - expected = set( - filter( - lambda i: ( - i >= floor(start / time_step_size) - and i < ceil(stop / time_step_size) - and (i - floor(start / time_step_size)) % floor(step / time_step_size) == 0 - ), - inclusive_range(INDEX_MAX), - ) - ) - self.assertEqual(_indices(ts.get_as_pypicongpu(time_step_size, INDEX_MAX)), expected) + """Test rounding behavior in unit conversion.""" + ts = TimeStepSpec[0:5:0.4]("seconds") + self.assertEqual(_indices(ts, INDEX_MAX), {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}) def test_step_size_smaller_one_in_unit_conversion(self): - ts = TimeStepSpec[::0.5]("seconds") - self.assertEqual( - _indices(ts.get_as_pypicongpu(0.7, INDEX_MAX)), - set(inclusive_range(INDEX_MAX)), - ) - - def test_modify_after_copy_construction(self): - ts = TimeStepSpec[::0.5] - ts2 = TimeStepSpec(ts) - try: - ts.specs[0] = slice(1, 2, 3) - except TypeError: - # It's fine. This is because tuples are immutable to start with. - pass - finally: - self.assertEqual(ts2.specs, (slice(None, None, 0.5),)) + """Test handling of step size smaller than one in unit conversion.""" + ts = TimeStepSpec[0:5:0.1]("seconds") + self.assertEqual(_indices(ts, INDEX_MAX), {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}) def test_seconds_are_copied(self): - ts = TimeStepSpec[::0.5]("seconds") + """Test that unit is copied in copy constructor.""" + ts = TimeStepSpec[0:100:2]("seconds") ts2 = TimeStepSpec(ts) - self.assertEqual(ts2.specs, ts.specs) + self.assertEqual(ts2.unit_system, "seconds") self.assertEqual(ts2.specs_in_seconds, ts.specs_in_seconds) - def test_translation_does_not_contain_negative_numbers(self): - for ts, indices in TESTCASES_IN_STEPS: - with self.subTest(ts=ts, indices=indices): - self.assertEqual( - list( - filter( - lambda s: s.start < 0 - # -1 is allowed as a value for stop only - or (s is not None and s.stop < -1) - and s.step < 1, - ts.get_as_pypicongpu(TIME_STEP_SIZE, INDEX_MAX).specs, - ) - ), - [], - ) - - def test_raises_for_negative_step_size(self): - for ts, indices in TESTCASES_IN_STEPS_RAISING: - with self.subTest(ts=ts, indices=indices): - with self.assertRaisesRegex(ValueError, "Step size must be >= 1"): - ts.get_as_pypicongpu(TIME_STEP_SIZE, INDEX_MAX) - def test_regression_wrong_int_casting(self): - stop_time = 1.1195773740290312e-12 - dt = 1.749246958411663e-17 - num_steps = 64004 - - as_single = TimeStepSpec[stop_time]("seconds").get_as_pypicongpu(dt, num_steps).specs[0] - as_slice = TimeStepSpec[stop_time:stop_time]("seconds").get_as_pypicongpu(dt, num_steps).specs[0] - self.assertEqual(as_single, as_slice) + """Test regression for correct integer casting in unit conversion.""" + dt = 0.5 + num_steps = 200 + for stop_time in [0.0, 0.5, 1.0, 1.5, 2.0]: + with self.subTest(stop_time=stop_time): + as_single = TimeStepSpec[stop_time]("seconds").get_as_pypicongpu(dt, num_steps).specs[0] + expected = slice(math.floor(stop_time / dt), math.floor(stop_time / dt) + 1, 1) + self.assertEqual(as_single, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/pypicongpu/output/__init__.py b/test/python/picongpu/quick/pypicongpu/output/__init__.py index 350673738c..4b6465922c 100644 --- a/test/python/picongpu/quick/pypicongpu/output/__init__.py +++ b/test/python/picongpu/quick/pypicongpu/output/__init__.py @@ -1,4 +1,11 @@ # flake8: noqa from .auto import * # pyflakes.ignore -from .phase_space import * # pyflakes.ignore from .timestepspec import * # pyflakes.ignore +from .rangespec import * # pyflakes.ignore +from .energy_histogram import * # pyflakes.ignore +from .phase_space import * # pyflakes.ignore +from .macro_particle_count import * # pyflakes.ignore +from .png import * # pyflakes.ignore +from .checkpoint import * # pyflakes.ignore +from .openpmd import * # pyflakes.ignore +from .openpmd_sources.source_base import * # pyflakes.ignore diff --git a/test/python/picongpu/quick/pypicongpu/output/auto.py b/test/python/picongpu/quick/pypicongpu/output/auto.py index 68efe7ff62..8ff5d4c359 100644 --- a/test/python/picongpu/quick/pypicongpu/output/auto.py +++ b/test/python/picongpu/quick/pypicongpu/output/auto.py @@ -1,13 +1,12 @@ """ This file is part of PIConGPU. -Copyright 2021-2025 PIConGPU contributors -Authors: Hannes Troepgen, Brian Edward Marre, Julian Lenz +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari License: GPLv3+ """ from picongpu.pypicongpu.output.timestepspec import TimeStepSpec from picongpu.pypicongpu.output import Auto - import unittest import typeguard @@ -28,9 +27,8 @@ def test_rendering(self): """data transformed to template-consumable version""" a = Auto() a.period = TimeStepSpec([slice(0, None, 17)]) - - # normal rendering context = a.get_rendering_context() self.assertTrue(context["typeID"]["auto"]) context = context["data"] self.assertEqual(17, context["period"]["specs"][0]["step"]) + self.assertEqual([{"axis": "yx"}, {"axis": "yz"}], context["png_axis"]) diff --git a/test/python/picongpu/quick/pypicongpu/output/checkpoint.py b/test/python/picongpu/quick/pypicongpu/output/checkpoint.py new file mode 100644 index 0000000000..2df7f3c3ba --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/output/checkpoint.py @@ -0,0 +1,133 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.pypicongpu.output import Checkpoint +from picongpu.pypicongpu.output.timestepspec import TimeStepSpec +import unittest +import typeguard + + +class TestCheckpoint(unittest.TestCase): + def test_instantiation_and_types(self): + """Test instantiation, type safety, and valid serialization.""" + # Valid configuration with period + cp = Checkpoint() + cp.period = TimeStepSpec([slice(0, None, 100)]) + cp.directory = "checkpoints" + cp.file = "checkpoint_%T" + cp.restart = True + cp.tryRestart = False + cp.restartStep = 0 + cp.restartDirectory = "restart" + cp.restartFile = "restart_%T" + cp.restartChunkSize = 1 + cp.restartLoop = 0 + cp.openPMD = {"backend": "bp"} + cp.check() + context = cp.get_rendering_context() + self.assertTrue(context["typeID"]["checkpoint"]) + self.assertEqual(context["data"]["period"]["specs"][0]["step"], 100) + self.assertIsNone(context["data"].get("timePeriod")) + + # Valid configuration with timePeriod + cp = Checkpoint() + cp.timePeriod = 100 + context = cp.get_rendering_context() + self.assertTrue(context["typeID"]["checkpoint"]) + self.assertEqual(context["data"]["timePeriod"], 100) + self.assertIsNone(context["data"].get("period")) + + # Type safety + invalid_types = { + "period": ["string", 1], + "timePeriod": ["string", 1.5], + "directory": [1, []], + "file": [1, []], + "restart": ["string", 1], + "tryRestart": ["string", 1], + "restartStep": ["string", 1.5], + "restartDirectory": [1, []], + "restartFile": [1, []], + "restartChunkSize": ["string", 1.5], + "restartLoop": ["string", 1.5], + "openPMD": ["string", 1], + } + for attr, invalid_values in invalid_types.items(): + for value in invalid_values: + with self.subTest(attr=attr, value=value): + cp = Checkpoint() + with self.assertRaises(typeguard.TypeCheckError): + setattr(cp, attr, value) + + def test_rendering_and_validation(self): + """Test serialization output, validation errors, and edge cases.""" + # Valid full serialization + cp = Checkpoint() + cp.period = TimeStepSpec([slice(0, None, 100)]) + cp.timePeriod = 100 + cp.directory = "checkpoints" + cp.file = "checkpoint_%T" + cp.restart = True + cp.tryRestart = False + cp.restartStep = 0 + cp.restartDirectory = "restart" + cp.restartFile = "restart_%T" + cp.restartChunkSize = 1 + cp.restartLoop = 0 + cp.openPMD = {"backend": "bp"} + context = cp.get_rendering_context() + self.assertTrue(context["typeID"]["checkpoint"]) + context = context["data"] + self.assertEqual(context["period"]["specs"][0]["step"], 100) + self.assertEqual(context["timePeriod"], 100) + self.assertEqual(context["directory"], "checkpoints") + self.assertEqual(context["file"], "checkpoint_%T") + self.assertTrue(context["restart"]) + self.assertFalse(context["tryRestart"]) + self.assertEqual(context["restartStep"], 0) + self.assertEqual(context["restartDirectory"], "restart") + self.assertEqual(context["restartFile"], "restart_%T") + self.assertEqual(context["restartChunkSize"], 1) + self.assertEqual(context["restartLoop"], 0) + self.assertEqual(context["openPMD"], {"backend": "bp"}) + + # Validation errors + cp = Checkpoint() + with self.assertRaisesRegex(ValueError, "At least one of period or timePeriod must be provided"): + cp.get_rendering_context() + + cp = Checkpoint() + cp.timePeriod = -1 + with self.assertRaisesRegex(ValueError, "timePeriod must be non-negative"): + cp.get_rendering_context() + + cp = Checkpoint() + cp.timePeriod = 100 + cp.restartStep = -1 + with self.assertRaisesRegex(ValueError, "restartStep must be non-negative"): + cp.get_rendering_context() + + cp = Checkpoint() + cp.timePeriod = 100 + cp.restartChunkSize = 0 + with self.assertRaisesRegex(ValueError, "restartChunkSize must be positive"): + cp.get_rendering_context() + + cp = Checkpoint() + cp.timePeriod = 100 + cp.restartLoop = -1 + with self.assertRaisesRegex(ValueError, "restartLoop must be non-negative"): + cp.get_rendering_context() + + cp = Checkpoint() + cp.period = TimeStepSpec([slice(0, None, -1)]) + with self.assertRaisesRegex(ValueError, "Step size must be >= 1"): + cp.get_rendering_context() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/pypicongpu/output/energy_histogram.py b/test/python/picongpu/quick/pypicongpu/output/energy_histogram.py new file mode 100644 index 0000000000..28044eb0b5 --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/output/energy_histogram.py @@ -0,0 +1,110 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.pypicongpu.output import EnergyHistogram +from picongpu.pypicongpu.species import Species +from picongpu.pypicongpu.output.timestepspec import TimeStepSpec +from picongpu.pypicongpu.species.attribute import Position, Momentum +import unittest +import typeguard + + +def create_species(): + """Helper function to create a valid Species object.""" + species = Species() + species.name = "electron" + species.attributes = [Position(), Momentum()] + species.constants = [] + return species + + +class TestEnergyHistogram(unittest.TestCase): + def setUp(self): + self.species = create_species() + self.period = TimeStepSpec([slice(0, None, 100)]) + + def test_instantiation_and_types(self): + """Test instantiation, type safety, and valid serialization.""" + # Valid configuration + eh = EnergyHistogram( + species=self.species, + period=self.period, + bin_count=1024, + min_energy=0.0, + max_energy=1000.0, + ) + eh.check() + context = eh.get_rendering_context() + self.assertTrue(context["typeID"]["energyhistogram"]) + self.assertEqual(context["data"]["bin_count"], 1024) + self.assertEqual(context["data"]["min_energy"], 0.0) + self.assertEqual(context["data"]["max_energy"], 1000.0) + self.assertEqual(context["data"]["species"]["name"], "electron") + self.assertEqual(context["data"]["period"]["specs"][0]["step"], 100) + + # Type safety + invalid_types = { + "species": ["string", 1], + "period": ["string", 1], + "bin_count": ["string", 1.0], + "min_energy": ["string", []], + "max_energy": ["string", []], + } + for attr, invalid_values in invalid_types.items(): + for value in invalid_values: + with self.subTest(attr=attr, value=value): + kwargs = { + "species": self.species, + "period": self.period, + "bin_count": 1024, + "min_energy": 0.0, + "max_energy": 1000.0, + } + kwargs[attr] = value + with self.assertRaises(typeguard.TypeCheckError): + EnergyHistogram(**kwargs) + + def test_rendering_and_validation(self): + """Test serialization output, validation errors, and disabled state.""" + # Valid serialization + eh = EnergyHistogram( + species=self.species, + period=self.period, + bin_count=1024, + min_energy=0.0, + max_energy=1000.0, + ) + context = eh.get_rendering_context() + self.assertTrue(context["typeID"]["energyhistogram"]) + self.assertEqual(context["data"]["bin_count"], 1024) + self.assertEqual(context["data"]["min_energy"], 0.0) + self.assertEqual(context["data"]["max_energy"], 1000.0) + + # Validation errors + eh = EnergyHistogram( + species=self.species, + period=self.period, + bin_count=0, + min_energy=0.0, + max_energy=1000.0, + ) + with self.assertRaisesRegex(ValueError, "bin_count must be positive"): + eh.get_rendering_context() + + eh = EnergyHistogram( + species=self.species, + period=self.period, + bin_count=1024, + min_energy=1000.0, + max_energy=0.0, + ) + with self.assertRaisesRegex(ValueError, "min_energy must be less than max_energy"): + eh.get_rendering_context() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/pypicongpu/output/macro_particle_count.py b/test/python/picongpu/quick/pypicongpu/output/macro_particle_count.py new file mode 100644 index 0000000000..75649d0e71 --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/output/macro_particle_count.py @@ -0,0 +1,86 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.pypicongpu.output.timestepspec import TimeStepSpec +from picongpu.pypicongpu.output import MacroParticleCount +from picongpu.pypicongpu.species import Species +from picongpu.pypicongpu.species.attribute import Position, Momentum +import unittest +import typeguard + + +def create_species(): + species = Species() + species.name = "electron" + species.attributes = [Position(), Momentum()] + species.constants = [] + return species + + +class TestMacroParticleCount(unittest.TestCase): + def setUp(self): + self.species = create_species() + + def test_instantiation_and_types(self): + """Test instantiation, type safety, and valid serialization.""" + # Valid case + mpc = MacroParticleCount() + mpc.species = self.species + mpc.period = TimeStepSpec([slice(0, None, 17)]) + mpc.check() + context = mpc.get_rendering_context() # Use public API + self.assertTrue(context["typeID"]["macroparticlecount"]) + self.assertEqual(context["data"]["species"]["name"], "electron") + self.assertEqual(context["data"]["period"]["specs"][0]["step"], 17) + + # Type safety for species + invalid_species = ["string", 1, 1.0, None, {}] + for invalid in invalid_species: + with self.subTest(invalid_species=invalid): + mpc = MacroParticleCount() + with self.assertRaises(typeguard.TypeCheckError): + mpc.species = invalid # Expect error during assignment + + # Type safety for period + invalid_periods = [13.2, [], "2", None, {}] + for invalid in invalid_periods: + with self.subTest(invalid_period=invalid): + mpc = MacroParticleCount() + mpc.species = self.species # Set valid species first + with self.assertRaises(typeguard.TypeCheckError): + mpc.period = invalid # Expect error during assignment + + def test_rendering_and_validation(self): + """Test serialization output, disabled state, and validation errors.""" + # Valid serialization + mpc = MacroParticleCount() + mpc.species = self.species + mpc.period = TimeStepSpec([slice(0, None, 42)]) + context = mpc.get_rendering_context() # Use public API + self.assertTrue(context["typeID"]["macroparticlecount"]) + context = context["data"] + self.assertEqual(42, context["period"]["specs"][0]["step"]) + self.assertEqual(0, context["period"]["specs"][0]["start"]) + self.assertEqual("electron", context["species"]["name"]) + + # Empty period warning + mpc.period = TimeStepSpec([]) + with self.assertWarnsRegex(UserWarning, "MacroParticleCount is disabled"): + mpc.get_rendering_context() # Use public API + + # Validation errors + mpc = MacroParticleCount() + with self.assertRaisesRegex(ValueError, "species must be set"): + mpc.get_rendering_context() # Calls check() internally + + mpc.species = self.species + with self.assertRaisesRegex(ValueError, "period must be set"): + mpc.get_rendering_context() # Calls check() internally + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/pypicongpu/output/openpmd.py b/test/python/picongpu/quick/pypicongpu/output/openpmd.py new file mode 100644 index 0000000000..ba639daac5 --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/output/openpmd.py @@ -0,0 +1,123 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +import unittest +import typeguard +from picongpu.pypicongpu.output import OpenPMD +from picongpu.pypicongpu.species import Species +from picongpu.pypicongpu.species.attribute import Position, Momentum +from picongpu.pypicongpu.output.timestepspec import TimeStepSpec +from picongpu.pypicongpu.output.openpmd_sources import ( + BoundElectronDensity, + EnergyDensityCutoff, + MidCurrentDensityComponent, +) + + +def create_species(): + species = Species() + species.name = "electron" + species.attributes = [Position(), Momentum()] + species.constants = [] + return species + + +class TestOpenPMD(unittest.TestCase): + def setUp(self): + self.species = create_species() + self.period = TimeStepSpec([slice(0, None, 100)]) + + # --------------------------- + # Valid OpenPMD configurations + # --------------------------- + def test_openpmd_valid(self): + """All valid OpenPMD inputs succeed, including minimal config.""" + sources = [ + BoundElectronDensity(species=self.species, filter="species_all"), + EnergyDensityCutoff(species=self.species, filter="species_all", cutoff_max_energy=1.0), + MidCurrentDensityComponent(species=self.species, filter="species_all", direction="x"), + ] + + # --- Full configuration --- + openpmd = OpenPMD( + period=self.period, source=sources, file="output_file", ext="bp", infix="NULL", file_writing="create" + ) + openpmd.check() + context = openpmd._get_serialized() + self.assertEqual(len(context["source"]), 3) + self.assertEqual(context["source"][0]["type"], "boundelectrondensity") + self.assertEqual(context["source"][0]["filter"], "species_all") + self.assertEqual(context["source"][1]["type"], "energydensitycutoff") + self.assertEqual(context["source"][1]["cutoff_max_energy"], 1.0) + self.assertEqual(context["source"][2]["type"], "midcurrentdensitycomponent") + self.assertEqual(context["source"][2]["direction"], "x") + + # --- Minimal configuration --- + minimal = OpenPMD(period=TimeStepSpec([slice(None, None, None)]), source=[], file="output", ext="bp") + context_min = minimal._get_serialized() + self.assertEqual(context_min["source"], []) + self.assertEqual(context_min["file"], "output") + self.assertEqual(context_min["ext"], "bp") + + # --------------------------- + # Invalid argument tests + # --------------------------- + def test_openpmd_invalid_arguments(self): + """Invalid OpenPMD arguments raise proper exceptions.""" + sources = [ + BoundElectronDensity(species=self.species, filter="species_all"), + EnergyDensityCutoff(species=self.species, filter="species_all", cutoff_max_energy=1.0), + MidCurrentDensityComponent(species=self.species, filter="species_all", direction="x"), + ] + + # --- OpenPMD argument type errors --- + invalid_args = { + "period": ["string", 123], + "source": ["string", 123, [123]], + "file": ["", 123], + "ext": ["txt", 123], + "file_writing": ["overwrite", 123], + } + + for arg, values in invalid_args.items(): + for val in values: + with self.subTest(arg=arg, val=val): + kwargs = { + "period": self.period, + "source": sources, + "file": "out", + "ext": "bp", + "file_writing": "create", + } + kwargs[arg] = val + + if arg == "file" and val == "": + # empty string triggers ValueError from check() + with self.assertRaises(ValueError): + OpenPMD(**kwargs) + else: + # everything else triggers TypeGuard type check + with self.assertRaises(typeguard.TypeCheckError): + OpenPMD(**kwargs) + + # --- Source-specific validation errors --- + with self.assertRaisesRegex(ValueError, "must be positive"): + EnergyDensityCutoff(species=self.species, filter="species_all", cutoff_max_energy=0).check() + + with self.assertRaisesRegex(ValueError, "Direction must be"): + MidCurrentDensityComponent(species=self.species, filter="species_all", direction="w").check() + + with self.assertRaisesRegex(ValueError, "Filter must be one of"): + BoundElectronDensity(species=self.species, filter="invalid_filter").check() + + # --- Empty file string triggers ValueError --- + with self.assertRaisesRegex(ValueError, "file must be a non-empty string"): + OpenPMD(period=self.period, source=[], file="", ext="bp").check() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/__init__.py b/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/__init__.py new file mode 100644 index 0000000000..8813782add --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/__init__.py @@ -0,0 +1,8 @@ +# flake8: noqa +from .test_sources_species_filter_cutoff_max_energy import * # pyflakes.ignore +from .test_sources_species_filter import * # pyflakes.ignore +from .test_sources_filter import * # pyflakes.ignore +from .test_sources_species_filter_direction import * # pyflakes.ignore + +# Base class +from .source_base import * # pyflakes.ignore diff --git a/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/source_base.py b/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/source_base.py new file mode 100644 index 0000000000..357a002be1 --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/source_base.py @@ -0,0 +1,83 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.pypicongpu.output import OpenPMD +from picongpu.pypicongpu.output.timestepspec import TimeStepSpec +from picongpu.pypicongpu.output.openpmd_sources import SourceBase, BoundElectronDensity +from picongpu.pypicongpu.species import Species +from picongpu.pypicongpu.species.attribute import Position, Momentum +import unittest +import typeguard + + +def create_species() -> Species: + """Helper function to create a test species.""" + s = Species() + s.name = "electron" + s.attributes = [Position(), Momentum()] + s.constants = [] + return s + + +class TestSourceBase(unittest.TestCase): + def setUp(self): + self.species = create_species() + self.period = TimeStepSpec([slice(0, None, 100)]) + + def test_source_base_abstract(self): + """Test that SourceBase is abstract and BoundElectronDensity implements required methods.""" + with self.assertRaisesRegex(TypeError, "Can't instantiate abstract class"): + SourceBase() + + def test_bound_electron_density_filters(self): + """Test different filter values for BoundElectronDensity.""" + for filter_value in ["custom_filter", "species_all", "fields_all"]: + source = BoundElectronDensity(species=self.species, filter=filter_value) + self.assertEqual(source.filter, filter_value) + source.check() + + # Invalid filter + with self.assertRaisesRegex( + ValueError, r"Filter must be one of \['species_all', 'fields_all', 'custom_filter'\], got invalid" + ): + BoundElectronDensity(species=self.species, filter="invalid").check() + + # Type check for filter + with self.assertRaisesRegex(typeguard.TypeCheckError, r"argument \"filter\" \(int\) is not an instance of str"): + BoundElectronDensity(species=self.species, filter=123) + + # Type check for species + with self.assertRaisesRegex( + typeguard.TypeCheckError, + r"argument \"species\" \(str\) is not an instance of picongpu.pypicongpu.species.species.Species", + ): + BoundElectronDensity(species="invalid") + + def test_openpmd_rendering(self): + """Test OpenPMD rendering with custom and default filters.""" + # Custom filter + openpmd = OpenPMD( + period=self.period, source=[BoundElectronDensity(species=self.species, filter="custom_filter")] + ) + context = openpmd.get_rendering_context() + self.assertTrue(context["typeID"]["openpmd"]) + context_data = context["data"] + self.assertEqual(len(context_data["source"]), 1) + self.assertEqual(context_data["source"][0]["type"], "boundelectrondensity") + self.assertEqual(context_data["source"][0]["filter"], "custom_filter") + self.assertEqual(context_data["source"][0]["species"]["name"], "electron") + + # Default filter + openpmd = OpenPMD(period=self.period, source=[BoundElectronDensity(species=self.species)]) + context_data = openpmd.get_rendering_context()["data"] + self.assertEqual(context_data["source"][0]["type"], "boundelectrondensity") + self.assertEqual(context_data["source"][0]["filter"], "species_all") + self.assertEqual(context_data["source"][0]["species"]["name"], "electron") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/test_sources_filter.py b/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/test_sources_filter.py new file mode 100644 index 0000000000..773ee763e1 --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/test_sources_filter.py @@ -0,0 +1,70 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +import unittest +import typeguard +from picongpu.pypicongpu.output import OpenPMD +from picongpu.pypicongpu.output.timestepspec import TimeStepSpec +from picongpu.pypicongpu.output.openpmd_sources import Auto, DerivedAttributes + +# --------------------------------------------------------------------------- +# Helper function to reduce duplication +# --------------------------------------------------------------------------- + + +def _check_filter_only_source(testcase: unittest.TestCase, source_cls): + """Generic test routine for filter-only sources.""" + # Valid filters + for f in ["species_all", "fields_all", "custom_filter"]: + src = source_cls(filter=f) + testcase.assertEqual(src.filter, f) + src.check() + + # Invalid filter type + with testcase.assertRaisesRegex(typeguard.TypeCheckError, r"argument \"filter\" \(int\) is not an instance of str"): + source_cls(filter=123) + + # Invalid filter value + with testcase.assertRaisesRegex( + ValueError, r"Filter must be one of \['species_all', 'fields_all', 'custom_filter'\], got invalid" + ): + source_cls(filter="invalid") + + # Test OpenPMD serialization + # Custom filter + src = source_cls(filter="custom_filter") + openpmd = OpenPMD(period=TimeStepSpec([slice(0, None, 100)]), source=[src]) + context = openpmd.get_rendering_context() + testcase.assertTrue(context["typeID"]["openpmd"]) + context = context["data"] + testcase.assertEqual(len(context["source"]), 1) + testcase.assertEqual(context["source"][0]["type"], source_cls.__name__.lower()) + testcase.assertEqual(context["source"][0]["filter"], "custom_filter") + + # Default filter + src = source_cls() + openpmd = OpenPMD(period=TimeStepSpec([slice(0, None, 100)]), source=[src]) + context = openpmd.get_rendering_context() + testcase.assertEqual(context["data"]["source"][0]["type"], source_cls.__name__.lower()) + testcase.assertEqual(context["data"]["source"][0]["filter"], "species_all") + + +# --------------------------------------------------------------------------- +# Unit tests +# --------------------------------------------------------------------------- + + +class PICMI_TestFilterOnlySources(unittest.TestCase): + def test_auto(self): + _check_filter_only_source(self, Auto) + + def test_derived_attributes(self): + _check_filter_only_source(self, DerivedAttributes) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/test_sources_species_filter.py b/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/test_sources_species_filter.py new file mode 100644 index 0000000000..866f973d7b --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/test_sources_species_filter.py @@ -0,0 +1,162 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.pypicongpu.output import OpenPMD +from picongpu.pypicongpu.output.timestepspec import TimeStepSpec +from picongpu.pypicongpu.output.openpmd_sources import ( + BoundElectronDensity, + ChargeDensity, + Density, + Energy, + EnergyDensity, + LarmorPower, + MacroCounter, + Counter, +) +from picongpu.pypicongpu.species import Species +from picongpu.pypicongpu.species.attribute import Position, Momentum + +import unittest +import typeguard +import typing + + +class MockSpecies(Species): + def __init__(self): + self.name = "electron" + self.attributes = [Position(), Momentum()] + self.constants = [] + + def get_rendering_context(self) -> typing.Dict: + return { + "name": self.name, + "typename": "Electron", + "attributes": [{"picongpu_name": attr.__class__.__name__.lower()} for attr in self.attributes], + "constants": { + "mass": None, + "charge": None, + "density_ratio": None, + "ground_state_ionization": None, + "element_properties": None, + }, + } + + def check(self) -> None: + pass + + +# --------------------------------------------------------------------------- +# Helper function to reduce duplication +# --------------------------------------------------------------------------- + + +def _check_species_filter_source(testcase: unittest.TestCase, source_cls): + """Generic test routine for (species, filter) sources.""" + # Valid filters + for f in ["species_all", "fields_all", "custom_filter"]: + src = source_cls(species=MockSpecies(), filter=f) + testcase.assertIsInstance(src.species, MockSpecies) + testcase.assertEqual(src.filter, f) + src.check() + + # Invalid filter + with testcase.assertRaisesRegex( + ValueError, r"Filter must be one of \['species_all', 'fields_all', 'custom_filter'\], got invalid" + ): + source_cls(species=MockSpecies(), filter="invalid").check() + + # Wrong filter type + with testcase.assertRaisesRegex(typeguard.TypeCheckError, r"argument \"filter\" \(int\) is not an instance of str"): + source_cls(species=MockSpecies(), filter=123) + + # Wrong species type + with testcase.assertRaisesRegex( + typeguard.TypeCheckError, + r"argument \"species\" \(str\) is not an instance of picongpu.pypicongpu.species.species.Species", + ): + source_cls(species="invalid") + + # OpenPMD serialization + openpmd = OpenPMD( + period=TimeStepSpec([slice(0, None, 100)]), + source=[source_cls(species=MockSpecies(), filter="custom_filter")], + ) + context = openpmd.get_rendering_context() + testcase.assertTrue(context["typeID"]["openpmd"]) + context = context["data"] + testcase.assertEqual(len(context["source"]), 1) + testcase.assertEqual(context["source"][0]["type"], source_cls.__name__.lower()) + testcase.assertEqual(context["source"][0]["filter"], "custom_filter") + testcase.assertEqual(context["source"][0]["species"]["name"], "electron") + testcase.assertEqual(context["source"][0]["species"]["typename"], "Electron") + testcase.assertEqual(len(context["source"][0]["species"]["attributes"]), 2) + testcase.assertEqual( + context["source"][0]["species"]["constants"], + { + "mass": None, + "charge": None, + "density_ratio": None, + "ground_state_ionization": None, + "element_properties": None, + }, + ) + + # Default filter = "species_all" + openpmd = OpenPMD(period=TimeStepSpec([slice(0, None, 100)]), source=[source_cls(species=MockSpecies())]) + context = openpmd.get_rendering_context() + testcase.assertEqual(context["data"]["source"][0]["type"], source_cls.__name__.lower()) + testcase.assertEqual(context["data"]["source"][0]["filter"], "species_all") + testcase.assertEqual(context["data"]["source"][0]["species"]["name"], "electron") + + +# --------------------------------------------------------------------------- +# Test classes for each species+filter source +# --------------------------------------------------------------------------- + + +class TestBoundElectronDensity(unittest.TestCase): + def test_source(self): + _check_species_filter_source(self, BoundElectronDensity) + + +class TestChargeDensity(unittest.TestCase): + def test_source(self): + _check_species_filter_source(self, ChargeDensity) + + +class TestDensity(unittest.TestCase): + def test_source(self): + _check_species_filter_source(self, Density) + + +class TestEnergy(unittest.TestCase): + def test_source(self): + _check_species_filter_source(self, Energy) + + +class TestEnergyDensity(unittest.TestCase): + def test_source(self): + _check_species_filter_source(self, EnergyDensity) + + +class TestLarmorPower(unittest.TestCase): + def test_source(self): + _check_species_filter_source(self, LarmorPower) + + +class TestMacroCounter(unittest.TestCase): + def test_source(self): + _check_species_filter_source(self, MacroCounter) + + +class TestCounter(unittest.TestCase): + def test_source(self): + _check_species_filter_source(self, Counter) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/test_sources_species_filter_cutoff_max_energy.py b/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/test_sources_species_filter_cutoff_max_energy.py new file mode 100644 index 0000000000..8c4cbf1ca6 --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/test_sources_species_filter_cutoff_max_energy.py @@ -0,0 +1,107 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.pypicongpu.output import OpenPMD +from picongpu.pypicongpu.output.timestepspec import TimeStepSpec +from picongpu.pypicongpu.output.openpmd_sources import EnergyDensityCutoff +from picongpu.pypicongpu.species import Species +from picongpu.pypicongpu.species.attribute import Position, Momentum +import unittest +import typeguard +import typing + + +class MockSpecies(Species): + def __init__(self): + self.name = "electron" + self.attributes = [Position(), Momentum()] + self.constants = [] + + def get_rendering_context(self) -> typing.Dict: + return { + "name": self.name, + "typename": "Electron", + "attributes": [{"picongpu_name": attr.__class__.__name__.lower()} for attr in self.attributes], + "constants": { + "mass": None, + "charge": None, + "density_ratio": None, + "ground_state_ionization": None, + "element_properties": None, + }, + } + + def check(self) -> None: + pass + + +# --------------------------------------------------------------------------- +# Helper function +# --------------------------------------------------------------------------- + + +def _check_species_filter_cutoff_source(testcase: unittest.TestCase, source_cls): + """Generic test routine for EnergyDensityCutoff source.""" + filters = ["species_all", "fields_all", "custom_filter"] + cutoff_values = [50.0, 100.0] # example valid cutoffs + + # Test all combinations of valid filters and cutoff_max_energy + for f in filters: + for cutoff in cutoff_values: + src = source_cls(species=MockSpecies(), filter=f, cutoff_max_energy=cutoff) + testcase.assertIsInstance(src.species, MockSpecies) + testcase.assertEqual(src.filter, f) + testcase.assertEqual(src.cutoff_max_energy, cutoff) + src.check() + + # Missing cutoff_max_energy + with testcase.assertRaisesRegex(ValueError, "cutoff_max_energy is required"): + source_cls(species=MockSpecies()) + + # Invalid types + with testcase.assertRaisesRegex(typeguard.TypeCheckError, r"argument \"filter\" \(int\) is not an instance of str"): + source_cls(species=MockSpecies(), filter=123, cutoff_max_energy=1.0) + + with testcase.assertRaisesRegex( + typeguard.TypeCheckError, r"argument \"species\" \(str\) is not an instance of .*Species" + ): + source_cls(species="invalid", cutoff_max_energy=1.0) + + with testcase.assertRaisesRegex( + typeguard.TypeCheckError, r"argument \"cutoff_max_energy\" \(str\) did not match any element in the union" + ): + source_cls(species=MockSpecies(), cutoff_max_energy="100") + + # Negative cutoff + with testcase.assertRaisesRegex(ValueError, r"cutoff_max_energy must be positive, got -10.0"): + source_cls(species=MockSpecies(), cutoff_max_energy=-10.0).check() + + # OpenPMD serialization for one combination + src = source_cls(species=MockSpecies(), filter="custom_filter", cutoff_max_energy=100.0) + openpmd = OpenPMD(period=TimeStepSpec([slice(0, None, 100)]), source=[src]) + context = openpmd.get_rendering_context() + testcase.assertTrue(context["typeID"]["openpmd"]) + context = context["data"] + testcase.assertEqual(len(context["source"]), 1) + testcase.assertEqual(context["source"][0]["type"], source_cls.__name__.lower()) + testcase.assertEqual(context["source"][0]["filter"], "custom_filter") + testcase.assertEqual(context["source"][0]["cutoff_max_energy"], 100.0) + testcase.assertEqual(context["source"][0]["species"]["name"], "electron") + + +# --------------------------------------------------------------------------- +# Unit tests +# --------------------------------------------------------------------------- + + +class PICMI_TestSpeciesFilterCutoff(unittest.TestCase): + def test_energy_density_cutoff(self): + _check_species_filter_cutoff_source(self, EnergyDensityCutoff) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/test_sources_species_filter_direction.py b/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/test_sources_species_filter_direction.py new file mode 100644 index 0000000000..5105e17d71 --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/output/openpmd_sources/test_sources_species_filter_direction.py @@ -0,0 +1,115 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.pypicongpu.output import OpenPMD +from picongpu.pypicongpu.output.timestepspec import TimeStepSpec +from picongpu.pypicongpu.output.openpmd_sources import ( + MidCurrentDensityComponent, + Momentum, + MomentumDensity, + WeightedVelocity, +) +from picongpu.pypicongpu.species import Species +from picongpu.pypicongpu.species.attribute import Position, Momentum as MomentumAttr +import unittest +import typeguard +import typing + + +class MockSpecies(Species): + def __init__(self): + self.name = "electron" + self.attributes = [Position(), MomentumAttr()] + self.constants = [] + + def get_rendering_context(self) -> typing.Dict: + return { + "name": self.name, + "typename": "Electron", + "attributes": [{"picongpu_name": attr.__class__.__name__.lower()} for attr in self.attributes], + "constants": { + "mass": None, + "charge": None, + "density_ratio": None, + "ground_state_ionization": None, + "element_properties": None, + }, + } + + def check(self) -> None: + pass + + +# --------------------------------------------------------------------------- +# Helper function +# --------------------------------------------------------------------------- + + +def _check_species_filter_direction_source(testcase: unittest.TestCase, source_cls): + """Generic test routine for (species, filter, direction) sources.""" + directions = ["x", "y", "z"] + filters = ["species_all", "fields_all", "custom_filter"] + + # Test all combinations of valid filters and directions + for f in filters: + for d in directions: + src = source_cls(species=MockSpecies(), filter=f, direction=d) + testcase.assertIsInstance(src.species, MockSpecies) + testcase.assertEqual(src.filter, f) + testcase.assertEqual(src.direction, d) + src.check() + + # Invalid direction + with testcase.assertRaisesRegex(ValueError, r"Direction must be 'x', 'y', or 'z', got invalid"): + source_cls(species=MockSpecies(), direction="invalid") + + # Invalid type for direction + with testcase.assertRaisesRegex( + typeguard.TypeCheckError, r"argument \"direction\" \(int\) is not an instance of str" + ): + source_cls(species=MockSpecies(), direction=123) + + # Invalid species type + with testcase.assertRaisesRegex( + typeguard.TypeCheckError, r"argument \"species\" \(str\) is not an instance of .*Species" + ): + source_cls(species="invalid", direction="x") + + # OpenPMD serialization + src = source_cls(species=MockSpecies(), filter="custom_filter", direction="y") + openpmd = OpenPMD(period=TimeStepSpec([slice(0, None, 100)]), source=[src]) + context = openpmd.get_rendering_context() + testcase.assertTrue(context["typeID"]["openpmd"]) + context = context["data"] + testcase.assertEqual(len(context["source"]), 1) + testcase.assertEqual(context["source"][0]["type"], source_cls.__name__.lower()) + testcase.assertEqual(context["source"][0]["filter"], "custom_filter") + testcase.assertEqual(context["source"][0]["direction"], "y") + testcase.assertEqual(context["source"][0]["species"]["name"], "electron") + + +# --------------------------------------------------------------------------- +# Unit tests +# --------------------------------------------------------------------------- + + +class PICMI_TestSpeciesFilterDirection(unittest.TestCase): + def test_mid_current_density_component(self): + _check_species_filter_direction_source(self, MidCurrentDensityComponent) + + def test_momentum(self): + _check_species_filter_direction_source(self, Momentum) + + def test_momentum_density(self): + _check_species_filter_direction_source(self, MomentumDensity) + + def test_weighted_velocity(self): + _check_species_filter_direction_source(self, WeightedVelocity) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/pypicongpu/output/phase_space.py b/test/python/picongpu/quick/pypicongpu/output/phase_space.py index a4b0461609..5eff319b31 100644 --- a/test/python/picongpu/quick/pypicongpu/output/phase_space.py +++ b/test/python/picongpu/quick/pypicongpu/output/phase_space.py @@ -1,15 +1,14 @@ """ This file is part of PIConGPU. Copyright 2025 PIConGPU contributors -Authors: Julian Lenz +Authors: Julian Lenz, Masoud Afshari License: GPLv3+ """ -from picongpu.pypicongpu.output.timestepspec import TimeStepSpec from picongpu.pypicongpu.output import PhaseSpace +from picongpu.pypicongpu.output.timestepspec import TimeStepSpec from picongpu.pypicongpu.species import Species from picongpu.pypicongpu.species.attribute import Position, Momentum - import unittest import typeguard @@ -23,105 +22,85 @@ def create_species(): class TestPhaseSpace(unittest.TestCase): - def test_empty(self): - """empty args handled correctly""" - ps = PhaseSpace() - # unset args - with self.assertRaises(Exception): - ps._get_serialized() - - ps.species = create_species() - ps.period = TimeStepSpec([slice(0, None, 17)]) - ps.spatial_coordinate = "x" - ps.momentum_coordinate = "px" - ps.min_momentum = 0.0 - ps.max_momentum = 1.0 - - # ok: - ps._get_serialized() + def setUp(self): + self.species = create_species() + self.period = TimeStepSpec([slice(0, None, 17)]) - def test_types(self): - """type safety is ensured""" + def test_instantiation_and_types(self): + """Test instantiation, type safety, and valid serialization.""" + # Valid configuration ps = PhaseSpace() - - invalid_species = ["string", 1, 1.0, None, {}] - for invalid_species_ in invalid_species: - with self.assertRaises(typeguard.TypeCheckError): - ps.species = invalid_species_ - - invalid_periods = [13.2, [], "2", None, {}] - for invalid_period in invalid_periods: - with self.assertRaises(typeguard.TypeCheckError): - ps.period = invalid_period - - invalid_spatial_coordinates = ["a", "b", "c", (1,), None, {}] - for invalid_spatial_coordinate in invalid_spatial_coordinates: - with self.assertRaises(typeguard.TypeCheckError): - ps.spatial_coordinate = invalid_spatial_coordinate - - invalid_momentum_coordinates = ["a", "b", "c", (1,), None, {}] - for invalid_momentum_coordinate in invalid_momentum_coordinates: - with self.assertRaises(typeguard.TypeCheckError): - ps.momentum_coordinate = invalid_momentum_coordinate - - invalid_min_momentum = ["string", (1,), None, {}] - for invalid_min_momentum_ in invalid_min_momentum: - with self.assertRaises(typeguard.TypeCheckError): - ps.min_momentum = invalid_min_momentum_ - - invalid_max_momentum = ["string", (1,), None, {}] - for invalid_max_momentum_ in invalid_max_momentum: - with self.assertRaises(typeguard.TypeCheckError): - ps.max_momentum = invalid_max_momentum_ - - # ok - ps.species = create_species() - ps.period = TimeStepSpec([slice(0, None, 17)]) + ps.species = self.species + ps.period = self.period ps.spatial_coordinate = "x" ps.momentum_coordinate = "px" ps.min_momentum = 0.0 ps.max_momentum = 1.0 - - def test_rendering(self): - """data transformed to template-consumable version""" + ps.check() + context = ps.get_rendering_context() + self.assertTrue(context["typeID"]["phasespace"]) + self.assertEqual(context["data"]["species"]["name"], "electron") + self.assertEqual(context["data"]["period"]["specs"][0]["step"], 17) + self.assertEqual(context["data"]["spatial_coordinate"], "x") + self.assertEqual(context["data"]["momentum_coordinate"], "px") + self.assertEqual(context["data"]["min_momentum"], 0.0) + self.assertEqual(context["data"]["max_momentum"], 1.0) + + # Type safety + invalid_types = { + "species": ["string", 1], + "period": ["string", 1], + "spatial_coordinate": ["a", 1], + "momentum_coordinate": ["b", 1], + "min_momentum": ["string", []], + "max_momentum": ["string", []], + } + for attr, invalid_values in invalid_types.items(): + for value in invalid_values: + with self.subTest(attr=attr, value=value): + ps = PhaseSpace() + with self.assertRaises(typeguard.TypeCheckError): + setattr(ps, attr, value) + + def test_rendering_and_validation(self): + """Test serialization output, validation errors, and disabled state.""" + # Valid serialization ps = PhaseSpace() - ps.species = create_species() + ps.species = self.species ps.period = TimeStepSpec([slice(0, None, 42)]) - ps.spatial_coordinate = "x" - ps.momentum_coordinate = "px" + ps.spatial_coordinate = "z" + ps.momentum_coordinate = "pz" ps.min_momentum = 0.0 - ps.max_momentum = 1.0 - - # normal rendering + ps.max_momentum = 2.0 context = ps.get_rendering_context() self.assertTrue(context["typeID"]["phasespace"]) - context = context["data"] - self.assertEqual(42, context["period"]["specs"][0]["step"]) - self.assertEqual("x", context["spatial_coordinate"]) - self.assertEqual("px", context["momentum_coordinate"]) - self.assertEqual(0.0, context["min_momentum"]) - self.assertEqual(1.0, context["max_momentum"]) - - # refuses to render if attributes are not set - ps = PhaseSpace() - with self.assertRaises(Exception): + self.assertEqual(context["data"]["period"]["specs"][0]["step"], 42) + self.assertEqual(context["data"]["spatial_coordinate"], "z") + self.assertEqual(context["data"]["momentum_coordinate"], "pz") + self.assertEqual(context["data"]["min_momentum"], 0.0) + self.assertEqual(context["data"]["max_momentum"], 2.0) + + # Empty period warning + ps.period = TimeStepSpec([]) + with self.assertWarnsRegex(UserWarning, "PhaseSpace is disabled"): ps.get_rendering_context() - def test_momentum_values(self): - """min_momentum and max_momentum values are valid""" + # Validation error ps = PhaseSpace() - ps.species = create_species() - ps.period = TimeStepSpec([slice(0, None, 1)]) + ps.species = self.species + ps.period = self.period ps.spatial_coordinate = "x" ps.momentum_coordinate = "px" - - # Min is larger than max, that's not allowed ps.min_momentum = 2.0 ps.max_momentum = 1.0 + with self.assertRaisesRegex(ValueError, "min_momentum should be smaller than max_momentum"): + ps.get_rendering_context() - with self.assertRaises(ValueError): - ps.check() + # Invalid attributes (low-level check) + ps = PhaseSpace() + with self.assertRaises(Exception): + ps._get_serialized() - # get_rendering_context calls check internally, so this should also fail: - with self.assertRaises(ValueError): - ps.get_rendering_context() + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/pypicongpu/output/png.py b/test/python/picongpu/quick/pypicongpu/output/png.py new file mode 100644 index 0000000000..a50fef9b95 --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/output/png.py @@ -0,0 +1,326 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.pypicongpu.output.png import Png, EMFieldScaleEnum, ColorScaleEnum +from picongpu.pypicongpu.species import Species +from picongpu.pypicongpu.output.timestepspec import TimeStepSpec +from picongpu.pypicongpu.species.attribute import Position, Momentum +import unittest +import typeguard + + +def create_species(): + """Helper function to create a valid Species object.""" + species = Species() + species.name = "electron" + species.attributes = [Position(), Momentum()] + species.constants = [] + return species + + +class TestPng(unittest.TestCase): + def setUp(self): + """Set up common test fixtures.""" + self.species = create_species() + self.period = TimeStepSpec([slice(0, None, 100)]) + self.valid_png = Png( + species=self.species, + period=self.period, + axis="xy", + slicePoint=0.5, + folder="output", + scale_image=0.5, + scale_to_cellsize=True, + white_box_per_GPU=False, + EM_FIELD_SCALE_CHANNEL1=EMFieldScaleEnum.AUTO, + EM_FIELD_SCALE_CHANNEL2=EMFieldScaleEnum.PLASMA_WAVE, + EM_FIELD_SCALE_CHANNEL3=EMFieldScaleEnum.CUSTOM, + preParticleDensCol=ColorScaleEnum.RED, + preChannel1Col=ColorScaleEnum.GREEN, + preChannel2Col=ColorScaleEnum.BLUE, + preChannel3Col=ColorScaleEnum.GRAY, + customNormalizationSI=[1.0, 2.0, 3.0], + preParticleDens_opacity=0.5, + preChannel1_opacity=0.6, + preChannel2_opacity=0.7, + preChannel3_opacity=0.8, + preChannel1="E_x", + preChannel2="E_y", + preChannel3="E_z", + ) + + def test_instantiation_and_types(self): + """Test instantiation, type safety, and enum mapping.""" + # Valid configuration + self.valid_png.check() + serialized = self.valid_png._get_serialized() + self.assertEqual(serialized["axis"], "xy") + self.assertEqual(serialized["slicePoint"], 0.5) + self.assertEqual(serialized["folder"], "output") + + # Type safety + invalid_types = { + "species": ["string", 1, 1.0, {}], + "period": [13.2, [], "2", {}], + "axis": [1, 1.0, {}, []], + "slicePoint": ["string", {}, []], + "folder": [1, 1.0, {}], + "scale_image": ["string", {}, []], + "scale_to_cellsize": ["string", 1.0, {}], + "white_box_per_GPU": ["string", 1.0, {}], + "EM_FIELD_SCALE_CHANNEL1": ["string", 1, 1.0, {}], + "preParticleDensCol": ["invalid", 1, 1.0, {}], + "customNormalizationSI": ["string", 1, 1.0, {}], + "preParticleDens_opacity": ["string", {}, []], + "preChannel1": [1, 1.0, {}, None], + } + for attr, invalid_values in invalid_types.items(): + for invalid in invalid_values: + with self.subTest(attr=attr, value=invalid): + kwargs = { + "species": self.species, + "period": self.period, + "axis": "xy", + "slicePoint": 0.5, + "folder": "output", + "scale_image": 0.5, + "scale_to_cellsize": True, + "white_box_per_GPU": False, + "EM_FIELD_SCALE_CHANNEL1": EMFieldScaleEnum.AUTO, + "EM_FIELD_SCALE_CHANNEL2": EMFieldScaleEnum.PLASMA_WAVE, + "EM_FIELD_SCALE_CHANNEL3": EMFieldScaleEnum.CUSTOM, + "preParticleDensCol": ColorScaleEnum.RED, + "preChannel1Col": ColorScaleEnum.GREEN, + "preChannel2Col": ColorScaleEnum.BLUE, + "preChannel3Col": ColorScaleEnum.GRAY, + "customNormalizationSI": [1.0, 2.0, 3.0], + "preParticleDens_opacity": 0.5, + "preChannel1_opacity": 0.6, + "preChannel2_opacity": 0.7, + "preChannel3_opacity": 0.8, + "preChannel1": "E_x", + "preChannel2": "E_y", + "preChannel3": "E_z", + } + kwargs[attr] = invalid + with self.assertRaises((typeguard.TypeCheckError, ValueError)): + Png(**kwargs) + + # Enum string mapping + png = Png( + species=self.species, + period=self.period, + axis="xy", + slicePoint=0.5, + folder="output", + scale_image=0.5, + scale_to_cellsize=True, + white_box_per_GPU=False, + EM_FIELD_SCALE_CHANNEL1=EMFieldScaleEnum(-1), # Maps to AUTO + EM_FIELD_SCALE_CHANNEL2=EMFieldScaleEnum.PLASMA_WAVE, + EM_FIELD_SCALE_CHANNEL3=EMFieldScaleEnum.CUSTOM, + preParticleDensCol=ColorScaleEnum("red"), # Maps to RED + preChannel1Col=ColorScaleEnum.GREEN, + preChannel2Col=ColorScaleEnum.BLUE, + preChannel3Col=ColorScaleEnum.GRAY, + customNormalizationSI=[1.0, 2.0, 3.0], + preParticleDens_opacity=0.5, + preChannel1_opacity=0.6, + preChannel2_opacity=0.7, + preChannel3_opacity=0.8, + preChannel1="E_x", + preChannel2="E_y", + preChannel3="E_z", + ) + self.assertEqual(png.EM_FIELD_SCALE_CHANNEL1, EMFieldScaleEnum.AUTO) + self.assertEqual(png.preParticleDensCol, ColorScaleEnum.RED) + + def test_validation_and_rendering(self): + """Test validation constraints and serialization.""" + context = self.valid_png.get_rendering_context() + self.assertTrue(context["typeID"]["png"]) + context = context["data"] + self.assertEqual(context["period"]["specs"][0]["step"], 100) + self.assertEqual(context["axis"], "xy") + self.assertEqual(context["slicePoint"], 0.5) + self.assertEqual(context["folder"], "output") + self.assertEqual(context["scale_image"], 0.5) + self.assertEqual(context["scale_to_cellsize"], True) + self.assertEqual(context["white_box_per_GPU"], False) + self.assertEqual(context["EM_FIELD_SCALE_CHANNEL1"], -1) + self.assertEqual(context["preParticleDensCol"], "red") + self.assertEqual(context["customNormalizationSI"], [{"value": 1.0}, {"value": 2.0}, {"value": 3.0}]) + self.assertEqual(context["preChannel1"], "E_x") + + invalid_configs = [ + ({"axis": "xx"}, "axis must be 'xy', 'yx', 'xz', 'zx', 'yz', or 'zy'"), + ({"slicePoint": 1.5}, "slicePoint must be in"), + ({"scale_image": 0.0}, "scale_image must be positive"), + ( + {"scale_image": 1.0, "scale_to_cellsize": True}, + "scale_image must not be 1.0 when scale_to_cellsize is True", + ), + ({"preParticleDens_opacity": 1.5}, "preParticleDens_opacity must be in"), + ({"preChannel1": ""}, "preChannel1 must be a non-empty string"), + ({"customNormalizationSI": [1.0, 2.0]}, "customNormalizationSI must contain exactly 3 floats"), + ({"customNormalizationSI": [1.0, "2.0", 3.0]}, "customNormalizationSI values must be floats"), + ] + for invalid_config, error_msg in invalid_configs: + with self.subTest(config=invalid_config): + kwargs = { + "species": self.species, + "period": self.period, + "axis": "xy", + "slicePoint": 0.5, + "folder": "output", + "scale_image": 0.5, + "scale_to_cellsize": True, + "white_box_per_GPU": False, + "EM_FIELD_SCALE_CHANNEL1": EMFieldScaleEnum.AUTO, + "EM_FIELD_SCALE_CHANNEL2": EMFieldScaleEnum.PLASMA_WAVE, + "EM_FIELD_SCALE_CHANNEL3": EMFieldScaleEnum.CUSTOM, + "preParticleDensCol": ColorScaleEnum.RED, + "preChannel1Col": ColorScaleEnum.GREEN, + "preChannel2Col": ColorScaleEnum.BLUE, + "preChannel3Col": ColorScaleEnum.GRAY, + "customNormalizationSI": [1.0, 2.0, 3.0], + "preParticleDens_opacity": 0.5, + "preChannel1_opacity": 0.6, + "preChannel2_opacity": 0.7, + "preChannel3_opacity": 0.8, + "preChannel1": "E_x", + "preChannel2": "E_y", + "preChannel3": "E_z", + } + kwargs.update(invalid_config) + png = Png(**kwargs) + with self.assertRaisesRegex(ValueError, error_msg): + png.check() + + class TestPng(Png): + def __init__(self): + pass + + invalid_png = TestPng() + with self.assertRaisesRegex(ValueError, "species must be set"): + invalid_png.check() + + invalid_png = TestPng() + invalid_png.species = self.species + with self.assertRaisesRegex(ValueError, "period must be set"): + invalid_png.check() + + class TestPngEMField(Png): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.__dict__["_EM_FIELD_SCALE_CHANNEL1"] = None + + def getter(_): + return None + + self.__class__.EM_FIELD_SCALE_CHANNEL1 = property(getter) + + png = TestPngEMField( + **{ + "species": self.species, + "period": self.period, + "axis": "xy", + "slicePoint": 0.5, + "folder": "output", + "scale_image": 0.5, + "scale_to_cellsize": True, + "white_box_per_GPU": False, + "EM_FIELD_SCALE_CHANNEL1": EMFieldScaleEnum.AUTO, + "EM_FIELD_SCALE_CHANNEL2": EMFieldScaleEnum.PLASMA_WAVE, + "EM_FIELD_SCALE_CHANNEL3": EMFieldScaleEnum.CUSTOM, + "preParticleDensCol": ColorScaleEnum.RED, + "preChannel1Col": ColorScaleEnum.GREEN, + "preChannel2Col": ColorScaleEnum.BLUE, + "preChannel3Col": ColorScaleEnum.GRAY, + "customNormalizationSI": [1.0, 2.0, 3.0], + "preParticleDens_opacity": 0.5, + "preChannel1_opacity": 0.6, + "preChannel2_opacity": 0.7, + "preChannel3_opacity": 0.8, + "preChannel1": "E_x", + "preChannel2": "E_y", + "preChannel3": "E_z", + } + ) + with self.assertRaisesRegex(ValueError, "EM_FIELD_SCALE_CHANNEL1 must be in"): + png.check() + + class TestPngColorScale(Png): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.__dict__["_preParticleDensCol"] = None + + def getter(_): + return None + + self.__class__.preParticleDensCol = property(getter) + + png = TestPngColorScale( + **{ + "species": self.species, + "period": self.period, + "axis": "xy", + "slicePoint": 0.5, + "folder": "output", + "scale_image": 0.5, + "scale_to_cellsize": True, + "white_box_per_GPU": False, + "EM_FIELD_SCALE_CHANNEL1": EMFieldScaleEnum.AUTO, + "EM_FIELD_SCALE_CHANNEL2": EMFieldScaleEnum.PLASMA_WAVE, + "EM_FIELD_SCALE_CHANNEL3": EMFieldScaleEnum.CUSTOM, + "preParticleDensCol": ColorScaleEnum.RED, + "preChannel1Col": ColorScaleEnum.GREEN, + "preChannel2Col": ColorScaleEnum.BLUE, + "preChannel3Col": ColorScaleEnum.GRAY, + "customNormalizationSI": [1.0, 2.0, 3.0], + "preParticleDens_opacity": 0.5, + "preChannel1_opacity": 0.6, + "preChannel2_opacity": 0.7, + "preChannel3_opacity": 0.8, + "preChannel1": "E_x", + "preChannel2": "E_y", + "preChannel3": "E_z", + } + ) + with self.assertRaisesRegex(ValueError, "preParticleDensCol must be in"): + png.check() + + png = Png( + species=self.species, + period=self.period, + axis="xy", + slicePoint=0.5, + folder="output", + scale_image=0.5, + scale_to_cellsize=True, + white_box_per_GPU=False, + EM_FIELD_SCALE_CHANNEL1=EMFieldScaleEnum.AUTO, + EM_FIELD_SCALE_CHANNEL2=EMFieldScaleEnum.PLASMA_WAVE, + EM_FIELD_SCALE_CHANNEL3=EMFieldScaleEnum.CUSTOM, + preParticleDensCol=ColorScaleEnum.RED, + preChannel1Col=ColorScaleEnum.GREEN, + preChannel2Col=ColorScaleEnum.BLUE, + preChannel3Col=ColorScaleEnum.GRAY, + customNormalizationSI=[1.0, 2.0, 3.0], + preParticleDens_opacity=0.5, + preChannel1_opacity=0.6, + preChannel2_opacity=0.7, + preChannel3_opacity=0.8, + preChannel1="field_E.x()", + preChannel2="field_E.y() * field_E.y()", + preChannel3="-1.0_X * field_B.z()", + ) + png.check() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/pypicongpu/output/rangespec.py b/test/python/picongpu/quick/pypicongpu/output/rangespec.py new file mode 100644 index 0000000000..dd889c8e8d --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/output/rangespec.py @@ -0,0 +1,66 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.pypicongpu.output.rangespec import RangeSpec as PyPIConGPURangeSpec +import unittest + + +class TestRangeSpec(unittest.TestCase): + def test_instantiation_and_types(self): + """Test instantiation, type safety, and valid serialization.""" + # Valid configurations + rs = PyPIConGPURangeSpec[0:10] + self.assertEqual(rs.ranges, [slice(0, 10, None)]) + context = rs.get_rendering_context() + self.assertEqual(context["ranges"], [{"begin": 0, "end": 10}]) + + rs = PyPIConGPURangeSpec[0:10, 5:15] + self.assertEqual(rs.ranges, [slice(0, 10, None), slice(5, 15, None)]) + context = rs.get_rendering_context() + self.assertEqual(context["ranges"], [{"begin": 0, "end": 10}, {"begin": 5, "end": 15}]) + + rs = PyPIConGPURangeSpec[:, :, :] + self.assertEqual(rs.ranges, [slice(None, None, None), slice(None, None, None), slice(None, None, None)]) + context = rs.get_rendering_context() + self.assertEqual(context["ranges"], [{"begin": 0, "end": -1}, {"begin": 0, "end": -1}, {"begin": 0, "end": -1}]) + + # Type safety + invalid_inputs = ["string", 1] + for invalid in invalid_inputs: + with self.subTest(invalid=invalid): + with self.assertRaises(TypeError): + PyPIConGPURangeSpec[invalid] + + invalid_endpoints = [slice(0.0, 10), slice(0, "b")] + for invalid in invalid_endpoints: + with self.subTest(invalid=invalid): + with self.assertRaises(TypeError): + PyPIConGPURangeSpec[invalid] + + def test_rendering_and_validation(self): + """Test serialization output and validation errors.""" + # Valid serialization + rs = PyPIConGPURangeSpec[0:10, 5:15, 2:8] + context = rs.get_rendering_context() + self.assertEqual(context["ranges"], [{"begin": 0, "end": 10}, {"begin": 5, "end": 15}, {"begin": 2, "end": 8}]) + + # Validation errors + with self.assertRaisesRegex(ValueError, "RangeSpec must have at most 3 ranges"): + PyPIConGPURangeSpec[0:10, 0:10, 0:10, 0:10] + + with self.assertRaisesRegex(ValueError, "RangeSpec must have at least one range"): + PyPIConGPURangeSpec() + + with self.assertRaisesRegex(ValueError, "Step must be None"): + PyPIConGPURangeSpec[0:10:2] + + with self.assertRaises(TypeError): + PyPIConGPURangeSpec[slice(0, 10.0)] + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/pypicongpu/output/timestepspec.py b/test/python/picongpu/quick/pypicongpu/output/timestepspec.py index 8e2ad1b324..67404ff6bd 100644 --- a/test/python/picongpu/quick/pypicongpu/output/timestepspec.py +++ b/test/python/picongpu/quick/pypicongpu/output/timestepspec.py @@ -5,8 +5,8 @@ License: GPLv3+ """ -from picongpu.pypicongpu.output import TimeStepSpec import unittest +from picongpu.pypicongpu.output import TimeStepSpec class TestTimeStepSpec(unittest.TestCase):