diff --git a/docs/source/usage/picmi/intro.rst b/docs/source/usage/picmi/intro.rst index 204f4a3aea..548978c5d0 100644 --- a/docs/source/usage/picmi/intro.rst +++ b/docs/source/usage/picmi/intro.rst @@ -48,16 +48,26 @@ After you have installed the dependencies you must include the PIConGPU PICMI im .. note:: Above, we used ``$PICSRC`` as a short hand for the path to picongpu's source code directory, provided from your shell environment if a pre-configured profile is used. -After you have installed all PICMI dependencies, simply create a user script, see :ref:`here `, and generate a picongpu setup, see :ref:`generating a PIConGPU setup with PICMI `. +After you have installed all PICMI dependencies, simply create a user script, see the :ref:`warm plasma ` and :ref:`laser wakefield ` examples, and generate a picongpu setup, see :ref:`generating a PIConGPU setup with PICMI `. Example User Script for a warm plasma setup: -------------------------------------- -.. _example_PICMI_setup: +.. _example_PICMI_setup_warm_plasma: .. literalinclude:: ../../../../share/picongpu/pypicongpu/examples/warm_plasma/main.py :language: python -Creates a directory ``generated_input``, where you can run ``pic-build`` and subsequently ``tbg``. +Creates a directory ``warm_plasma``, where you can run ``pic-build`` and subsequently ``tbg``. +You can find this and more elaborate examples in `share/picongpu/pypicongpu/examples`. + +Example User Script for a laser wakefield setup: +-------------------------------------- +.. _example_PICMI_setup_lwfa: + +.. literalinclude:: ../../../../share/picongpu/pypicongpu/examples/laser_wakefield/main.py + :language: python + +Creates a directory ``LWFA``, where you can run ``pic-build`` and subsequently ``tbg``. Generation of PIConGPU setups with PICMI ---------------------------------------- @@ -113,21 +123,49 @@ Parameters/Methods prefixed with ``picongpu_`` are PIConGPU-exclusive. - **Simulation** - - ``__init__(..., picongpu_template_dir)``: - Specify the template dir to use for code generation, + not supported methods: + + - ``add_interaction(self, interaction)``: + The PIConGPU PICMI interface does not support the PICMI interaction specification, due to PICMI standard ambiguities. + Instead you must use the PIConGPU specific ``Interaction`` interface described below. + + additional constructor/configuration options: + + - ``picongpu_template_dir``: + Specify the template directory to use for code generation, please refer to :ref:`the documentation on the matter for details ` - - ``__init__(..., picongpu_typical_ppc)`` typical ppc to be used for normalization in PIConGPU + - ``picongpu_typical_ppc``: + typical particle per cell(ppc) to be used for normalization in PIConGPU, if not set explicitly, PIConGPU will use the median ppc of all defined species + - ``picongpu_moving_window_move_point``: + portion of the simulation window a light ray reaches from the time of the start of the simulation until the simulation window begins to move. + + .. warning:: + + If the moving window is active, one gpu row in y direction is reserved for initializing new spaces, thereby reducing the simulation window size accordingly + + - ``picongpu_moving_window_stop_iteration``: + iteration at which to stop moving the simulation window + - ``picongpu_interaction``: + ``Interaction`` object specifying all interactions of the simulation, i.e. all ionization models and their configurations and so on. + This replaces the PICMI ``add_interaction`` method. + + additional method arguments: + - ``write_input_file(..., pypicongpu_simulation)``: use a :ref:`PyPIConGPU simulation` object instead of an PICMI- simulation object to generate a PIConGPU input. + + additional methods: + - ``get_as_pypicongpu()``: convert the PICMI simulation object to an equivalent :ref:`PyPIConGPU ` simulation object. - ``picongpu_get_runner()``: - Retrieve a :ref:`PyPIConGPU Runner ` for running a PIConGPU simulation from Python, **not recommended** + Retrieve a :ref:`PyPIConGPU Runner ` for running a PIConGPU simulation from Python, **not recommended, see :ref:`PICMI setup generation `**. - ``picongpu_add_custom_user_input()``: pass custom user input to the code generation. This may be used in conjunction with custom templates to change the code generation. See :ref:`PICMI custom code generation` for the documentation on using custom input. + - **Grid** - ``picongpu_n_gpus``: @@ -150,17 +188,28 @@ Parameters/Methods prefixed with ``picongpu_`` are PIConGPU-exclusive. - **Species** - - ``picongpu_ionization_electrons``: - Electron species to use for ionization. - Optional, will be guessed if possible. - - ``picongpu_fully_ionized``: - When defining an element (using ``particle_type``) it may or may not be ionizable + - ``picongpu_fixed_charge``: + When defining an ion species using ``particle_type`` it may or may not be ionizable + + - to **enable** ionization add an ionization model to the Interaction object of the simulation and set the initial charge state using ``charge_state``. + - to **disable** ionization set ``picongpu_fixed_charge=True``, this will fix the charge of particles of this species for entire simulation. - - to **enable** ionization simulation set ``charge_state`` to an integer - - to **disable** ionization (ions are only core without electrons) set ``picongpu_fully_ionized=True`` + ``picongpu_fixed_charge`` maybe combined with ``charge_state`` to control which charge state is to used for the ion species If neither is set a warning is printed prompting for either of the options above. +Ionization: +^^^^^^^^^^^ +The PIConGPU PICMI interface currently supports the configuration of ionization only through a picongpu specific PICMI extension, not the in the PICMI standard defined interface, due to the lack of standardization of ionization algorithm names in the PICMI standard. + +Use the **Interaction** interface + + +- **Interaction** + picongpu specific configuration of PIC-algorithm extensions. + + - ``__init__(ground_state_ionizaion_model_list= )`` + Output ^^^^^^ Output is currently **not configurable** for picongpu using the PICMI interface. diff --git a/lib/python/picongpu/picmi/__init__.py b/lib/python/picongpu/picmi/__init__.py index 62f5e813c3..3873f2d73f 100644 --- a/lib/python/picongpu/picmi/__init__.py +++ b/lib/python/picongpu/picmi/__init__.py @@ -1,7 +1,6 @@ """ PICMI for PIConGPU """ - from .simulation import Simulation from .grid import Cartesian3DGrid from .solver import ElectromagneticSolver @@ -10,9 +9,10 @@ from .layout import PseudoRandomLayout from . import constants -from .distribution import FoilDistribution -from .distribution import UniformDistribution -from .distribution import GaussianDistribution +from .distribution import FoilDistribution, UniformDistribution, GaussianDistribution +from .interaction import Interaction +from .interaction.ionization.fieldionization import ADK, ADKVariant, BSI, BSIExtension, Keldysh +from .interaction.ionization.electroniccollisionalequilibrium import ThomasFermi import picmistandard @@ -27,12 +27,20 @@ "GaussianLaser", "Species", "PseudoRandomLayout", + "constants", "FoilDistribution", "UniformDistribution", "GaussianDistribution", - "constants", + "ADK", + "ADKVariant", + "BSI", + "BSIExtension", + "Keldysh", + "ThomasFermi", + "Interaction", ] + codename = "picongpu" """ name of this PICMI implementation diff --git a/lib/python/picongpu/picmi/distribution/Distribution.py b/lib/python/picongpu/picmi/distribution/Distribution.py index cc4d096ad6..46459619de 100644 --- a/lib/python/picongpu/picmi/distribution/Distribution.py +++ b/lib/python/picongpu/picmi/distribution/Distribution.py @@ -9,6 +9,7 @@ import typing import pydantic +import typeguard """ note on rms_velocity: @@ -32,6 +33,7 @@ """ +@typeguard.typechecked class Distribution(pydantic.BaseModel): rms_velocity: typing.Tuple[float, float, float] = (0, 0, 0) """thermal velocity spread [m/s]""" diff --git a/lib/python/picongpu/picmi/distribution/FoilDistribution.py b/lib/python/picongpu/picmi/distribution/FoilDistribution.py index 1a8ece0260..4951142a0a 100644 --- a/lib/python/picongpu/picmi/distribution/FoilDistribution.py +++ b/lib/python/picongpu/picmi/distribution/FoilDistribution.py @@ -42,8 +42,8 @@ def picongpu_get_rms_velocity_si(self) -> typing.Tuple[float, float, float]: def get_as_pypicongpu(self) -> species.operation.densityprofile.DensityProfile: util.unsupported("fill in", self.fill_in) - util.unsupported("lower bound", self.lower_bound, [None, None, None]) - util.unsupported("upper bound", self.upper_bound, [None, None, None]) + util.unsupported("lower bound", self.lower_bound, (None, None, None)) + util.unsupported("upper bound", self.upper_bound, (None, None, None)) foilProfile = species.operation.densityprofile.Foil() foilProfile.density_si = self.density diff --git a/lib/python/picongpu/picmi/distribution/GaussianDistribution.py b/lib/python/picongpu/picmi/distribution/GaussianDistribution.py index 053f81d670..0a34f541a6 100644 --- a/lib/python/picongpu/picmi/distribution/GaussianDistribution.py +++ b/lib/python/picongpu/picmi/distribution/GaussianDistribution.py @@ -61,8 +61,8 @@ def get_as_pypicongpu(self) -> species.operation.densityprofile.DensityProfile: util.unsupported("fill in not active", self.fill_in, True) # @todo support bounds, Brian Marre, 2024 - util.unsupported("lower bound", self.lower_bound, [None, None, None]) - util.unsupported("upper bound", self.upper_bound, [None, None, None]) + util.unsupported("lower bound", self.lower_bound, (None, None, None)) + util.unsupported("upper bound", self.upper_bound, (None, None, None)) gaussian_profile = species.operation.densityprofile.Gaussian() diff --git a/lib/python/picongpu/picmi/distribution/UniformDistribution.py b/lib/python/picongpu/picmi/distribution/UniformDistribution.py index e7034b1ede..a84c0f431b 100644 --- a/lib/python/picongpu/picmi/distribution/UniformDistribution.py +++ b/lib/python/picongpu/picmi/distribution/UniformDistribution.py @@ -44,8 +44,8 @@ def picongpu_get_rms_velocity_si(self) -> typing.Tuple[float, float, float]: def get_as_pypicongpu(self) -> species.operation.densityprofile.DensityProfile: util.unsupported("fill in", self.fill_in) - util.unsupported("lower bound", self.lower_bound, [None, None, None]) - util.unsupported("upper bound", self.upper_bound, [None, None, None]) + util.unsupported("lower bound", self.lower_bound, (None, None, None)) + util.unsupported("upper bound", self.upper_bound, (None, None, None)) profile = species.operation.densityprofile.Uniform() profile.density_si = self.density diff --git a/lib/python/picongpu/picmi/interaction/__init__.py b/lib/python/picongpu/picmi/interaction/__init__.py new file mode 100644 index 0000000000..69896a4ddf --- /dev/null +++ b/lib/python/picongpu/picmi/interaction/__init__.py @@ -0,0 +1,4 @@ +from .interaction import Interaction +from . import ionization + +__all__ = ["Interaction", "ionization"] diff --git a/lib/python/picongpu/picmi/interaction/interaction.py b/lib/python/picongpu/picmi/interaction/interaction.py new file mode 100644 index 0000000000..1d997483b6 --- /dev/null +++ b/lib/python/picongpu/picmi/interaction/interaction.py @@ -0,0 +1,155 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from ... import pypicongpu + +from .ionization.groundstateionizationmodel import GroundStateIonizationModel, IonizationModel + +import picmistandard + +import typeguard +import pydantic + + +@typeguard.typechecked +class Interaction(pydantic.BaseModel): + """ + Common interface of Particle-In-Cell particle interaction extensions + + e.g. collisions, ionization, nuclear reactions + + This interface is only a semantic interface for typing interactions for storage in the simulation object. + It does not specify interface requirements for sub classes, since they differ too much. + """ + + ground_state_ionization_model_list: list[GroundStateIonizationModel] + """ + list of all interaction models that change the charge state of ions + + e.g. field ionization, collisional ionization, ... + + """ + + # @todo add Collisions as elastic interaction model, Brian Marre, 2024 + + @staticmethod + def update_constant_list( + existing_list: list[pypicongpu.species.constant.Constant], + new_list: list[pypicongpu.species.constant.Constant], + ) -> None: + """check if dicts may be merged without overwriting previously set values""" + + new_constant_list = [] + + for constant_new in new_list: + exists_already = False + for constant in existing_list: + if type(constant) == type(constant_new): + # constant_new already exists in existing constants list + exists_already = True + + if constant != constant_new: + # same type of constant but conflicting values + raise ValueError(f"Constants {constant} and {constant_new} conflict with each other.") + + if not exists_already: + new_constant_list.append(constant_new) + # ignore already existing constants + + # update constant_list + existing_list.extend(new_constant_list) + + def get_interaction_constants( + self, picmi_species: picmistandard.PICMI_Species + ) -> tuple[ + list[pypicongpu.species.constant.Constant], + dict[IonizationModel, pypicongpu.species.constant.ionizationmodel.IonizationModel], + ]: + """get list of all constants required by interactions for the given species""" + + has_ionization = False + constant_list = [] + ionization_model_conversion = {} + for model in self.ground_state_ionization_model_list: + if model.ion_species == picmi_species: + has_ionization = True + model_constants = model.get_constants() + Interaction.update_constant_list(constant_list, model_constants) + ionization_model_conversion[model] = model.get_as_pypicongpu() + + if has_ionization: + # add GroundStateIonization constant for entire species + constant_list.append( + pypicongpu.species.constant.GroundStateIonization( + ionization_model_list=ionization_model_conversion.values() + ) + ) + + # add additional interaction sub groups needing constants here + return constant_list, ionization_model_conversion + + def fill_in_ionization_electron_species( + self, + pypicongpu_by_picmi_species: dict[picmistandard.PICMI_Species, pypicongpu.species.Species], + ionization_model_conversion_by_type_and_species: dict[ + picmistandard.PICMI_Species, + None | dict[IonizationModel, pypicongpu.species.constant.ionizationmodel.IonizationModel], + ], + ) -> None: + """ + add ionization models to pypicongpu species + + In PICMI ionization is defined as a list ionization models owned by an interaction object which in turn is a + member of the simulation, with each ionization model storing its PICMI ion and PICMI ionization electron + species. + + In contrast in PyPIConGPU each ion PyPIConGPU species owns a list of ionization models, each storing its + PyPIConGPU ionization electron species. + + This creates the problem that upon translation of the PICMI species to an PyPIConGPU species the PyPIConGPU + ionization electron species might not exist yet. + + Therefore we leave the ionization electron unspecified upon species creation and fill it in from the PICMI + simulation ionization model list later. + + (And because python uses pointers, this will be applied to the existing species objects passed in + pypicongpu_by_picmi_species) + """ + + # ground state ionization model + for species, ionization_model_conversion in ionization_model_conversion_by_type_and_species.items(): + if ionization_model_conversion is not None: + for picmi_ionization_model, pypicongpu_ionization_model in ionization_model_conversion.items(): + try: + pypicongpu_ionization_electron_species = pypicongpu_by_picmi_species[ + picmi_ionization_model.ionization_electron_species + ] + except KeyError: + raise ValueError( + f"Ionization electron species of {picmi_ionization_model} not known to simulation.\n" + + f"Please add species {picmi_ionization_model.ionization_electron_species.name} to" + + " the simulation." + ) + pypicongpu_ionization_model.ionization_electron_species = pypicongpu_ionization_electron_species + + def __has_ground_state_ionization(self, species) -> bool: + """does at least one ground state ionization model list species as ion species?""" + + for ionization_model in self.ground_state_ionization_model_list: + if species == ionization_model.ion_species: + return True + return False + + def has_ionization(self, species) -> bool: + """does at least one ionization model list species as ion species?""" + from ..species import Species + + assert isinstance(species, Species) + + # add additional groups of ionization models here + ionization_configured = self.__has_ground_state_ionization(species) + return ionization_configured diff --git a/lib/python/picongpu/picmi/interaction/ionization/__init__.py b/lib/python/picongpu/picmi/interaction/ionization/__init__.py new file mode 100644 index 0000000000..da88ca3413 --- /dev/null +++ b/lib/python/picongpu/picmi/interaction/ionization/__init__.py @@ -0,0 +1,11 @@ +from .ionizationmodel import IonizationModel +from .groundstateionizationmodel import GroundStateIonizationModel +from . import fieldionization +from . import electroniccollisionalequilibrium + +__all__ = [ + "IonizationModel", + "GroundStateIonizationModel", + "fieldionization", + "electroniccollisionalequilibrium", +] diff --git a/lib/python/picongpu/picmi/interaction/ionization/electroniccollisionalequilibrium/__init__.py b/lib/python/picongpu/picmi/interaction/ionization/electroniccollisionalequilibrium/__init__.py new file mode 100644 index 0000000000..a8bb084044 --- /dev/null +++ b/lib/python/picongpu/picmi/interaction/ionization/electroniccollisionalequilibrium/__init__.py @@ -0,0 +1,3 @@ +from .thomasfermi import ThomasFermi + +__all__ = ["ThomasFermi"] diff --git a/lib/python/picongpu/picmi/interaction/ionization/electroniccollisionalequilibrium/thomasfermi.py b/lib/python/picongpu/picmi/interaction/ionization/electroniccollisionalequilibrium/thomasfermi.py new file mode 100644 index 0000000000..ca11d40a7e --- /dev/null +++ b/lib/python/picongpu/picmi/interaction/ionization/electroniccollisionalequilibrium/thomasfermi.py @@ -0,0 +1,23 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from ..groundstateionizationmodel import GroundStateIonizationModel +from ..... import pypicongpu + +import typeguard + + +@typeguard.typechecked +class ThomasFermi(GroundStateIonizationModel): + """thomas fermi ionization model""" + + MODEL_NAME: str = "ThomasFermi" + + def get_as_pypicongpu(self) -> pypicongpu.species.constant.ionizationmodel.IonizationModel: + self.check() + + return pypicongpu.species.constant.ionizationmodel.ThomasFermi() diff --git a/lib/python/picongpu/picmi/interaction/ionization/fieldionization/ADK.py b/lib/python/picongpu/picmi/interaction/ionization/fieldionization/ADK.py new file mode 100644 index 0000000000..2a54e68ba5 --- /dev/null +++ b/lib/python/picongpu/picmi/interaction/ionization/fieldionization/ADK.py @@ -0,0 +1,46 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from .fieldionization import FieldIonization + +from .....pypicongpu.species.constant.ionizationcurrent import None_ +from .....pypicongpu.species.constant.ionizationmodel import ( + ADKLinearPolarization, + ADKCircularPolarization, + IonizationModel, +) + + +import enum +import typeguard + + +@typeguard.typechecked +class ADKVariant(enum.Enum): + LinearPolarization = 0 + CircularPolarization = 1 + + +@typeguard.typechecked +class ADK(FieldIonization): + """ADK Tunneling Ionization model""" + + MODEL_NAME: str = "ADK" + + ADK_variant: ADKVariant + """ADK model variant specification""" + + def get_as_pypicongpu(self) -> IonizationModel: + self.check() + + if self.ADK_variant is ADKVariant.LinearPolarization: + return ADKLinearPolarization(ionization_current=None_()) + if self.ADK_variant is ADKVariant.CircularPolarization: + return ADKCircularPolarization(ionization_current=None_()) + + # unknown ADK variant + raise ValueError(f"ADKVariant {self.ADK_variant} is not supported.") diff --git a/lib/python/picongpu/picmi/interaction/ionization/fieldionization/BSI.py b/lib/python/picongpu/picmi/interaction/ionization/fieldionization/BSI.py new file mode 100644 index 0000000000..4054a196ff --- /dev/null +++ b/lib/python/picongpu/picmi/interaction/ionization/fieldionization/BSI.py @@ -0,0 +1,47 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from .fieldionization import FieldIonization + +from ..... import pypicongpu +from .....pypicongpu.species.constant.ionizationcurrent import None_ +from .....pypicongpu.species.constant import ionizationmodel + +import enum +import typeguard + + +@typeguard.typechecked +class BSIExtension(enum.Enum): + StarkShift = 0 + EffectiveZ = 1 + # add additional extensions here + + +@typeguard.typechecked +class BSI(FieldIonization): + """Barrier Suppression Ionization model""" + + MODEL_NAME: str = "BSI" + + BSI_extensions: tuple[BSIExtension] + """extension to the BSI model""" + + def get_as_pypicongpu(self) -> ionizationmodel.IonizationModel: + self.check() + + if self.BSI_extensions == []: + return ionizationmodel.BSI(ionization_current=None_()) + + if len(self.BSI_extensions) > 1: + pypicongpu.util.unsupported("more than one BSI_extension, will use first entry only") + + if self.BSI_extensions[0] is BSIExtension.StarkShift: + return ionizationmodel.BSIStarkShifted(ionization_current=None_()) + if self.BSI_extensions[0] is BSIExtension.EffectiveZ: + return ionizationmodel.BSIEffectiveZ(ionization_current=None_()) + raise ValueError(f"unknown BSI_extension {self.BSI_extensions[0]}.") diff --git a/lib/python/picongpu/picmi/interaction/ionization/fieldionization/__init__.py b/lib/python/picongpu/picmi/interaction/ionization/fieldionization/__init__.py new file mode 100644 index 0000000000..9bc5c73be5 --- /dev/null +++ b/lib/python/picongpu/picmi/interaction/ionization/fieldionization/__init__.py @@ -0,0 +1,7 @@ +from .fieldionization import FieldIonization +from .keldysh import Keldysh +from .ADK import ADK, ADKVariant +from .BSI import BSI, BSIExtension +from . import ionizationcurrent + +__all__ = ["FieldIonization", "Keldysh", "ADK", "ADKVariant", "BSI", "BSIExtension", "ionizationcurrent"] diff --git a/lib/python/picongpu/picmi/interaction/ionization/fieldionization/fieldionization.py b/lib/python/picongpu/picmi/interaction/ionization/fieldionization/fieldionization.py new file mode 100644 index 0000000000..5f484202fe --- /dev/null +++ b/lib/python/picongpu/picmi/interaction/ionization/fieldionization/fieldionization.py @@ -0,0 +1,20 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from ..groundstateionizationmodel import GroundStateIonizationModel +from .ionizationcurrent import IonizationCurrent + +import typing +import typeguard + + +@typeguard.typechecked +class FieldIonization(GroundStateIonizationModel): + """common interface of all field ionization models""" + + ionization_current: typing.Optional[IonizationCurrent] + """ionization current for energy conservation of field ionization""" diff --git a/lib/python/picongpu/picmi/interaction/ionization/fieldionization/ionizationcurrent/__init__.py b/lib/python/picongpu/picmi/interaction/ionization/fieldionization/ionizationcurrent/__init__.py new file mode 100644 index 0000000000..c48e658e91 --- /dev/null +++ b/lib/python/picongpu/picmi/interaction/ionization/fieldionization/ionizationcurrent/__init__.py @@ -0,0 +1,3 @@ +from .ionizationcurrent import IonizationCurrent + +__all__ = ["IonizationCurrent"] diff --git a/lib/python/picongpu/picmi/interaction/ionization/fieldionization/ionizationcurrent/ionizationcurrent.py b/lib/python/picongpu/picmi/interaction/ionization/fieldionization/ionizationcurrent/ionizationcurrent.py new file mode 100644 index 0000000000..bb30fe2213 --- /dev/null +++ b/lib/python/picongpu/picmi/interaction/ionization/fieldionization/ionizationcurrent/ionizationcurrent.py @@ -0,0 +1,16 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +import pydantic +import typeguard + + +@typeguard.typechecked +class IonizationCurrent(pydantic.BaseModel): + """common interface of all ionization current models""" + + MODEL_NAME: str diff --git a/lib/python/picongpu/picmi/interaction/ionization/fieldionization/keldysh.py b/lib/python/picongpu/picmi/interaction/ionization/fieldionization/keldysh.py new file mode 100644 index 0000000000..7cc79f30f4 --- /dev/null +++ b/lib/python/picongpu/picmi/interaction/ionization/fieldionization/keldysh.py @@ -0,0 +1,25 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from .fieldionization import FieldIonization + +from .....pypicongpu.species.constant.ionizationcurrent import None_ +from .....pypicongpu.species.constant import ionizationmodel + +import typeguard + + +@typeguard.typechecked +class Keldysh(FieldIonization): + """Barrier Suppression Ioniztion model""" + + MODEL_NAME: str = "Keldysh" + + def get_as_pypicongpu(self) -> ionizationmodel.IonizationModel: + self.check() + + return ionizationmodel.Keldysh(ionization_current=None_()) diff --git a/lib/python/picongpu/picmi/interaction/ionization/groundstateionizationmodel.py b/lib/python/picongpu/picmi/interaction/ionization/groundstateionizationmodel.py new file mode 100644 index 0000000000..68a596be3e --- /dev/null +++ b/lib/python/picongpu/picmi/interaction/ionization/groundstateionizationmodel.py @@ -0,0 +1,26 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from .ionizationmodel import IonizationModel + +from .... import pypicongpu + +import typeguard + + +@typeguard.typechecked +class GroundStateIonizationModel(IonizationModel): + def get_constants(self) -> list[pypicongpu.species.constant.Constant]: + """get all PyPIConGPU constants required by a ground state ionization model in PIConGPU""" + self.check() + + Z = self.ion_species.picongpu_element.get_atomic_number() + assert self.ion_species.charge_state <= Z, f"charge_state must be <= atomic number ({Z})" + + element_properties_const = pypicongpu.species.constant.ElementProperties() + element_properties_const.element = self.ion_species.picongpu_element + return [element_properties_const] diff --git a/lib/python/picongpu/picmi/interaction/ionization/ionizationmodel.py b/lib/python/picongpu/picmi/interaction/ionization/ionizationmodel.py new file mode 100644 index 0000000000..b09795eae0 --- /dev/null +++ b/lib/python/picongpu/picmi/interaction/ionization/ionizationmodel.py @@ -0,0 +1,59 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from .... import pypicongpu + +import pydantic +import typeguard +import typing + + +@typeguard.typechecked +class IonizationModel(pydantic.BaseModel): + """ + common interface for all ionization models + + @note further configurations may be added by implementations + """ + + MODEL_NAME: str + """ionization model""" + + ion_species: typing.Any + """PICMI ion species to apply ionization model for""" + + ionization_electron_species: typing.Any + """PICMI electron species of which to create macro particle upon ionization""" + + def __hash__(self): + """custom hash function for indexing in dicts""" + hash_value = hash(type(self)) + + for value in self.__dict__.values(): + try: + if value is not None: + hash_value += hash(value) + except TypeError: + print(self) + print(type(self)) + raise TypeError + return hash_value + + def check(self): + # import here to avoid circular import that stems from projecting different species types from PIConGPU onto the same `Species` type in PICMI + from ... import Species + + assert isinstance(self.ion_species, Species), "ion_species must be an instance of the species object" + assert isinstance( + self.ionization_electron_species, Species + ), "ionization_electron_species must be an instance of the species object" + + def get_constants(self) -> list[pypicongpu.species.constant.Constant]: + raise NotImplementedError("abstract base class only!") + + def get_as_pypicongpu(self) -> pypicongpu.species.constant.ionizationmodel.IonizationModel: + raise NotImplementedError("abstract base class only!") diff --git a/lib/python/picongpu/picmi/predefinedparticletypeproperties.py b/lib/python/picongpu/picmi/predefinedparticletypeproperties.py new file mode 100644 index 0000000000..dd457025d0 --- /dev/null +++ b/lib/python/picongpu/picmi/predefinedparticletypeproperties.py @@ -0,0 +1,125 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +import collections +import particle + +from scipy import constants as consts + +_PropertyTuple: collections.namedtuple = collections.namedtuple("_PropertyTuple", ["mass", "charge"]) + +# based on 2024 Particle data Group values, @todo read automatically from somewhere, BrianMarre +_quarks = { + "up": _PropertyTuple( + mass=particle.Particle.findall("u")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("u")[0].charge * consts.elementary_charge, + ), + "charm": _PropertyTuple( + mass=particle.Particle.findall("c")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("c")[0].charge * consts.elementary_charge, + ), + "top": _PropertyTuple( + mass=particle.Particle.findall("t")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("t")[0].charge * consts.elementary_charge, + ), + "down": _PropertyTuple( + mass=particle.Particle.findall("d")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("d")[0].charge * consts.elementary_charge, + ), + "strange": _PropertyTuple( + mass=particle.Particle.findall("s")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("s")[0].charge * consts.elementary_charge, + ), + "bottom": _PropertyTuple( + mass=particle.Particle.findall("b")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("b")[0].charge * consts.elementary_charge, + ), + "anti-up": _PropertyTuple( + mass=particle.Particle.findall("u~")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("u~")[0].charge * consts.elementary_charge, + ), + "anti-charm": _PropertyTuple( + mass=particle.Particle.findall("c~")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("c~")[0].charge * consts.elementary_charge, + ), + "anti-top": _PropertyTuple( + mass=particle.Particle.findall("t~")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("t~")[0].charge * consts.elementary_charge, + ), + "anti-down": _PropertyTuple( + mass=particle.Particle.findall("d~")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("d~")[0].charge * consts.elementary_charge, + ), + "anti-strange": _PropertyTuple( + mass=particle.Particle.findall("s~")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("s~")[0].charge * consts.elementary_charge, + ), + "anti-bottom": _PropertyTuple( + mass=particle.Particle.findall("b~")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("b~")[0].charge * consts.elementary_charge, + ), +} + +_leptons = { + "electron": _PropertyTuple(mass=consts.electron_mass, charge=-consts.elementary_charge), + "muon": _PropertyTuple( + mass=particle.Particle.findall("mu-")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("mu-")[0].charge * consts.elementary_charge, + ), + "tau": _PropertyTuple( + mass=particle.Particle.findall("tau-")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("tau-")[0].charge * consts.elementary_charge, + ), + "positron": _PropertyTuple(mass=consts.electron_mass, charge=consts.elementary_charge), + "anti-muon": _PropertyTuple( + mass=particle.Particle.findall("mu+")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("mu+")[0].charge * consts.elementary_charge, + ), + "anti-tau": _PropertyTuple( + mass=particle.Particle.findall("tau+")[0].mass * 1e6 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("tau+")[0].charge * consts.elementary_charge, + ), +} + +_nucleons = { + "proton": _PropertyTuple(mass=consts.proton_mass, charge=consts.elementary_charge), + "anti-proton": _PropertyTuple(mass=consts.proton_mass, charge=-consts.elementary_charge), + "neutron": _PropertyTuple(mass=consts.neutron_mass, charge=None), + "anti-neutron": _PropertyTuple(mass=consts.neutron_mass, charge=None), +} + +_neutrinos = { + "electron-neutrino": _PropertyTuple(mass=0.0, charge=0.0), + "muon-neutrino": _PropertyTuple(mass=0.0, charge=0.0), + "tau-neutrino": _PropertyTuple(mass=0.0, charge=0.0), + "anti-electron-neutrino": _PropertyTuple(mass=0.0, charge=0.0), + "anti-muon-neutrino": _PropertyTuple(mass=0.0, charge=0.0), + "anti-tau-neutrino": _PropertyTuple(mass=0.0, charge=0.0), +} + +_gauge_bosons = { + "photon": _PropertyTuple(mass=None, charge=0.0), + "gluon": _PropertyTuple(mass=None, charge=0.0), + "w-plus-boson": _PropertyTuple( + mass=particle.Particle.findall("W+")[0].mass * 1e9 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("W+")[0].charge * consts.elementary_charge, + ), + "w-minus-boson": _PropertyTuple( + mass=particle.Particle.findall("W-")[0].mass * 1e9 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("W-")[0].charge * consts.elementary_charge, + ), + "z-boson": _PropertyTuple( + mass=particle.Particle.findall("Z")[0].mass * 1e9 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("Z")[0].charge * consts.elementary_charge, + ), + "higgs": _PropertyTuple( + mass=particle.Particle.findall("H")[0].mass * 1e9 * consts.elementary_charge / consts.speed_of_light**2, + charge=particle.Particle.findall("H")[0].charge * consts.elementary_charge, + ), +} + +non_element_particle_type_properties = {**_quarks, **_leptons, **_neutrinos, **_nucleons, **_gauge_bosons} diff --git a/lib/python/picongpu/picmi/requirements.txt b/lib/python/picongpu/picmi/requirements.txt index b2a1853e9b..9f751e927b 100644 --- a/lib/python/picongpu/picmi/requirements.txt +++ b/lib/python/picongpu/picmi/requirements.txt @@ -4,4 +4,5 @@ picmistandard >= 0.27.0 typeguard >= 4.2.1 sympy >= 1.9 pydantic >= 2.6.4 +particle >= 0.25.1 -r ../pypicongpu/requirements.txt diff --git a/lib/python/picongpu/picmi/simulation.py b/lib/python/picongpu/picmi/simulation.py index 884f7f8d88..7f9a780e3a 100644 --- a/lib/python/picongpu/picmi/simulation.py +++ b/lib/python/picongpu/picmi/simulation.py @@ -5,10 +5,14 @@ License: GPLv3+ """ -from ..pypicongpu import simulation, runner, util, species, movingwindow, customuserinput +# make pypicongpu classes accessible for conversion to pypicongpu +from .. import pypicongpu +from .species import Species +from .interaction.ionization import IonizationModel + from . import constants from .grid import Cartesian3DGrid -from .species import Species as PicongpuPicmiSpecies +from .interaction import Interaction import picmistandard @@ -19,6 +23,7 @@ import typing +# may not use pydantic since inherits from _DocumentedMetaClass @typeguard.typechecked class Simulation(picmistandard.PICMI_Simulation): """ @@ -28,10 +33,84 @@ class Simulation(picmistandard.PICMI_Simulation): https://picmi-standard.github.io/standard/simulation.html """ - __picongpu_custom_input = util.build_typesafe_property( - typing.Optional[list[customuserinput.InterfaceCustomUserInput]] + picongpu_custom_user_input = pypicongpu.util.build_typesafe_property( + typing.Optional[list[pypicongpu.customuserinput.InterfaceCustomUserInput]] ) - """list of custom user input objects""" + """ + list of custom user input objects + + update using picongpu_add_custom_user_input() or by direct setting + """ + + picongpu_interaction = pypicongpu.util.build_typesafe_property(typing.Optional[Interaction]) + """Interaction instance containing all particle interactions of the simulation, set to None to have no interactions""" + + picongpu_typical_ppc = pypicongpu.util.build_typesafe_property(typing.Optional[int]) + """ + typical number of particle in a cell in the simulation + + used for normalization of code units + + optional, if set to None, will be set to median ppc of all species ppcs + """ + + picongpu_template_dir = pypicongpu.util.build_typesafe_property(typing.Optional[str]) + """directory containing templates to use for generating picongpu setups""" + + picongpu_moving_window_move_point = pypicongpu.util.build_typesafe_property(typing.Optional[float]) + """ + point a light ray reaches in y from the left border until we begin sliding the simulation window with the speed of + light + + in multiples of the simulation window size + + @attention if moving window is active, one gpu in y direction is reserved for initializing new spaces, + thereby reducing the simulation window size accordingrelative spot at which to start moving the simulation window + """ + + picongpu_moving_window_stop_iteration = pypicongpu.util.build_typesafe_property(typing.Optional[int]) + """iteration, at which to stop moving the simulation window""" + + __runner = pypicongpu.util.build_typesafe_property(typing.Optional[pypicongpu.runner.Runner]) + + # @todo remove boiler plate constructor argument list once picmistandard reference implementation switches to + # pydantic, Brian Marre, 2024 + def __init__( + self, + picongpu_template_dir: typing.Optional[typing.Union[str, pathlib.Path]] = None, + picongpu_typical_ppc: typing.Optional[int] = None, + picongpu_moving_window_move_point: typing.Optional[float] = None, + picongpu_moving_window_stop_iteration: typing.Optional[int] = None, + picongpu_interaction: typing.Optional[Interaction] = None, + **keyword_arguments, + ): + if picongpu_template_dir is not None: + self.picongpu_template_dir = str(picongpu_template_dir) + else: + self.picongpu_template_dir = picongpu_template_dir + + self.picongpu_typical_ppc = picongpu_typical_ppc + self.picongpu_moving_window_move_point = picongpu_moving_window_move_point + self.picongpu_moving_window_stop_iteration = picongpu_moving_window_stop_iteration + self.picongpu_interaction = picongpu_interaction + self.picongpu_custom_user_input = None + self.__runner = None + + picmistandard.PICMI_Simulation.__init__(self, **keyword_arguments) + + # additional PICMI stuff checks, @todo move to picmistandard, Brian Marre, 2024 + ## throw if both cfl & delta_t are set + if self.solver is not None and "Yee" == self.solver.method and isinstance(self.solver.grid, Cartesian3DGrid): + self.__yee_compute_cfl_or_delta_t() + + # checks on picongpu specific stuff + ## template_path is valid + if picongpu_template_dir == "": + raise ValueError("picongpu_template_dir MUST NOT be empty string") + if picongpu_template_dir is not None: + template_path = pathlib.Path(picongpu_template_dir) + if not template_path.is_dir(): + raise ValueError("picongpu_template_dir must be existing directory") def __yee_compute_cfl_or_delta_t(self) -> None: """ @@ -100,56 +179,10 @@ def __yee_compute_cfl_or_delta_t(self) -> None: # if neither delta_t nor cfl are given simply silently pass # (might change in the future) - def __init__( - self, - picongpu_template_dir: typing.Optional[typing.Union[str, pathlib.Path]] = None, - picongpu_typical_ppc: typing.Optional[int] = None, - picongpu_moving_window_move_point: typing.Optional[float] = None, - picongpu_moving_window_stop_iteration: typing.Optional[int] = None, - **kw, - ): - # delegate additional work to parent - super().__init__(**kw) - - # additional checks on inputs, @todo move to picmistandard, Brian Marre, 2024 - - ## throw if both cfl & delta_t are set - if self.solver is not None and "Yee" == self.solver.method and isinstance(self.solver.grid, Cartesian3DGrid): - self.__yee_compute_cfl_or_delta_t() - - # store picongpu specific stuff - # @todo switch to pydantic for automatic instrumentation of init method, Brian Marre 2024 - self.picongpu_typical_ppc = picongpu_typical_ppc - - # internal stuff for PICMI interface only - self.__runner = None - self.__electron_species = None - self.__picongpu_custom_input = None - - # set PyPIConGPU template directory - if picongpu_template_dir is None: - self.picongpu_template_dir = None - else: - assert "" != picongpu_template_dir, "picongpu_template_dir MUST NOT be empty" - # note: pathlib.Path(pathlib.Path(...)) is valid - template_path = pathlib.Path(picongpu_template_dir) - assert template_path.is_dir(), "picongpu_template_dir must be existing dir" - self.picongpu_template_dir = str(template_path) - - self.moving_window_move_point = picongpu_moving_window_move_point - self.moving_window_stop_iteration = picongpu_moving_window_stop_iteration - - self.picongpu_typical_ppc = picongpu_typical_ppc - - # store runner state - self.__runner = None - - self.__electron_species = None - def __get_operations_simple_density( self, - pypicongpu_by_picmi_species: typing.Dict[picmistandard.PICMI_Species, species.Species], - ) -> typing.List[species.operation.SimpleDensity]: + pypicongpu_by_picmi_species: typing.Dict[Species, pypicongpu.species.Species], + ) -> typing.List[pypicongpu.species.operation.SimpleDensity]: """ retrieve operations for simple density placements @@ -186,7 +219,7 @@ def __get_operations_simple_density( for profile, picmi_species_list in picmi_species_by_profile.items(): assert isinstance(layout, picmistandard.PICMI_PseudoRandomLayout) - op = species.operation.SimpleDensity() + op = pypicongpu.species.operation.SimpleDensity() op.ppc = layout.n_macroparticles_per_cell op.profile = profile.get_as_pypicongpu() @@ -203,8 +236,8 @@ def __get_operations_simple_density( def __get_operations_not_placed( self, - pypicongpu_by_picmi_species: typing.Dict[picmistandard.PICMI_Species, species.Species], - ) -> typing.List[species.operation.NotPlaced]: + pypicongpu_by_picmi_species: typing.Dict[Species, pypicongpu.species.Species], + ) -> typing.List[pypicongpu.species.operation.NotPlaced]: """ retrieve operations for not placed species @@ -225,7 +258,7 @@ def __get_operations_not_placed( continue # is not placed -> add op - not_placed = species.operation.NotPlaced() + not_placed = pypicongpu.species.operation.NotPlaced() not_placed.species = pypicongpu_by_picmi_species[picmi_species] all_operations.append(not_placed) @@ -233,8 +266,8 @@ def __get_operations_not_placed( def __get_operations_from_individual_species( self, - pypicongpu_by_picmi_species: typing.Dict[picmistandard.PICMI_Species, species.Species], - ) -> typing.List[species.operation.Operation]: + pypicongpu_by_picmi_species: typing.Dict[Species, pypicongpu.species.Species], + ) -> typing.List[pypicongpu.species.operation.Operation]: """ call get_independent_operations() of all species @@ -245,202 +278,106 @@ def __get_operations_from_individual_species( all_operations = [] for picmi_species, pypicongpu_species in pypicongpu_by_picmi_species.items(): - all_operations += picmi_species.get_independent_operations(pypicongpu_species) + all_operations += picmi_species.get_independent_operations(pypicongpu_species, self.picongpu_interaction) return all_operations - def __fill_ionization_electrons( - self, - pypicongpu_by_picmi_species: typing.Dict[picmistandard.PICMI_Species, species.Species], - ) -> None: - """ - copy used-electron-relationship from PICMI to PIConGPU species - - Translating a PICMI species to a PyPIConGPU species creates a ionizers - constant, but the reference to the used species is missing at this - point (b/c the translated species doesn't know the corresponding - PyPIConGPU species to be associated to.) - - This method fills the pypicongpu ionizers electron_species from the - PICMI picongpu_ionization_electrons attribute. - Note that for this the picongpu_ionization_electrons attribute must be - already set, probably from __resolve_electrons() - - (An b/c python uses pointers, this will be applied to the existing - species objects passed in pypicongpu_by_picmi_species) - """ - - for picmi_species, pypic_species in pypicongpu_by_picmi_species.items(): - # only fill ionization electrons if required (by ionizers) - if not pypic_species.has_constant_of_type(species.constant.Ionizers): - continue - - assert picmi_species.picongpu_ionization_electrons in pypicongpu_by_picmi_species, ( - "species {} (set as electrons " - "for species {} via picongpu_ionization_species) must be " - "explicitly added with add_species()".format( - picmi_species.picongpu_ionization_electrons.name, pypic_species.name - ) - ) - - ionizers = pypic_species.get_constant_by_type(species.constant.Ionizers) - # is pointer -> sets correct species for actual pypicongpu species - ionizers.electron_species = pypicongpu_by_picmi_species[picmi_species.picongpu_ionization_electrons] - - def __get_init_manager(self) -> species.InitManager: - """ - create & fill an initmanager - - performs the following steps: - 1. check preconditions - 2. translate species to pypicongpu representation - Note: Cache translations to avoid creating new translations by - continuosly translating again and again - 3. generate operations which have inter-species dependencies - 4. generate operations without inter-species dependencies - """ - initmgr = species.InitManager() - - # check preconditions + def __check_preconditions_init_manager(self) -> None: + """check preconditions, @todo move to picmistandard, Brian Marre 2024""" assert len(self.species) == len(self.layouts) - # either no layout AND no profile, or both - # (also: no ratio without layout and profile) for layout, picmi_species in zip(self.layouts, self.species): profile = picmi_species.initial_distribution ratio = picmi_species.density_scale - # either both None or both not None: assert 1 != [layout, profile].count( None ), "species need BOTH layout AND initial distribution set (or neither)" - # ratio only set if if ratio is not None: assert ( layout is not None and profile is not None ), "layout and initial distribution must be set to use density scale" - # get species list - ## + def __get_translated_species_and_ionization_models( + self, + ) -> tuple[ + dict[Species, pypicongpu.species.Species], + dict[Species, None | dict[IonizationModel, pypicongpu.species.constant.ionizationmodel.IonizationModel]], + ]: + """ + get mappping of PICMI species to PyPIConGPU species and mapping of of simulation + + @details cache to reuse *exactly the same* object in operations + """ - # note: cache to reuse *exactly the same* object in operations pypicongpu_by_picmi_species = {} + ionization_model_conversion_by_species = {} for picmi_species in self.species: - pypicongpu_species = picmi_species.get_as_pypicongpu() - pypicongpu_by_picmi_species[picmi_species] = pypicongpu_species - initmgr.all_species.append(pypicongpu_species) + # @todo split into two different fucntion calls?, Brian Marre, 2024 + pypicongpu_species, ionization_model_conversion = picmi_species.get_as_pypicongpu(self.picongpu_interaction) - # fill inter-species dependencies - ## - - # ionization (PICMI species don't know which PyPIConGPU species they - # use as electrons) - self.__fill_ionization_electrons(pypicongpu_by_picmi_species) - - # operations with inter-species dependencies - ## - initmgr.all_operations += self.__get_operations_simple_density(pypicongpu_by_picmi_species) - - # operations without inter-species dependencies - ## - initmgr.all_operations += self.__get_operations_not_placed(pypicongpu_by_picmi_species) - initmgr.all_operations += self.__get_operations_from_individual_species(pypicongpu_by_picmi_species) + pypicongpu_by_picmi_species[picmi_species] = pypicongpu_species + ionization_model_conversion_by_species[picmi_species] = ionization_model_conversion - return initmgr + return pypicongpu_by_picmi_species, ionization_model_conversion_by_species - def __get_electron_species(self) -> PicongpuPicmiSpecies: + def __fill_in_ionization_electrons( + self, + pypicongpu_by_picmi_species: dict[Species, pypicongpu.species.Species], + ionization_model_conversion_by_species: dict[ + Species, None | dict[IonizationModel, pypicongpu.species.constant.ionizationmodel.IonizationModel] + ], + ) -> None: """ - get electron species from existing species or generate new - - PIConGPU requires an explicit electron species, which PICMI assumes to - implicitly already exist. - This method retrieves an electron species by either reusing an existing - one or generating one if missing. - - Approach: - - 0 electron species: add one (print INFO log) - - 1 electron species: use it - - >1 electron species: raise, b/c is ambiguous + set the ionization electron species for each ionization model - electrons are identified by either mass & charge, or by particle_type. + Ionization electron species need to be set after species translation is complete since the PyPIConGPU electron + species is not at the time of translation by the PICMI ion species. """ - # use caching, this is method is expensive - if self.__electron_species is not None: - return self.__electron_species - - all_electrons = [] - for picmi_species in self.species: - if "electron" == picmi_species.particle_type: - all_electrons.append(picmi_species) - elif ( - picmi_species.mass is not None - and math.isclose(picmi_species.mass, constants.m_e) - and picmi_species.charge is not None - and math.isclose(picmi_species.charge, -constants.q_e) - ): - all_electrons.append(picmi_species) - - # exactly one electron species: use it - if 1 == len(all_electrons): - self.__electron_species = all_electrons[0] - return self.__electron_species - - # no electron species: add one - if 0 == len(all_electrons): - # compute unambiguous name - all_species_names = list(map(lambda picmi_species: picmi_species.name, self.species)) - electrons_name = "e" - while electrons_name in all_species_names: - electrons_name += "_" - - logging.info( - "no electron species for ionization available, creating electrons with name: {}".format(electrons_name) + if self.picongpu_interaction is not None: + self.picongpu_interaction.fill_in_ionization_electron_species( + pypicongpu_by_picmi_species, ionization_model_conversion_by_species ) - electrons = PicongpuPicmiSpecies(name=electrons_name, particle_type="electron") - self.add_species(electrons, None) - - self.__electron_species = electrons - return self.__electron_species - - # ambiguous choice -> raise - raise ValueError( - "choice of electron species for ionization is ambiguous, please " - "set picongpu_ionization_electrons explicitly for ionizable " - "species; found electron species: {}".format( - ", ".join(map(lambda picmi_species: picmi_species.name, all_electrons)) - ) - ) - def __resolve_electrons(self) -> None: + def __get_init_manager(self) -> pypicongpu.species.InitManager: """ - fill missing picongpu_ionization_electrons for ionized species + create & fill an Initmanager - PIConGPU needs every electron species set explicitly. - For this, PIConGPU PICMI species have a property - picongpu_ionization_electrons, which points to another PICMI species - to be used for ionization. - To be compatible to the native PICMI, this property is not required - from the **user**, but it is stillrequired for **translation**. + performs the following steps: + 1. check preconditions + 2. translate species and ionization models to PyPIConGPU representations + Note: Cache translations to avoid creating new translations by continuously translating again and again + 3. generate operations which have inter-species dependencies + 4. generate operations without inter-species dependencies + """ + self.__check_preconditions_init_manager() + ( + pypicongpu_by_picmi_species, + ionization_model_conversion_by_species, + ) = self.__get_translated_species_and_ionization_models() - This method guesses the value of picongpu_ionization_electrons if they - are not set. + # fill inter-species dependencies + self.__fill_in_ionization_electrons(pypicongpu_by_picmi_species, ionization_model_conversion_by_species) - The actual electron selection is implemented in - __get_electron_species() - """ - for picmi_species in self.species: - # only handle ionized species anyways - if not picmi_species.has_ionizers(): - continue + # init PyPIConGPU init manager + initmgr = pypicongpu.species.InitManager() - # skip if ionization electrons already set (nothing to guess) - if picmi_species.picongpu_ionization_electrons is not None: - continue + for pypicongpu_species in pypicongpu_by_picmi_species.values(): + initmgr.all_species.append(pypicongpu_species) + + # operations on multiple species + initmgr.all_operations += self.__get_operations_simple_density(pypicongpu_by_picmi_species) + + # operations on single species + initmgr.all_operations += self.__get_operations_not_placed(pypicongpu_by_picmi_species) + initmgr.all_operations += self.__get_operations_from_individual_species(pypicongpu_by_picmi_species) - picmi_species.picongpu_ionization_electrons = self.__get_electron_species() + return initmgr - def write_input_file(self, file_name: str, pypicongpu_simulation: simulation.Simulation | None = None) -> None: + def write_input_file( + self, file_name: str, pypicongpu_simulation: typing.Optional[pypicongpu.simulation.Simulation] = None + ) -> None: """ generate input data set for picongpu @@ -456,9 +393,19 @@ def write_input_file(self, file_name: str, pypicongpu_simulation: simulation.Sim if pypicongpu_simulation is None: pypicongpu_simulation = self.get_as_pypicongpu() - self.__runner = runner.Runner(pypicongpu_simulation, self.picongpu_template_dir, setup_dir=file_name) + self.__runner = pypicongpu.runner.Runner(pypicongpu_simulation, self.picongpu_template_dir, setup_dir=file_name) self.__runner.generate() + def picongpu_add_custom_user_input(self, custom_user_input: pypicongpu.customuserinput.InterfaceCustomUserInput): + """add custom user input to previously stored input""" + self.picongpu_custom_user_input = (self.picongpu_custom_user_input or []) + [custom_user_input] + + def add_interaction(self, interaction) -> None: + pypicongpu.util.unsupported( + "PICMI standard interactions are not supported by PIConGPU, use the picongpu specific Interaction object instead" + ) + + # @todo add refactor once restarts are supported by the Runner, Brian Marre, 2024 def step(self, nsteps: int = 1): if nsteps != self.max_steps: raise ValueError( @@ -466,15 +413,15 @@ def step(self, nsteps: int = 1): ) self.picongpu_run() - def get_as_pypicongpu(self) -> simulation.Simulation: + def get_as_pypicongpu(self) -> pypicongpu.simulation.Simulation: """translate to PyPIConGPU object""" - s = simulation.Simulation() + s = pypicongpu.simulation.Simulation() s.delta_t_si = self.time_step_size s.solver = self.solver.get_as_pypicongpu() - # already in pypicongpu objects - s.custom_user_input = self.__picongpu_custom_input + # already pypicongpu objects, therefore directly passing on + s.custom_user_input = self.picongpu_custom_user_input # calculate time step if self.max_steps is not None: @@ -484,22 +431,22 @@ def get_as_pypicongpu(self) -> simulation.Simulation: else: raise ValueError("runtime not specified (neither as step count nor max time)") - util.unsupported("verbose", self.verbose) - util.unsupported("particle shape", self.particle_shape, "linear") - util.unsupported("gamma boost, use picongpu_moving_window_move_point instead", self.gamma_boost) + pypicongpu.util.unsupported("verbose", self.verbose) + pypicongpu.util.unsupported("particle shape", self.particle_shape, "linear") + pypicongpu.util.unsupported("gamma boost", self.gamma_boost) try: s.grid = self.solver.grid.get_as_pypicongpu() except AttributeError: - util.unsupported(f"grid type: {type(self.solver.grid)}") + pypicongpu.util.unsupported(f"grid type: {type(self.solver.grid)}") # any injection method != None is not supported if len(self.laser_injection_methods) != self.laser_injection_methods.count(None): - util.unsupported("laser injection method", self.laser_injection_methods, []) + pypicongpu.util.unsupported("laser injection method", self.laser_injection_methods, []) # pypicongpu interface currently only supports one laser, @todo change Brian Marre, 2024 if len(self.lasers) > 1: - util.unsupported("more than one laser") + pypicongpu.util.unsupported("more than one laser") if len(self.lasers) == 1: # check requires grid, so grid is translated (and thereby also checked) above @@ -508,12 +455,9 @@ def get_as_pypicongpu(self) -> simulation.Simulation: # explictly disable laser (as required by pypicongpu) s.laser = None - # resolve electrons - self.__resolve_electrons() - s.init_manager = self.__get_init_manager() - # set typical ppc if not overwritten by user + # set typical ppc if not set explicitly by user if self.picongpu_typical_ppc is None: s.typical_ppc = (s.init_manager).get_typical_particle_per_cell() else: @@ -523,30 +467,25 @@ def get_as_pypicongpu(self) -> simulation.Simulation: raise ValueError("typical_ppc must be >= 1") # disable moving Window if explicitly activated by the user - if self.moving_window_move_point is None: + if self.picongpu_moving_window_move_point is None: s.moving_window = None else: - s.moving_window = movingwindow.MovingWindow( - move_point=self.moving_window_move_point, stop_iteration=self.moving_window_stop_iteration + s.moving_window = pypicongpu.movingwindow.MovingWindow( + move_point=self.picongpu_moving_window_move_point, + stop_iteration=self.picongpu_moving_window_stop_iteration, ) return s - def picongpu_add_custom_user_input(self, custom_user_input: customuserinput.InterfaceCustomUserInput): - if self.__picongpu_custom_input is None: - self.__picongpu_custom_input = [custom_user_input] - else: - self.__picongpu_custom_input.append(custom_user_input) - def picongpu_run(self) -> None: """build and run PIConGPU simulation""" if self.__runner is None: - self.__runner = runner.Runner(self.get_as_pypicongpu(), self.picongpu_template_dir) + self.__runner = pypicongpu.runner.Runner(self.get_as_pypicongpu(), self.picongpu_template_dir) self.__runner.generate() self.__runner.build() self.__runner.run() - def picongpu_get_runner(self) -> runner.Runner: + def picongpu_get_runner(self) -> pypicongpu.runner.Runner: if self.__runner is None: - self.__runner = runner.Runner(self.get_as_pypicongpu(), self.picongpu_template_dir) + self.__runner = pypicongpu.runner.Runner(self.get_as_pypicongpu(), self.picongpu_template_dir) return self.__runner diff --git a/lib/python/picongpu/picmi/species.py b/lib/python/picongpu/picmi/species.py index 1662ebecc3..c5ade6ac2a 100644 --- a/lib/python/picongpu/picmi/species.py +++ b/lib/python/picongpu/picmi/species.py @@ -1,17 +1,23 @@ """ This file is part of PIConGPU. -Copyright 2021-2023 PIConGPU contributors +Copyright 2021-2024 PIConGPU contributors Authors: Hannes Troepgen, Brian Edward Marre License: GPLv3+ """ -from ..pypicongpu import util, species +from .predefinedparticletypeproperties import non_element_particle_type_properties +from .interaction import Interaction + +from .. import pypicongpu +from ..pypicongpu.species.util.element import Element import picmistandard -import typeguard import typing +import typeguard import logging +import re + from scipy import constants as consts @@ -19,36 +25,37 @@ class Species(picmistandard.PICMI_Species): """PICMI object for a (single) particle species""" - # ONLY set non-element particles here -- all other are handled by - # element - __mass_charge_by_openpmd_name_non_elements = { - "electron": (consts.electron_mass, -consts.elementary_charge), - } - """mass/charge tuple to use when passed a non-element particle_type""" - - picongpu_fully_ionized = util.build_typesafe_property(typing.Optional[bool]) + __non_element_particle_type_properties = non_element_particle_type_properties """ - *usually* ionization is expected to be used on elements -- use this to - explicitly DISABLE ionization + mass/charge to use when passed a non-element particle_type + + @attention ONLY set non-element particles here, all other are handled by element """ - def __init__(self, picongpu_fully_ionized: typing.Optional[bool] = None, picongpu_ionization_electrons=None, **kw): - self.picongpu_fully_ionized = picongpu_fully_ionized + picongpu_element = pypicongpu.util.build_typesafe_property(typing.Optional[Element]) + """element information of object""" + + __non_element_particle_types: list[str] = __non_element_particle_type_properties.keys() + """list of particle types""" - # note: picongpu_ionization_electrons would *normally* just use a - # forward-declared typecheck "Species" - # However, this requires that "Species" at some point resolves to this - # class. Typically this picmi species object is only available as - # "picmi.Species()", and the resolution fails. - # Hence, the type is checked manually here. - typeguard.check_type(picongpu_ionization_electrons, typing.Optional[Species]) - self.picongpu_ionization_electrons = picongpu_ionization_electrons + picongpu_fixed_charge = pypicongpu.util.build_typesafe_property(bool) - super().__init__(**kw) + interactions = pypicongpu.util.build_typesafe_property(typing.Optional[list[None]]) + """overwrite base class interactions to disallow setting them""" + + __warned_already: bool = False + __previous_check: bool = False + + def __init__(self, picongpu_fixed_charge: bool = False, **keyword_arguments): + self.picongpu_fixed_charge = picongpu_fixed_charge + self.picongpu_element = None + + # let PICMI class handle remaining init + picmistandard.PICMI_Species.__init__(self, **keyword_arguments) @staticmethod def __get_temperature_kev_by_rms_velocity( - rms_velocity_si: typing.Tuple[float, float, float], particle_mass_si: float + rms_velocity_si: tuple[float, float, float], particle_mass_si: float ) -> float: """ convert temperature from RMS velocity vector to keV @@ -72,7 +79,7 @@ def __get_temperature_kev_by_rms_velocity( rms_velocity_si_squared = rms_velocity_si[0] ** 2 return particle_mass_si * rms_velocity_si_squared * consts.electron_volt**-1 * 10**-3 - def __get_drift(self) -> typing.Optional[species.operation.momentum.Drift]: + def __get_drift(self) -> typing.Optional[pypicongpu.species.operation.momentum.Drift]: """ Retrieve respective pypicongpu drift object (or None) @@ -86,85 +93,164 @@ def __get_drift(self) -> typing.Optional[species.operation.momentum.Drift]: def __maybe_apply_particle_type(self) -> None: """ - check if particle type is set, if yes set self.mass and self.charge - """ - if self.particle_type is None: - return + if particle type is set, set self.mass, self.charge and element from particle type - # particle type is set -> retrieve mass & charge - assert self.charge is None, "charge is specify implicitly via particle type, " "do NOT set charge explictly" - assert self.mass is None, "mass is specify implicitly via particle type, " "do NOT set mass explictly" + necessary to ensure consistent state regardless which parameters the user specified in species init - if self.particle_type in self.__mass_charge_by_openpmd_name_non_elements: - # not element, but known - mass_charge_tuple = self.__mass_charge_by_openpmd_name_non_elements[self.particle_type] - self.mass = mass_charge_tuple[0] - self.charge = mass_charge_tuple[1] + @raises if both particle_type and charge mass are specified + """ + + if (self.particle_type is None) or re.match(r"other:.*", self.particle_type): + # no particle or custom particle type set + pass else: - # element (or unkown, which raises when trying to get an - # element for that name) - self.element = species.util.Element.get_by_openpmd_name(self.particle_type) - self.mass = self.element.get_mass_si() - self.charge = self.element.get_charge_si() + # set mass & charge + if self.particle_type in self.__non_element_particle_types: + # not element, but known + mass_charge_tuple = self.__non_element_particle_type_properties[self.particle_type] + + self.mass = mass_charge_tuple.mass + self.charge = mass_charge_tuple.charge + elif Element.is_element(self.particle_type): + # element or similar, will raise if element name is unknown + self.picongpu_element = pypicongpu.species.util.Element(self.particle_type) + self.mass = self.picongpu_element.get_mass_si() + self.charge = self.picongpu_element.get_charge_si() + else: + # unknown particle type + raise ValueError(f"Species {self.name} has unknown particle type {self.particle_type}") + + def has_ionization(self, interaction: Interaction | None) -> bool: + """does species have ionization configured?""" + if interaction is None: + return False + if interaction.has_ionization(self): + return True - def __check_ionization(self) -> None: + # to get typecheck to shut up + return False + + def is_ion(self) -> bool: """ - check if ionization (charge_state) can be applied, potentially warns + is species an ion? + + @attention requires __maybe_apply_particle_type() to have been called first, + otherwise will return wrong result """ - assert not self.picongpu_fully_ionized or self.charge_state is None, ( - "picongpu_fully_ionized may only be used if " "charge_state is none" - ) + if self.picongpu_element is None: + return False + return True - if self.particle_type is None: - # no particle type -> charge state is not allowed - assert self.charge_state is None, "charge_state is ONLY allowed " "when setting particle_type explicitly" + def __check_ionization_configuration(self, interaction: Interaction | None) -> None: + """ + check species ioniaztion- and species- configuration are compatible + + @raises if incorrect configuration found + """ - # no particle type -> fully ionized flag not permitted - assert self.picongpu_fully_ionized is None, ( - "picongpu_fully_ionized is ONLY allowed " "when setting particle_type explicitly" + if self.particle_type is None: + assert not self.has_ionization( + interaction + ), f"Species {self.name} configured with active ionization but required particle_type not set." + assert self.charge_state is None, ( + f"Species {self.name} specified initial charge state via charge_state without also specifying particle " + "type, must either set particle_type explicitly or only use charge instead" ) + assert ( + self.picongpu_fixed_charge is False + ), f"Species {self.name} specified fixed charge without also specifying particle_type" + else: + # particle type is + if (self.particle_type in self.__non_element_particle_types) or re.match(r"other:.*", self.particle_type): + # non ion predefined particle, or custom particle type + assert self.charge_state is None, "charge_state may only be set for ions" + assert not self.has_ionization( + interaction + ), f"Species {self.name} configured with active ionization but particle type indicates non ion." + assert ( + self.picongpu_fixed_charge is False + ), f"Species {self.name} configured with fixed charge state but particle_type indicates non ion" + elif Element.is_element(self.particle_type): + # ion + + # check for unphysical charge state + if self.charge_state is not None: + assert ( + Element(self.particle_type).get_atomic_number() >= self.charge_state + ), f"Species {self.name} intial charge state is unphysical" + + if self.has_ionization(interaction): + assert self.picongpu_fixed_charge is False, ( + f"Species {self.name} configured both as fixed charge ion and ion with ionization, may be " + " either or but not both." + ) + assert self.charge_state is not None, ( + f"Species {self.name} configured with ionization but no initial charge state specified, " + "must be explicitly specified via charge_state." + ) + else: + # ion with fixed charge + if self.picongpu_fixed_charge is False: + raise ValueError( + f"Species {self.name} configured with fixed charge state without explicitly setting picongpu_fixed_charge=True" + ) + + if not self.__warned_already: + logging.warning( + f"Species {self.name} configured with fixed charge state but particle type" + "indicates element. This is not recommended but supported" + ) + self.__warned_already = True + + # charge_state may be set or None indicating some fixed number of bound electrons or fully ionized + # ion + else: + # unknown particle type + raise ValueError(f"unknown particle type {self.particle_type} in species {self.name}") + + def __check_interaction_configuration(self, interaction: Interaction | None) -> None: + """check all interactions sub groups for compatibility with this species configuration""" + self.__check_ionization_configuration(interaction) - # no charge_state -> nothing left - return + def check(self, interaction: Interaction | None) -> None: + assert self.name is not None, "picongpu requires each species to have a name set." - # particle type is set: fully ionized flag *ONLY* allowed if using - # element - if self.picongpu_fully_ionized is not None: + # check charge and mass explicitly set/not set depending on particle_type + if (self.particle_type is None) or re.match(r"other:.*", self.particle_type): + # custom species may not have mass or charge + pass + elif not self.__previous_check: assert ( - self.particle_type not in self.__mass_charge_by_openpmd_name_non_elements - ), "picongpu_fully_ionized is ONLY allowed for elements" - - # maybe warn - if self.charge_state is None: - # theoretically speaking atoms *always* have a charge state - # for PIConGPU an atom (ion) may exist without a charge state, - # i.e. without ionization, however this may result in - # (physically) incorrect behavior - # Therefore warn if there is no charge state -- unless this - # warning is explicitly disabled with a flag is given - - # (note: omit if not element) - if ( - not self.picongpu_fully_ionized - and self.particle_type not in self.__mass_charge_by_openpmd_name_non_elements - ): - logging.warning( - "species {} will be fully ionized for the entire " - "simulation -- if this is intended, set " - "picongpu_fully_ionized=True".format(self.name) - ) - - def get_as_pypicongpu(self) -> species.Species: - util.unsupported("method", self.method) - util.unsupported("particle shape", self.particle_shape) - # note: placement params are respected in associated simulation object - - assert self.name is not None, "name must be set" + self.charge is None + ), f"Species' {self.name}, charge is specified implicitly via particle type, do NOT set charge explictly" + assert ( + self.mass is None + ), f"Species' {self.name}, mass is specified implicitly via particle type, do NOT set mass explictly" + + self.__check_interaction_configuration(interaction) + self.__previous_check = True + + def get_as_pypicongpu( + self, interaction: Interaction | None + ) -> tuple[ + pypicongpu.species.Species, None | dict[typing.Any, pypicongpu.species.constant.ionizationmodel.IonizationModel] + ]: + """ + translate PICMI species object to equivalent PyPIConGPU species object + + @attention only translates ONLY species owned objects, for example species-Constants + everything else requires a call to the corresponding getter of this class + """ + # error on unsupported options + pypicongpu.util.unsupported("method", self.method) + pypicongpu.util.unsupported("particle shape", self.particle_shape) + # @note placement params are respected in associated simulation object + + self.check(interaction) self.__maybe_apply_particle_type() - self.__check_ionization() - s = species.Species() + s = pypicongpu.species.Species() s.name = self.name s.constants = [] @@ -172,57 +258,48 @@ def get_as_pypicongpu(self) -> species.Species: # if 0==mass rather omit mass entirely assert self.mass > 0 - mass_constant = species.constant.Mass() + mass_constant = pypicongpu.species.constant.Mass() mass_constant.mass_si = self.mass s.constants.append(mass_constant) - if self.charge is not None: - charge_constant = species.constant.Charge() - charge_constant.charge_si = self.charge - s.constants.append(charge_constant) - if self.density_scale is not None: assert self.density_scale > 0 - density_scale_constant = species.constant.DensityRatio() + density_scale_constant = pypicongpu.species.constant.DensityRatio() density_scale_constant.ratio = self.density_scale s.constants.append(density_scale_constant) - if self.particle_type and self.particle_type not in self.__mass_charge_by_openpmd_name_non_elements: - # particle type given and is not non-element (==is element) - # -> add element flags - element = species.util.Element.get_by_openpmd_name(self.particle_type) + # default case species with no charge and/or no bound electrons or with ionization + charge_constant_value = self.charge - elementary_properties_const = species.constant.ElementProperties() - elementary_properties_const.element = element - s.constants.append(elementary_properties_const) + initial_charge_state_set = self.charge_state is not None + fixed_charge_state = not self.has_ionization(interaction) + if self.is_ion() and initial_charge_state_set and fixed_charge_state: + # fixed not completely ionized ion + charge_constant_value = self.charge_state * consts.elementary_charge - if self.charge_state is not None: - # element must be set from previous code section - assert element is not None + if charge_constant_value is not None: + charge_constant = pypicongpu.species.constant.Charge() + charge_constant.charge_si = charge_constant_value + s.constants.append(charge_constant) - atomic_number = element.value - assert self.charge_state <= atomic_number, "charge_state must be <= atomic number ({})".format( - atomic_number - ) + if interaction is not None: + interaction_constants, pypicongpu_model_by_picmi_model = interaction.get_interaction_constants(self) + s.constants.extend(interaction_constants) + else: + pypicongpu_model_by_picmi_model = None - const_ionizers = species.constant.Ionizers() - # const_ionizers.electron_species must be set to a pypicongpu - # species, but this is not available here - # -> inserted externally - s.constants.append(const_ionizers) + return s, pypicongpu_model_by_picmi_model - return s + def get_independent_operations( + self, pypicongpu_species: pypicongpu.species.Species, interaction: Interaction | None + ) -> list[pypicongpu.species.operation.Operation]: + """get a list of all operations only initializing attributes of this species""" - def has_ionizers(self) -> bool: - """ - returns true iff a species will have ionizers (algorithms) - """ - return self.charge_state is not None + # assure consistent state of species + self.check(interaction) + self.__maybe_apply_particle_type() - def get_independent_operations( - self, pypicongpu_species: species.Species - ) -> typing.List[species.operation.Operation]: assert pypicongpu_species.name == self.name, ( "to generate " "operations for PyPIConGPU species: names must match" ) @@ -230,13 +307,13 @@ def get_independent_operations( all_operations = [] # assign momentum - momentum_op = species.operation.SimpleMomentum() + momentum_op = pypicongpu.species.operation.SimpleMomentum() momentum_op.species = pypicongpu_species momentum_op.drift = self.__get_drift() temperature_kev = 0 if self.initial_distribution is not None and self.initial_distribution.rms_velocity is not None: - mass_const = pypicongpu_species.get_constant_by_type(species.constant.Mass) + mass_const = pypicongpu_species.get_constant_by_type(pypicongpu.species.constant.Mass) mass_si = mass_const.mass_si temperature_kev = self.__get_temperature_kev_by_rms_velocity( @@ -244,34 +321,21 @@ def get_independent_operations( ) if 0 != temperature_kev: - momentum_op.temperature = species.operation.momentum.Temperature() + momentum_op.temperature = pypicongpu.species.operation.momentum.Temperature() momentum_op.temperature.temperature_kev = temperature_kev else: momentum_op.temperature = None all_operations.append(momentum_op) - # ionization: - if self.has_ionizers(): - # note: this will raise if called *before* get_as_pypicongpu with - # "self.element" is not defined -- in this case, either fix the - # order or compute the element here on the fly - atomic_number = self.element.value - - # fully ionized? - if self.charge_state == atomic_number: - ion_op_no_electrons = species.operation.NoBoundElectrons() - ion_op_no_electrons.species = pypicongpu_species - all_operations.append(ion_op_no_electrons) - else: - # not fully ionized - bound_electrons = atomic_number - self.charge_state - assert bound_electrons > 0 - - ion_op_electrons = species.operation.SetBoundElectrons() - ion_op_electrons.species = pypicongpu_species - ion_op_electrons.bound_electrons = bound_electrons - - all_operations.append(ion_op_electrons) + # assign boundElectrons attribute + if self.is_ion() and self.has_ionization(interaction): + bound_electrons_op = pypicongpu.species.operation.SetChargeState() + bound_electrons_op.species = pypicongpu_species + bound_electrons_op.charge_state = self.charge_state + all_operations.append(bound_electrons_op) + else: + # fixed charge state -> therefore no bound electron attribute necessary + pass return all_operations diff --git a/lib/python/picongpu/pypicongpu/rendering/renderedobject.py b/lib/python/picongpu/pypicongpu/rendering/renderedobject.py index 177e87b29e..1f249ca332 100644 --- a/lib/python/picongpu/pypicongpu/rendering/renderedobject.py +++ b/lib/python/picongpu/pypicongpu/rendering/renderedobject.py @@ -53,6 +53,20 @@ class RenderedObject: which is used for identification purposes. """ + def __hash__(self): + """custom hash function for indexing in dicts""" + hash_value = hash(type(self)) + + for value in self.__dict__.values(): + try: + if value is not None: + hash_value += hash(value) + except TypeError: + print(self) + print(type(self)) + raise TypeError + return hash_value + @staticmethod def _maybe_fill_schema_store() -> None: """ @@ -160,7 +174,8 @@ def _get_schema_from_class(class_type: type) -> typing.Any: if type(schema) is dict: if "unevaluatedProperties" not in schema: logging.warning("schema does not explicitly forbid " "unevaluated properties: {}".format(fqn)) - elif schema["unevaluatedProperties"]: + # special exemption for custom user input which is never evaluated + elif schema["unevaluatedProperties"] and fqn != "picongpu.pypicongpu.customuserinput.CustomUserInput": logging.warning("schema supports unevaluated properties: {}".format(fqn)) else: logging.warning("schema is not dict: {}".format(fqn)) @@ -180,7 +195,7 @@ def get_rendering_context(self) -> dict | None: delegates work to _get_serialized and invokes checks performed by check_context_for_type(). - :raise ValidationError: on schema violiation + :raise ValidationError: on schema violation :raise RuntimeError: on schema not found :return: self as rendering context """ diff --git a/lib/python/picongpu/pypicongpu/requirements.txt b/lib/python/picongpu/pypicongpu/requirements.txt index b6799b535e..d30e2f0878 100644 --- a/lib/python/picongpu/pypicongpu/requirements.txt +++ b/lib/python/picongpu/pypicongpu/requirements.txt @@ -1,4 +1,6 @@ chevron >= 0.13.1 jsonschema >= 4.23.0 typeguard >= 4.2.1 -referencing >=0.35.1 +referencing >= 0.35.1 +pydantic >= 2.6.4 +periodictable >= 1.7.1 diff --git a/lib/python/picongpu/pypicongpu/runner.py b/lib/python/picongpu/pypicongpu/runner.py index ecdf1432d4..bcbeb37767 100644 --- a/lib/python/picongpu/pypicongpu/runner.py +++ b/lib/python/picongpu/pypicongpu/runner.py @@ -248,7 +248,7 @@ def __helper_set_scratch_dir(self, scratch_dir: typing.Optional[str]) -> None: # try to retrieve from environment var if self.SCRATCH_ENV_NAME in environ: logging.info( - "loading scratch directory (implicitly) " "from environment var ${}".format(self.SCRATCH_ENV_NAME) + "loading scratch directory (implicitly) from environment var ${}".format(self.SCRATCH_ENV_NAME) ) self.scratch_dir = path.abspath(environ[self.SCRATCH_ENV_NAME]) else: @@ -333,7 +333,10 @@ def __run(self): chdir(self.setup_dir) runArgs( "PIConGPU", - "tbg -s bash -c etc/picongpu/N.cfg -t " "etc/picongpu/bash/mpiexec.tpl".split(" ") + [self.run_dir], + ( + ("tbg -s bash -c etc/picongpu/N.cfg -t " + environ["PIC_SYSTEM_TEMPLATE_PATH"] + "/mpiexec.tpl").split() + + [self.run_dir] + ), ) def generate(self, printDirToConsole=False): diff --git a/lib/python/picongpu/pypicongpu/simulation.py b/lib/python/picongpu/pypicongpu/simulation.py index f92cfb5f8f..d04196e941 100644 --- a/lib/python/picongpu/pypicongpu/simulation.py +++ b/lib/python/picongpu/pypicongpu/simulation.py @@ -99,14 +99,7 @@ def __found_custom_input(self, serialized: dict): + "\t WARNING: custom templates are required if using custom user input.\n" ) - def check(self): - """check validity of self""" - if self.typical_ppc < 1: - raise ValueError("typical_ppc must be >= 1") - def _get_serialized(self) -> dict: - self.check() - serialized = { "delta_t_si": self.delta_t_si, "time_steps": self.time_steps, diff --git a/lib/python/picongpu/pypicongpu/species/constant/__init__.py b/lib/python/picongpu/pypicongpu/species/constant/__init__.py index 071c09533a..0c4f2049a1 100644 --- a/lib/python/picongpu/pypicongpu/species/constant/__init__.py +++ b/lib/python/picongpu/pypicongpu/species/constant/__init__.py @@ -2,14 +2,14 @@ from .mass import Mass from .charge import Charge from .densityratio import DensityRatio -from .ionizers import Ionizers from .elementproperties import ElementProperties +from .groundstateionization import GroundStateIonization __all__ = [ "Constant", "Mass", "Charge", "DensityRatio", - "Ionizers", "ElementProperties", + "GroundStateIonization", ] diff --git a/lib/python/picongpu/pypicongpu/species/constant/constant.py b/lib/python/picongpu/pypicongpu/species/constant/constant.py index 1a887f9ff0..9b2ca87675 100644 --- a/lib/python/picongpu/pypicongpu/species/constant/constant.py +++ b/lib/python/picongpu/pypicongpu/species/constant/constant.py @@ -45,8 +45,9 @@ class Constant(RenderedObject): constant) """ - def __init__(self): - raise NotImplementedError() + def __eq__(self, other) -> bool: + """two constants are equal if they have the same attributes values""" + return set(self.__dict__.items()) == set(other.__dict__.items()) def check(self) -> None: """ diff --git a/lib/python/picongpu/pypicongpu/species/constant/groundstateionization.py b/lib/python/picongpu/pypicongpu/species/constant/groundstateionization.py new file mode 100644 index 0000000000..65de4341e5 --- /dev/null +++ b/lib/python/picongpu/pypicongpu/species/constant/groundstateionization.py @@ -0,0 +1,93 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from .constant import Constant +from .ionizationmodel import IonizationModel, IonizationModelGroups + +import pydantic +import typing + + +class GroundStateIonization(Constant, pydantic.BaseModel): + ionization_model_list: list[IonizationModel] + """list of ground state only ionization models to apply for the species""" + + def get(self): + return self.ionization_model_list + + def __hash__(self) -> int: + return_hash_value = hash(type(self)) + for model in self.ionization_model_list: + return_hash_value += hash(model) + return return_hash_value + + def check(self) -> None: + # check that at least one ionization model in list + if len(self.ionization_model_list) == 0: + raise ValueError("at least one ionization model must be specified if ground_state_ionization is not none.") + + # call check() all ionization models + for ionization_model in self.ionization_model_list: + ionization_model.check() + + # check that no ionization model group is represented more than once + groups = IonizationModelGroups().get_by_group().keys() + + type_already_present = {} + for group in groups: + type_already_present[group] = False + + by_model = IonizationModelGroups().get_by_model() + for ionization_model in self.ionization_model_list: + group: str = by_model[type(ionization_model)] + if type_already_present[group]: + raise ValueError(f"ionization model group already represented: {group}") + else: + type_already_present[group] = True + + def get_species_dependencies(self) -> list[type]: + """get all species one of the ionization models in ionization_model_list depends on""" + + total_species_dependencies = [] + for ionization_model in self.ionization_model_list: + species_dependencies = ionization_model.get_species_dependencies() + for species in species_dependencies: + if species not in total_species_dependencies: + total_species_dependencies.append(species) + + return total_species_dependencies + + def get_attribute_dependencies(self) -> list[type]: + """get all attributes one of the ionization models in ionization_model_list depends on""" + total_attribute_dependencies = [] + for ionization_model in self.ionization_model_list: + attribute_dependencies = ionization_model.get_attribute_dependencies() + for attribute in attribute_dependencies: + if attribute not in total_attribute_dependencies: + total_attribute_dependencies.append(attribute) + + return total_attribute_dependencies + + def get_constant_dependencies(self) -> list[type]: + """get all constants one of the ionization models in ionization_model_list depends on""" + total_constant_dependencies = [] + for ionization_model in self.ionization_model_list: + constant_dependencies = ionization_model.get_constant_dependencies() + for constant in constant_dependencies: + if constant not in total_constant_dependencies: + total_constant_dependencies.append(constant) + + return total_constant_dependencies + + def _get_serialized(self) -> dict[str, list[dict[str, typing.Any]]]: + self.check() + + list_serialized = [] + for ionization_model in self.ionization_model_list: + list_serialized.append(ionization_model.get_generic_rendering_context()) + + return {"ionization_model_list": list_serialized} diff --git a/lib/python/picongpu/pypicongpu/species/constant/ionizationcurrent/__init__.py b/lib/python/picongpu/pypicongpu/species/constant/ionizationcurrent/__init__.py new file mode 100644 index 0000000000..b5f00f4a4a --- /dev/null +++ b/lib/python/picongpu/pypicongpu/species/constant/ionizationcurrent/__init__.py @@ -0,0 +1,4 @@ +from .ionizationcurrent import IonizationCurrent +from .none_ import None_ + +__all__ = ["IonizationCurrent", "None_"] diff --git a/lib/python/picongpu/pypicongpu/species/constant/ionizationcurrent/ionizationcurrent.py b/lib/python/picongpu/pypicongpu/species/constant/ionizationcurrent/ionizationcurrent.py new file mode 100644 index 0000000000..a00c469f62 --- /dev/null +++ b/lib/python/picongpu/pypicongpu/species/constant/ionizationcurrent/ionizationcurrent.py @@ -0,0 +1,42 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from ..constant import Constant + +import pydantic +import typing + +import typeguard + + +@typeguard.typechecked +class IonizationCurrent(Constant, pydantic.BaseModel): + """base class for all ionization currents models""" + + PICONGPU_NAME: str + """C++ Code type name of ionizer""" + + def check(self) -> None: + # nothing to check here + pass + + def _get_serialized(self) -> dict: + # do not remove!, always check + self.check() + return {"picongpu_name": self.PICONGPU_NAME} + + def get_generic_rendering_context(self) -> dict: + return IonizationCurrent(PICONGPU_NAME=self.PICONGPU_NAME).get_rendering_context() + + def get_species_dependencies(self): + return [] + + def get_attribute_dependencies(self) -> typing.List[type]: + return [] + + def get_constant_dependencies(self) -> typing.List[type]: + return [] diff --git a/lib/python/picongpu/pypicongpu/species/constant/ionizationcurrent/none_.py b/lib/python/picongpu/pypicongpu/species/constant/ionizationcurrent/none_.py new file mode 100644 index 0000000000..7019a0db44 --- /dev/null +++ b/lib/python/picongpu/pypicongpu/species/constant/ionizationcurrent/none_.py @@ -0,0 +1,15 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from .ionizationcurrent import IonizationCurrent + +import typeguard + + +@typeguard.typechecked +class None_(IonizationCurrent): + PICONGPU_NAME: str = "None" diff --git a/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/ADKcircularpolarization.py b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/ADKcircularpolarization.py new file mode 100644 index 0000000000..ea1c876faa --- /dev/null +++ b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/ADKcircularpolarization.py @@ -0,0 +1,28 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from .ionizationmodel import IonizationModel +from ..ionizationcurrent import IonizationCurrent + + +class ADKCircularPolarization(IonizationModel): + """ + Ammosov-Delone-Krainov tunnelling ionization for hydrogenlike atoms model -- circular polarization + + see for example: Delone, N. B.; Krainov, V. P. (1998). + "Tunneling and barrier-suppression ionization of atoms and ions in a laser radiation field" + doi:10.1070/PU1998v041n05ABEH000393 + + @attention this model is derived for near constant fields and may give erroneous predictions for rapidly changing + high intensity laser fields. + """ + + PICONGPU_NAME: str = "ADKCircPol" + """C++ Code type name of ionizer""" + + ionization_current: IonizationCurrent + """ionization current implementation to use""" diff --git a/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/ADKlinearpolarization.py b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/ADKlinearpolarization.py new file mode 100644 index 0000000000..7e1a3e8e8a --- /dev/null +++ b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/ADKlinearpolarization.py @@ -0,0 +1,28 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from .ionizationmodel import IonizationModel +from ..ionizationcurrent import IonizationCurrent + + +class ADKLinearPolarization(IonizationModel): + """ + Ammosov-Delone-Krainov tunnelling ionization for hydrogenlike atoms model -- linear polarization + + see for example: Delone, N. B.; Krainov, V. P. (1998). + "Tunneling and barrier-suppression ionization of atoms and ions in a laser radiation field" + doi:10.1070/PU1998v041n05ABEH000393 + + @attention this model is derived for near constant fields and may give erroneous predictions for rapidly changing + high intensity laser fields. + """ + + PICONGPU_NAME: str = "ADKLinearPol" + """C++ Code type name of ionizer""" + + ionization_current: IonizationCurrent + """ionization current implementation to use""" diff --git a/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/BSI.py b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/BSI.py new file mode 100644 index 0000000000..5ecd59bda1 --- /dev/null +++ b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/BSI.py @@ -0,0 +1,31 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from .ionizationmodel import IonizationModel +from ..ionizationcurrent import IonizationCurrent + + +class BSI(IonizationModel): + """ + Barrier Suppression Ionization for hydrogen-like ions + + see for example: Delone, N. B.; Krainov, V. P. (1998). + "Tunneling and barrier-suppression ionization of atoms and ions in a laser radiation field" + doi:10.1070/PU1998v041n05ABEH000393 + + Calculates the electric field strength limit necessary to overcome the binding energy of the electron to the + core. If this limit exceed by the local electric field strength of an ion the ion is ionized. + + This model uses for naive inner electron charge shielding, assumes that the charge the electron 'feels' is equal to + `proton number - number of inner shell electrons`, but neglects the Stark upshift of ionization energies. + """ + + PICONGPU_NAME: str = "BSI" + """C++ Code type name of ionizer""" + + ionization_current: IonizationCurrent + """ionization current implementation to use""" diff --git a/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/BSIeffectiveZ.py b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/BSIeffectiveZ.py new file mode 100644 index 0000000000..01ed232ec3 --- /dev/null +++ b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/BSIeffectiveZ.py @@ -0,0 +1,26 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from .ionizationmodel import IonizationModel +from ..ionizationcurrent import IonizationCurrent + + +class BSIEffectiveZ(IonizationModel): + """ + Barrier Suppression Ionization for hydrogen-like ions, using tabulated Z_effective values + + see BSI.py for further information + + Variant of the BSI ionization model using tabulated Z_effective values instead of the naive inner electron charge + shielding, but still neglecting the Stark upshift of ionization energies. + """ + + PICONGPU_NAME: str = "BSIEffectiveZ" + """C++ Code type name of ionizer""" + + ionization_current: IonizationCurrent + """ionization current implementation to use""" diff --git a/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/BSIstarkshifted.py b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/BSIstarkshifted.py new file mode 100644 index 0000000000..c9b77d2111 --- /dev/null +++ b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/BSIstarkshifted.py @@ -0,0 +1,25 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from .ionizationmodel import IonizationModel +from ..ionizationcurrent import IonizationCurrent + + +class BSIStarkShifted(IonizationModel): + """ + Barrier Suppression Ionization for hydrogen-like ions, accounting for stark upshift of ionization energies + + see BSI.py for further information + + Variant of the BSI ionization model accounting for the Stark upshift of ionization energies. + """ + + PICONGPU_NAME: str = "BSIStarkShifted" + """C++ Code type name of ionizer""" + + ionization_current: IonizationCurrent + """ionization current implementation to use""" diff --git a/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/__init__.py b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/__init__.py new file mode 100644 index 0000000000..46f81a32b4 --- /dev/null +++ b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/__init__.py @@ -0,0 +1,21 @@ +from .ionizationmodel import IonizationModel +from .ionizationmodelgroups import IonizationModelGroups +from .BSI import BSI +from .BSIeffectiveZ import BSIEffectiveZ +from .BSIstarkshifted import BSIStarkShifted +from .ADKlinearpolarization import ADKLinearPolarization +from .ADKcircularpolarization import ADKCircularPolarization +from .keldysh import Keldysh +from .thomasfermi import ThomasFermi + +__all__ = [ + "IonizationModel", + "IonizationModelGroups", + "BSI", + "BSIEffectiveZ", + "BSIStarkShifted", + "ADKLinearPolarization", + "ADKCircularPolarization", + "Keldysh", + "ThomasFermi", +] diff --git a/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/ionizationmodel.py b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/ionizationmodel.py new file mode 100644 index 0000000000..bbf1276f9a --- /dev/null +++ b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/ionizationmodel.py @@ -0,0 +1,99 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from ..constant import Constant +from ...attribute import BoundElectrons +from ..ionizationcurrent import IonizationCurrent +from ..elementproperties import ElementProperties + +import pydantic +import typing +import typeguard + + +@typeguard.typechecked +class IonizationModel(pydantic.BaseModel, Constant): + """ + base class for an ground state only ionization models of an ion species + + Owned by exactly one species. + + Identified by its PIConGPU name. + + PIConGPU term: "ionizer" + """ + + PICONGPU_NAME: str + """C++ Code type name of ionizer""" + + # no typecheck here -- would require circular imports + ionization_electron_species: typing.Any = None + """species to be used as electrons""" + + ionization_current: typing.Optional[IonizationCurrent] = None + """ionization current implementation to use""" + + def check(self) -> None: + """check internal consistency""" + + # import here to avoid circular import + from ...species import Species + from ..groundstateionization import GroundStateIonization + + # check ionization electron species is actually pypicongpu species instance + if not isinstance(self.ionization_electron_species, Species): + raise TypeError("ionization_electron_species must be of type pypicongpu Species") + + # electron species must not be an ionizable + if self.ionization_electron_species.has_constant_of_type(GroundStateIonization): + raise ValueError( + "used electron species {} must not be ionizable itself".format(self.ionization_electron_species.name) + ) + + # test ionization current set if required + test = self.ionization_current # noqa + + # note: do **NOT** check() electron species here + # -> it is not fully initialized at this point in the initialization + # (check requires attributes which are added last, + # but constants are added first) + + def _get_serialized(self) -> dict[str, typing.Any]: + # do not remove!, always do a check call + self.check() + + if self.ionization_current is None: + # case no ionization_current configurable + return { + "ionizer_picongpu_name": self.PICONGPU_NAME, + "ionization_electron_species": self.ionization_electron_species.get_rendering_context(), + "ionization_current": None, + } + + # default case + return { + "ionizer_picongpu_name": self.PICONGPU_NAME, + "ionization_electron_species": self.ionization_electron_species.get_rendering_context(), + "ionization_current": self.ionization_current.get_generic_rendering_context(), + } + + def get_generic_rendering_context(self) -> dict[str, typing.Any]: + return IonizationModel( + PICONGPU_NAME=self.PICONGPU_NAME, + ionization_electron_species=self.ionization_electron_species, + ionization_current=self.ionization_current, + ).get_rendering_context() + + def get_species_dependencies(self) -> list[typing.Any]: + self.check() + return [self.ionization_electron_species] + + def get_attribute_dependencies(self) -> list[type]: + return [BoundElectrons] + + def get_constant_dependencies(self) -> list[type]: + return [ElementProperties] diff --git a/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/ionizationmodelgroups.py b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/ionizationmodelgroups.py new file mode 100644 index 0000000000..b68f3b678b --- /dev/null +++ b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/ionizationmodelgroups.py @@ -0,0 +1,46 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from .BSI import BSI +from .BSIeffectiveZ import BSIEffectiveZ +from .BSIstarkshifted import BSIStarkShifted +from .ADKlinearpolarization import ADKLinearPolarization +from .ADKcircularpolarization import ADKCircularPolarization +from .keldysh import Keldysh +from .thomasfermi import ThomasFermi +from .ionizationmodel import IonizationModel + +import copy +import typing +import pydantic + + +class IonizationModelGroups(pydantic.BaseModel): + """ + grouping of ionization models into sub groups that may not be used at the same time + + every instance of this class is immutable, all method always return copies of the data contained + """ + + by_group: dict[str, list[typing.Type[IonizationModel]]] = { + "BSI_like": [BSI, BSIEffectiveZ, BSIStarkShifted], + "ADK_like": [ADKLinearPolarization, ADKCircularPolarization], + "Keldysh_like": [Keldysh], + "electronic_collisional_equilibrium": [ThomasFermi], + } + + def get_by_group(self) -> dict[str, list[typing.Type[IonizationModel]]]: + return copy.deepcopy(self.by_group) + + def get_by_model(self) -> dict[typing.Type[IonizationModel], str]: + return_dict: dict[typing.Type[IonizationModel], str] = {} + + for ionization_model_type, list_ionization_model in self.by_group.items(): + for ionization_model in list_ionization_model: + return_dict[ionization_model] = copy.deepcopy(ionization_model_type) + + return return_dict diff --git a/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/keldysh.py b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/keldysh.py new file mode 100644 index 0000000000..b61876fc58 --- /dev/null +++ b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/keldysh.py @@ -0,0 +1,29 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from .ionizationmodel import IonizationModel +from ..ionizationcurrent import IonizationCurrent + + +class Keldysh(IonizationModel): + """ + Keldysh multi photon ionization + + see for example: D. Bauer and P. Mulser(1999) + "Exact field ionization rates in the barrier-suppression regime from numerical time-dependent + Schroedinger-equation calculations" + Physical Review A, 59(1):569+, January 1999 + + @attention this model is derived for near constant fields and may give erroneous predictions for rapidly changing + high intensity laser fields. + """ + + PICONGPU_NAME: str = "Keldysh" + """C++ Code type name of ionizer""" + + ionization_current: IonizationCurrent + """ionization current implementation to use""" diff --git a/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/thomasfermi.py b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/thomasfermi.py new file mode 100644 index 0000000000..fc88413c31 --- /dev/null +++ b/lib/python/picongpu/pypicongpu/species/constant/ionizationmodel/thomasfermi.py @@ -0,0 +1,30 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from .ionizationmodel import IonizationModel + + +class ThomasFermi(IonizationModel): + """ + Thomas-Fermi impact ionization + + See table IV from Pressure Ionization, Resonances, and the Continuity of Bound and Free States + http://www.sciencedirect.com/science/article/pii/S0065219908601451 + doi:10.1016/S0065-2199(08)60145-1 + + This ionization model is based on the assumption of an "ion sphere", constructed based on describing electrons as a + density, a point charge atomic core and a finite atomic potential as a result of matter density. + + In this framework ionization may occur due to due to overlap of adjacent ion spheres lowering the ionization barrier + and causing electrons to become quasi-free in the system, being bound in resonance states. + + This model is used to calculate an average ionization degree with respect to local charge density and temperature. + This is extenden to arbitrary temperatures and atoms through fitting parameters and temperature cutoffs. + """ + + PICONGPU_NAME: str = "ThomasFermi" + """C++ Code type name of ionizer""" diff --git a/lib/python/picongpu/pypicongpu/species/constant/ionizers.py b/lib/python/picongpu/pypicongpu/species/constant/ionizers.py deleted file mode 100644 index e893de73a1..0000000000 --- a/lib/python/picongpu/pypicongpu/species/constant/ionizers.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -This file is part of PIConGPU. -Copyright 2021-2023 PIConGPU contributors -Authors: Hannes Troepgen, Brian Edward Marre -License: GPLv3+ -""" - -from .constant import Constant -from ..attribute import BoundElectrons -from .elementproperties import ElementProperties - -import typeguard -import typing - - -@typeguard.typechecked -class Ionizers(Constant): - """ - ionizers describing the ionization methods - - Currently the selected ionizers are fixed by the code generation. - When they are selectable by the user, they can be added here. - """ - - # note: no typecheck here -- which would require circular imports - electron_species = None - """species to be used as electrons""" - - def __init__(self): - # overwrite from parent - pass - - def check(self) -> None: - # import here to avoid circular import - from ..species import Species - - if not isinstance(self.electron_species, Species): - raise TypeError("electron_species must be of type pypicongpu Species") - - # electron species must not be ionizable - if self.electron_species.has_constant_of_type(Ionizers): - raise ValueError("used electron species {} must not be ionizable itself".format(self.electron_species.name)) - - # note: do **NOT** check() electron species here - # -> it is not fully initialized at this point in the initialization - # (check requires attributes which are added last, - # but constants are added first) - - def _get_serialized(self) -> dict: - # (please resist the temptation of removing the check b/c "its not - # needed here": checks should *always* be run before serialization, - # so make it a habit of expecting it everywhere) - self.check() - return { - "electron_species": self.electron_species.get_rendering_context(), - } - - def get_species_dependencies(self): - self.check() - return [self.electron_species] - - def get_attribute_dependencies(self) -> typing.List[type]: - return [BoundElectrons] - - def get_constant_dependencies(self) -> typing.List[type]: - return [ElementProperties] diff --git a/lib/python/picongpu/pypicongpu/species/initmanager.py b/lib/python/picongpu/pypicongpu/species/initmanager.py index a23562421d..01a6873b86 100644 --- a/lib/python/picongpu/pypicongpu/species/initmanager.py +++ b/lib/python/picongpu/pypicongpu/species/initmanager.py @@ -14,7 +14,7 @@ DensityOperation, SimpleDensity, SimpleMomentum, - SetBoundElectrons, + SetChargeState, ) from .attribute import Attribute from .constant import Constant @@ -68,6 +68,10 @@ def __init__(self) -> None: self.all_operations = [] self.__baked = False + def __hash__(self) -> int: + # every simulation may only ever have one InitManager + return hash(type(self)) + def __get_all_attributes(self): """ accumulate *all* attributes currently assigned to any species @@ -496,7 +500,7 @@ def _get_serialized(self) -> dict: operation_types_by_name = { "simple_density": SimpleDensity, "simple_momentum": SimpleMomentum, - "set_bound_electrons": SetBoundElectrons, + "set_charge_state": SetChargeState, # note: NotPlaced is not rendered (as it provides no data & does # nothing anyways) -> it is not in this list # same as NoBoundElectrons diff --git a/lib/python/picongpu/pypicongpu/species/operation/__init__.py b/lib/python/picongpu/pypicongpu/species/operation/__init__.py index 6fd4327caf..adc75ade30 100644 --- a/lib/python/picongpu/pypicongpu/species/operation/__init__.py +++ b/lib/python/picongpu/pypicongpu/species/operation/__init__.py @@ -4,7 +4,7 @@ from .notplaced import NotPlaced from .simplemomentum import SimpleMomentum from .noboundelectrons import NoBoundElectrons -from .setboundelectrons import SetBoundElectrons +from .setchargestate import SetChargeState from . import densityprofile from . import momentum @@ -16,7 +16,7 @@ "NotPlaced", "SimpleMomentum", "NoBoundElectrons", - "SetBoundElectrons", + "SetChargeState", "densityprofile", "momentum", ] diff --git a/lib/python/picongpu/pypicongpu/species/operation/noboundelectrons.py b/lib/python/picongpu/pypicongpu/species/operation/noboundelectrons.py index f4d9857d51..8923233bdc 100644 --- a/lib/python/picongpu/pypicongpu/species/operation/noboundelectrons.py +++ b/lib/python/picongpu/pypicongpu/species/operation/noboundelectrons.py @@ -8,7 +8,7 @@ from .operation import Operation from ..species import Species from ..attribute import BoundElectrons -from ..constant import Ionizers +from ..constant import GroundStateIonization from ... import util import typeguard @@ -25,13 +25,13 @@ class NoBoundElectrons(Operation): """ species = util.build_typesafe_property(Species) - """species which will have boundElectorns set to 0""" + """species which will have BoundElectrons set to 0""" def __init__(self): pass def check_preconditions(self) -> None: - assert self.species.has_constant_of_type(Ionizers), "BoundElectrons requires Ionizers" + assert self.species.has_constant_of_type(GroundStateIonization), "BoundElectrons requires GroundStateIonization" def prebook_species_attributes(self) -> None: self.attributes_by_species = { diff --git a/lib/python/picongpu/pypicongpu/species/operation/setboundelectrons.py b/lib/python/picongpu/pypicongpu/species/operation/setchargestate.py similarity index 57% rename from lib/python/picongpu/pypicongpu/species/operation/setboundelectrons.py rename to lib/python/picongpu/pypicongpu/species/operation/setchargestate.py index 2f33f844a2..53a475d5fb 100644 --- a/lib/python/picongpu/pypicongpu/species/operation/setboundelectrons.py +++ b/lib/python/picongpu/pypicongpu/species/operation/setchargestate.py @@ -8,37 +8,36 @@ from .operation import Operation from ..species import Species from ..attribute import BoundElectrons -from ..constant import Ionizers +from ..constant import GroundStateIonization from ... import util import typeguard @typeguard.typechecked -class SetBoundElectrons(Operation): +class SetChargeState(Operation): """ - assigns and set the boundElectrons attribute + assigns boundElectrons attribute and sets it to the initial charge state - Standard attribute for pre-ionization. + used for ionization of ions """ species = util.build_typesafe_property(Species) """species which will have boundElectrons set""" - bound_electrons = util.build_typesafe_property(int) + charge_state = util.build_typesafe_property(int) """number of bound electrons to set""" def __init__(self): pass def check_preconditions(self) -> None: - assert self.species.has_constant_of_type(Ionizers), "BoundElectrons requires Ionizers" + assert self.species.has_constant_of_type(GroundStateIonization), "BoundElectrons requires GroundStateIonization" - if self.bound_electrons < 0: - raise ValueError("bound electrons must be >0") + if self.charge_state < 0: + raise ValueError("charge state must be > 0") - if 0 == self.bound_electrons: - raise ValueError("bound electrons must be >0, use NoBoundElectrons to assign " "0 bound electrons") + # may not check for charge_state > Z since Z not known in this context def prebook_species_attributes(self) -> None: self.attributes_by_species = { @@ -48,5 +47,5 @@ def prebook_species_attributes(self) -> None: def _get_serialized(self) -> dict: return { "species": self.species.get_rendering_context(), - "bound_electrons": self.bound_electrons, + "charge_state": self.charge_state, } diff --git a/lib/python/picongpu/pypicongpu/species/species.py b/lib/python/picongpu/pypicongpu/species/species.py index 2d62f4793b..8e66890c64 100644 --- a/lib/python/picongpu/pypicongpu/species/species.py +++ b/lib/python/picongpu/pypicongpu/species/species.py @@ -7,7 +7,7 @@ from ..rendering import RenderedObject from .attribute import Attribute, Position, Momentum -from .constant import Constant, Charge, Mass, DensityRatio, Ionizers, ElementProperties +from .constant import Constant, Charge, Mass, DensityRatio, GroundStateIonization, ElementProperties from .. import util import typeguard @@ -25,7 +25,7 @@ class Species(RenderedObject): - A set of species constants (mass, charge, etc.), - a set of species attributes (position, number of bound electrons), and - a set of operations which collectively initialize these attributes, - where one attribute is initializated by exactly one operation. + where one attribute is initialized by exactly one operation. - (and a name) Note that some of the species attributes or constants are considered @@ -41,6 +41,22 @@ class Species(RenderedObject): name = util.build_typesafe_property(str) """name of the species""" + def __str__(self) -> str: + try: + return ( + self.name + + " : \n\t constants: " + + str(self.constants) + + "\n\t attributes: " + + str(self.attributes) + + "\n" + ) + except Exception: + try: + return self.name + " : \n\t constants: " + str(self.constants) + "\n" + except Exception: + return self.name + def get_cxx_typename(self) -> str: """ get (standalone) C++ name for this species @@ -50,6 +66,10 @@ def get_cxx_typename(self) -> str: return "species_" + self.name + def __hash__(self): + # species must be uniquely defined by name + return hash(self.name) + def check(self) -> None: """ sanity-check self, if ok pass silently @@ -75,7 +95,7 @@ def check(self) -> None: # position if Position not in [type(a) for a in self.attributes]: raise ValueError("Each species must have the position attribute!") - # momentum + # momentum, @todo really necessary?, Brian Marre, 2024 if Momentum not in [type(a) for a in self.attributes]: raise ValueError("Each species must have the momentum attribute!") @@ -125,7 +145,7 @@ def has_constant_of_type(self, needle_type: typing.Type[Constant]) -> bool: """ lookup if constant of given type is present - Searches through constants of this species and returns true iff a + Searches through constants of this species and returns true if a constant of the given type is present. :param needle_type: constant type to look for @@ -148,7 +168,7 @@ def _get_serialized(self) -> dict: # a typo in the variable name in prints a warning (still continues # though -- to be compliant to the rendering standard). # - # To accomodate this behavior, we always define all keys for constant, + # To accommodate this behavior, we always define all keys for constant, # but maybe set them to null. For this below there is a list of *all # known constants*. When adding a constant do not forget to add it in # the JSON schema too. @@ -160,8 +180,8 @@ def _get_serialized(self) -> dict: "mass": Mass, "charge": Charge, "density_ratio": DensityRatio, - "ionizers": Ionizers, "element_properties": ElementProperties, + "ground_state_ionization": GroundStateIonization, } constants_context = {} diff --git a/lib/python/picongpu/pypicongpu/species/util/element.py b/lib/python/picongpu/pypicongpu/species/util/element.py index 881a42246e..83060592ac 100644 --- a/lib/python/picongpu/pypicongpu/species/util/element.py +++ b/lib/python/picongpu/pypicongpu/species/util/element.py @@ -7,52 +7,84 @@ from ...rendering import RenderedObject +import pydantic import typeguard -import enum +import typing import scipy +import periodictable +import re @typeguard.typechecked -class Element(RenderedObject, enum.Enum): +class Element(RenderedObject, pydantic.BaseModel): """ Denotes an element from the periodic table of elements Used to provide fundamental constants for elements, and to map them in a type-safe way to PIConGPU. - The number associated is the number of protons. - Note: Spelling follows periodic table, e.g. "Na", "C", "He" + The number associated is just an id. + Note: Spelling follows periodic table, e.g. "Na", "C", "He" + typical nuclear variations Note that these denote Elements, but when initialized in a species *only* - represent the core, i.e. there are no electrons. To make an atom also - initialize an appropriate ionization. + describe the core, i.e. without electrons. + To describe atoms/ions you also need to initialize the charge_state of the species. """ - H = 1 - """hydrogen""" - He = 2 - """helium""" - N = 7 - """nitrogen""" + _store: typing.Optional[periodictable.core.Element] = None @staticmethod - def get_by_openpmd_name(openpmd_name: str) -> "Element": + def parse_openpmd_isotopes(openpmd_name: str) -> tuple[int | None, str]: + if openpmd_name == "": + raise ValueError("Empty string is not a valid openPMD particle type") + if openpmd_name[0] != "#" and re.match(r"[A-Z][a-z]?$|n$", openpmd_name): + return None, openpmd_name + + m = re.match(r"#([1-9][0-9]*)([A-Z][a-z]?)$", openpmd_name) + + if m is None: + raise ValueError(f"{openpmd_name} is not a valid openPMD particle type") + + mass_number = int(m.group(1)) + symbol = m.group(2) + + return mass_number, symbol + + @staticmethod + def is_element(openpmd_name: str) -> bool: + """does openpmd_name describe an element?""" + mass_number, symbol = Element.parse_openpmd_isotopes(openpmd_name) + + for element in periodictable.elements: + if symbol == element.symbol: + if openpmd_name not in ["n"]: + return True + return False + + def __init__(self, openpmd_name: str) -> None: """ get the correct substance implementation from a openPMD type name - Names are (case-sensitive) element symbols (e.g. "H", "He", "N"). + @param openpmd_name (case-sensitive) chemical/nuclear element symbols (e.g. "H", "D", "He", "N"). - :param openpmd_name: single species following openPMD species extension :return: object representing the given species """ - element_by_openpmd_name = { - "H": Element.H, - "He": Element.He, - "N": Element.N, - } - if openpmd_name not in element_by_openpmd_name: - raise NameError("unkown element: {}".format(openpmd_name)) - return element_by_openpmd_name[openpmd_name] + pydantic.BaseModel.__init__(self) + + mass_number, openpmd_name = Element.parse_openpmd_isotopes(openpmd_name) + + found = False + # search for name in periodic table + for element in periodictable.elements: + if openpmd_name == element.symbol: + if mass_number is None: + self._store = element + else: + self._store = element[mass_number] + found = True + + if not found: + raise NameError(f"unknown element: {openpmd_name}") def get_picongpu_name(self) -> str: """ @@ -60,12 +92,9 @@ def get_picongpu_name(self) -> str: Used for type name lookups """ - picongpu_name_by_element = { - Element.H: "Hydrogen", - Element.He: "Helium", - Element.N: "Nitrogen", - } - return picongpu_name_by_element[self] + name = self._store.name + # element names are capitalized in piconpgu + return name[0].upper() + name[1:] def get_mass_si(self) -> float: """ @@ -76,12 +105,7 @@ def get_mass_si(self) -> float: :return: mass in kg """ - mass_by_particle = { - Element.H: 1.008 * scipy.constants.atomic_mass, - Element.He: 4.0026 * scipy.constants.atomic_mass, - Element.N: 14.007 * scipy.constants.atomic_mass, - } - return mass_by_particle[self] + return self._store.mass * scipy.constants.atomic_mass def get_charge_si(self) -> float: """ @@ -91,10 +115,17 @@ def get_charge_si(self) -> float: :return: charge in C """ - return self.value * scipy.constants.elementary_charge + return self._store.ions[-1] * scipy.constants.elementary_charge + + def get_atomic_number(self) -> int: + return self._store.number + + def get_symbol(self) -> str: + """get symbol""" + return self._store.symbol def _get_serialized(self) -> dict: return { - "symbol": self.name, + "symbol": self.get_symbol(), "picongpu_name": self.get_picongpu_name(), } diff --git a/share/ci/bash.profile b/share/ci/bash.profile index 72a6f1e439..a52cc992b5 100755 --- a/share/ci/bash.profile +++ b/share/ci/bash.profile @@ -22,6 +22,9 @@ export CMAKE_PREFIX_PATH=$ADIOS2_ROOT:$CMAKE_PREFIX_PATH export PATH=$ADIOS2_ROOT/bin:$PATH export LD_LIBRARY_PATH=$ADIOS2_ROOT/lib:$LD_LIBRARY_PATH +# set environment variable for path to tpls for PyPIConGPU runner +export PIC_SYSTEM_TEMPLATE_PATH=${PIC_SYSTEM_TEMPLATE_PATH:-"etc/picongpu/bash"} + if [ -z "$DISABLE_ISAAC" ] ; then export ICET_ROOT=/opt/icet/2.9.0 export CMAKE_PREFIX_PATH=$ICET_ROOT/lib:$CMAKE_PREFIX_PATH diff --git a/share/picongpu/pypicongpu/examples/laser_wakefield/.gitignore b/share/picongpu/pypicongpu/examples/laser_wakefield/.gitignore new file mode 100644 index 0000000000..07cd22f55b --- /dev/null +++ b/share/picongpu/pypicongpu/examples/laser_wakefield/.gitignore @@ -0,0 +1,2 @@ +# This folder is produced by default upon execution: +LWFA diff --git a/share/picongpu/pypicongpu/examples/laser_wakefield/main.py b/share/picongpu/pypicongpu/examples/laser_wakefield/main.py new file mode 100644 index 0000000000..c3872142b8 --- /dev/null +++ b/share/picongpu/pypicongpu/examples/laser_wakefield/main.py @@ -0,0 +1,208 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Masoud Afshari, Brian Edward Marre +License: GPLv3+ +""" + +from picongpu import picmi +from picongpu import pypicongpu +import numpy as np + +""" +@file PICMI user script reproducing the PIConGPU LWFA example + +This Python script is example PICMI user script reproducing the LaserWakefield example setup, based on 8.cfg. +""" + +# generation modifiers +ENABLE_IONS = True +ENABLE_IONIZATION = True +ADD_CUSTOM_INPUT = True +OUTPUT_DIRECTORY_PATH = "LWFA" + +numberCells = np.array([192, 2048, 192]) +cellSize = np.array([0.1772e-6, 0.4430e-7, 0.1772e-6]) # unit: meter) + +# Define the simulation grid based on grid.param +grid = picmi.Cartesian3DGrid( + picongpu_n_gpus=[2, 4, 1], + number_of_cells=numberCells.tolist(), + lower_bound=[0, 0, 0], + upper_bound=(numberCells * cellSize).tolist(), + lower_boundary_conditions=["open", "open", "open"], + upper_boundary_conditions=["open", "open", "open"], +) + +gaussianProfile = picmi.distribution.GaussianDistribution( + density=1.0e25, + center_front=8.0e-5, + sigma_front=8.0e-5, + center_rear=10.0e-5, + sigma_rear=8.0e-5, + factor=-1.0, + power=4.0, + vacuum_cells_front=50, +) + +solver = picmi.ElectromagneticSolver( + grid=grid, + method="Yee", +) + +laser = picmi.GaussianLaser( + wavelength=0.8e-6, + waist=5.0e-6 / 1.17741, + duration=5.0e-15, + propagation_direction=[0.0, 1.0, 0.0], + polarization_direction=[1.0, 0.0, 0.0], + focal_position=[float(numberCells[0] * cellSize[0] / 2.0), 4.62e-5, float(numberCells[2] * cellSize[2] / 2.0)], + centroid_position=[float(numberCells[0] * cellSize[0] / 2.0), 0.0, float(numberCells[2] * cellSize[2] / 2.0)], + picongpu_polarization_type=pypicongpu.laser.GaussianLaser.PolarizationType.CIRCULAR, + a0=8.0, + picongpu_phase=0.0, +) + +random_layout = picmi.PseudoRandomLayout(n_macroparticles_per_cell=2) + +# Initialize particles based on speciesInitialization.param +# simulation schema : https://github.com/BrianMarre/picongpu/blob/2ddcdab4c1aca70e1fc0ba02dbda8bd5e29d98eb/share/picongpu/pypicongpu/schema/simulation.Simulation.json + +# for particle type see https://github.com/openPMD/openPMD-standard/blob/upcoming-2.0.0/EXT_SpeciesType.md +species_list = [] +if not ENABLE_IONIZATION: + interaction = None + + electron_placed = picmi.Species(particle_type="electron", name="electron", initial_distribution=gaussianProfile) + species_list.append((electron_placed, random_layout)) + + if ENABLE_IONS: + hydrogen_fully_ionized = picmi.Species( + particle_type="H", name="hydrogen", picongpu_fixed_charge=True, initial_distribution=gaussianProfile + ) + species_list.append((hydrogen_fully_ionized, random_layout)) +else: + if not ENABLE_IONS: + raise ValueError("Ions species required for ionization") + + hydrogen_with_ionization = picmi.Species( + particle_type="H", name="hydrogen", charge_state=0, initial_distribution=gaussianProfile + ) + species_list.append((hydrogen_with_ionization, random_layout)) + + electron_not_placed = picmi.Species(particle_type="electron", name="electron", initial_distribution=None) + species_list.append((electron_not_placed, None)) + + adk_ionization_model = picmi.ADK( + ADK_variant=picmi.ADKVariant.CircularPolarization, + ion_species=hydrogen_with_ionization, + ionization_electron_species=electron_not_placed, + ionization_current=None, + ) + + bsi_effectiveZ_ionization_model = picmi.BSI( + BSI_extensions=[picmi.BSIExtension.EffectiveZ], + ion_species=hydrogen_with_ionization, + ionization_electron_species=electron_not_placed, + ionization_current=None, + ) + + interaction = picmi.Interaction( + ground_state_ionization_model_list=[adk_ionization_model, bsi_effectiveZ_ionization_model] + ) + +sim = picmi.Simulation( + solver=solver, + max_steps=4000, + time_step_size=1.39e-16, + picongpu_moving_window_move_point=0.9, + picongpu_interaction=interaction, +) +for species, layout in species_list: + sim.add_species(species, layout=layout) + +sim.add_laser(laser, None) + +# additional non standardized custom user input +# only active if custom templates are used + +# for generating setup with custom input see standard implementation, +# see https://picongpu.readthedocs.io/en/latest/usage/picmi/custom_template.html +if ADD_CUSTOM_INPUT: + min_weight_input = pypicongpu.customuserinput.CustomUserInput() + 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( + { + "png_plugin_data_list": "['Ex', 'Ey', 'Ez', 'Bx', 'By', 'Bz', 'Jx', 'Jy', 'Jz']", + "png_plugin_SCALE_IMAGE": 1.0, + "png_plugin_SCALE_TO_CELLSIZE": True, + "png_plugin_WHITE_BOX_PER_GPU": False, + "png_plugin_EM_FIELD_SCALE_CHANNEL1": 7, + "png_plugin_EM_FIELD_SCALE_CHANNEL2": -1, + "png_plugin_EM_FIELD_SCALE_CHANNEL3": -1, + "png_plugin_CUSTOM_NORMALIZATION_SI": "5.0e12 / constants.c, 5.0e12, 15.0", + "png_plugin_PRE_PARTICLE_DENS_OPACITY": 0.25, + "png_plugin_PRE_CHANNEL1_OPACITY": 1.0, + "png_plugin_PRE_CHANNEL2_OPACITY": 1.0, + "png_plugin_PRE_CHANNEL3_OPACITY": 1.0, + "png_plugin_preParticleDensCol": "colorScales::grayInv", + "png_plugin_preChannel1Col": "colorScales::green", + "png_plugin_preChannel2Col": "colorScales::none", + "png_plugin_preChannel3Col": "colorScales::none", + "png_plugin_preChannel1": "field_E.x() * field_E.x();", + "png_plugin_preChannel2": "field_E.y()", + "png_plugin_preChannel3": "-1.0_X * field_E.y()", + "png_plugin_period": 100, + "png_plugin_axis": "yx", + "png_plugin_slicePoint": 0.5, + "png_plugin_species_name": "electron", + "png_plugin_folder_name": "pngElectronsYX", + }, + "png plugin configuration", + ) + + output_configuration.addToCustomInput( + { + "energy_histogram_species_name": "electron", + "energy_histogram_period": 100, + "energy_histogram_bin_count": 1024, + "energy_histogram_min_energy": 0.0, + "energy_histogram_maxEnergy": 1000.0, + "energy_histogram_filter": "all", + }, + "energy histogram plugin configuration", + ) + + output_configuration.addToCustomInput( + { + "phase_space_species_name": "electron", + "phase_space_period": 100, + "phase_space_space": "y", + "phase_space_momentum": "py", + "phase_space_min": -1.0, + "phase_space_max": 1.0, + "phase_space_filter": "all", + }, + "phase space plugin configuration", + ) + + output_configuration.addToCustomInput( + {"opnePMD_period": 100, "opnePMD_file": "simData", "opnePMD_extension": "bp"}, "openPMD plugin configuration" + ) + + output_configuration.addToCustomInput( + {"checkpoint_period": 100, "checkpoint_backend": "openPMD", "checkpoint_restart_backend": "openPMD"}, + "checkpoint configuration", + ) + + output_configuration.addToCustomInput( + {"macro_particle_count_period": 100, "macro_particle_count_species_name": "electron"}, + "macro particle count plugin configuration", + ) + sim.picongpu_add_custom_user_input(output_configuration) + +if __name__ == "__main__": + sim.write_input_file(OUTPUT_DIRECTORY_PATH) diff --git a/share/picongpu/pypicongpu/examples/warm_plasma/.gitignore b/share/picongpu/pypicongpu/examples/warm_plasma/.gitignore index 470af14ac9..8015854005 100644 --- a/share/picongpu/pypicongpu/examples/warm_plasma/.gitignore +++ b/share/picongpu/pypicongpu/examples/warm_plasma/.gitignore @@ -1 +1,2 @@ -generated_input +# This folder is produced by default upon execution: +warm_plasma diff --git a/share/picongpu/pypicongpu/examples/warm_plasma/main.py b/share/picongpu/pypicongpu/examples/warm_plasma/main.py index cec188ff8a..ffed6dbb10 100644 --- a/share/picongpu/pypicongpu/examples/warm_plasma/main.py +++ b/share/picongpu/pypicongpu/examples/warm_plasma/main.py @@ -1,5 +1,14 @@ +""" +This file is part of PIConGPU. +Copyright 2021-2024 PIConGPU contributors +Authors: Hannes Troepgen +License: GPLv3+ +""" + from picongpu import picmi +OUTPUT_DIRECTORY_PATH = "warm_plasma" + boundary_conditions = ["periodic", "periodic", "periodic"] grid = picmi.Cartesian3DGrid( # note: [x] * 3 == [x, x, x] @@ -36,4 +45,5 @@ layout = picmi.PseudoRandomLayout(n_macroparticles_per_cell=25) sim.add_species(electron, layout) -sim.write_input_file("generated_input") +if __name__ == "__main__": + sim.write_input_file(OUTPUT_DIRECTORY_PATH) diff --git a/share/picongpu/pypicongpu/schema/species/constant/groundstateionization.GroundStateIonization.json b/share/picongpu/pypicongpu/schema/species/constant/groundstateionization.GroundStateIonization.json new file mode 100644 index 0000000000..7f69ab32cc --- /dev/null +++ b/share/picongpu/pypicongpu/schema/species/constant/groundstateionization.GroundStateIonization.json @@ -0,0 +1,14 @@ +{ + "$id":"https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.constant.groundstateionization.GroundStateIonization", + "required":["ionization_model_list"], + "unevaluatedProperties":false, + "properties": { + "ionization_model_list": { + "type": "array", + "description": "list of ionization models for species", + "items": { + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.constant.ionizationmodel.ionizationmodel.IonizationModel" + } + } + } +} diff --git a/share/picongpu/pypicongpu/schema/species/constant/ionizationcurrent.ionizationcurrent.IonizationCurrent.json b/share/picongpu/pypicongpu/schema/species/constant/ionizationcurrent.ionizationcurrent.IonizationCurrent.json new file mode 100644 index 0000000000..144f85238f --- /dev/null +++ b/share/picongpu/pypicongpu/schema/species/constant/ionizationcurrent.ionizationcurrent.IonizationCurrent.json @@ -0,0 +1,15 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.constant.ionizationcurrent.ionizationcurrent.IonizationCurrent", + "type": "object", + "description": "ionization current configuration", + "required": ["picongpu_name"], + "unevaluatedProperties": false, + "properties": { + "picongpu_name": { + "type": "string", + "description": "c++ code name of ionization current corresponding to ionization current model", + "minLength": 1, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + } +} diff --git a/share/picongpu/pypicongpu/schema/species/constant/ionizationmodel.Implementation.json b/share/picongpu/pypicongpu/schema/species/constant/ionizationmodel.Implementation.json new file mode 100644 index 0000000000..a682515dba --- /dev/null +++ b/share/picongpu/pypicongpu/schema/species/constant/ionizationmodel.Implementation.json @@ -0,0 +1,3 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/quick.pypicongpu.species.constant.ionizationmodel.ionizationmodel.Implementation" +} diff --git a/share/picongpu/pypicongpu/schema/species/constant/ionizationmodel.ionizationmodel.IonizationModel.json b/share/picongpu/pypicongpu/schema/species/constant/ionizationmodel.ionizationmodel.IonizationModel.json new file mode 100644 index 0000000000..386600b35f --- /dev/null +++ b/share/picongpu/pypicongpu/schema/species/constant/ionizationmodel.ionizationmodel.IonizationModel.json @@ -0,0 +1,28 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.constant.ionizationmodel.ionizationmodel.IonizationModel", + "type": "object", + "unevaluatedProperties": false, + "required": [ + "ionizer_picongpu_name", + "ionization_electron_species", + "ionization_current" + ], + "properties": { + "ionizer_picongpu_name": { + "type": "string", + "description": "c++ code name of ionizer corresponding to ionization model", + "minLength": 1, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "ionization_electron_species": { + "description": "Electron species spawned by ionization.", + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species" + }, + "ionization_current": { + "anyOf": [ + {"type": "null"}, + {"$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.constant.ionizationcurrent.ionizationcurrent.IonizationCurrent"} + ] + } + } +} diff --git a/share/picongpu/pypicongpu/schema/species/constant/ionizers.Ionizers.json b/share/picongpu/pypicongpu/schema/species/constant/ionizers.Ionizers.json deleted file mode 100644 index 0ccd74a4fe..0000000000 --- a/share/picongpu/pypicongpu/schema/species/constant/ionizers.Ionizers.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.constant.ionizers.Ionizers", - "description": "used ionization methods and electron species", - "type": "object", - "unevaluatedProperties": false, - "required": ["electron_species"], - "properties": { - "electron_species": { - "description": "electrons used for ionization", - "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species" - } - } -} diff --git a/share/picongpu/pypicongpu/schema/species/initmanager.InitManager.json b/share/picongpu/pypicongpu/schema/species/initmanager.InitManager.json index fe5e2b6757..e7fd901403 100644 --- a/share/picongpu/pypicongpu/schema/species/initmanager.InitManager.json +++ b/share/picongpu/pypicongpu/schema/species/initmanager.InitManager.json @@ -35,10 +35,10 @@ "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.operation.simplemomentum.SimpleMomentum" } }, - "set_bound_electrons": { + "set_charge_state": { "type": "array", "items": { - "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.operation.setboundelectrons.SetBoundElectrons" + "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.operation.setchargestate.SetChargeState" } } } diff --git a/share/picongpu/pypicongpu/schema/species/operation/setboundelectrons.SetBoundElectrons.json b/share/picongpu/pypicongpu/schema/species/operation/setchargestate.SetChargeState.json similarity index 66% rename from share/picongpu/pypicongpu/schema/species/operation/setboundelectrons.SetBoundElectrons.json rename to share/picongpu/pypicongpu/schema/species/operation/setchargestate.SetChargeState.json index 63d38e45fd..f3bf3e8144 100644 --- a/share/picongpu/pypicongpu/schema/species/operation/setboundelectrons.SetBoundElectrons.json +++ b/share/picongpu/pypicongpu/schema/species/operation/setchargestate.SetChargeState.json @@ -1,18 +1,18 @@ { - "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.operation.setboundelectrons.SetBoundElectrons", + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.operation.setchargestate.SetChargeState", "description": "set bound electrons attribute to given value", "type": "object", "unevaluatedProperties": false, - "required": ["species", "bound_electrons"], + "required": ["species", "charge_state"], "properties": { "species": { "description": "species to set bound electrons for", "$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.species.Species" }, - "bound_electrons": { - "description": "number of bound electrons to set", + "charge_state": { + "description": "charge state to set boundElectrons attribute for", "type": "integer", - "minimum": 1 + "minimum": 0 } } } diff --git a/share/picongpu/pypicongpu/schema/species/species.Species.json b/share/picongpu/pypicongpu/schema/species/species.Species.json index 068544d310..67d6b22bec 100644 --- a/share/picongpu/pypicongpu/schema/species/species.Species.json +++ b/share/picongpu/pypicongpu/schema/species/species.Species.json @@ -24,7 +24,7 @@ }, "attributes": { "type": "array", - "description": "names of attributes of each macroparticle", + "description": "names of attributes of each macro particle of this species", "items": { "type": "object", "unevaluatedProperties": false, @@ -45,7 +45,8 @@ "required": [ "mass", "charge", - "density_ratio" + "density_ratio", + "ground_state_ionization" ], "properties": { "mass": { @@ -66,10 +67,10 @@ {"$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.constant.densityratio.DensityRatio"} ] }, - "ionizers": { - "anyOf": [ + "ground_state_ionization": { + "anyOf": [ {"type": "null"}, - {"$ref": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.constant.ionizers.Ionizers"} + {"$ref":"https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.species.constant.groundstateionization.GroundStateIonization"} ] }, "element_properties": { diff --git a/share/picongpu/pypicongpu/template/include/picongpu/param/particle.param.mustache b/share/picongpu/pypicongpu/template/include/picongpu/param/particle.param.mustache index 9c932faa3a..7c23e10070 100644 --- a/share/picongpu/pypicongpu/template/include/picongpu/param/particle.param.mustache +++ b/share/picongpu/pypicongpu/template/include/picongpu/param/particle.param.mustache @@ -104,10 +104,10 @@ namespace picongpu {{/temperature}} {{/species_initmanager.operations.simple_momentum}} - {{#species_initmanager.operations.set_bound_electrons}} + {{#species_initmanager.operations.set_charge_state}} //! definition of PreIonized manipulator - using PreIonize_{{{species.typename}}} = unary::ChargeState<{{{bound_electrons}}}u>;; - {{/species_initmanager.operations.set_bound_electrons}} + using PreIonize_{{{species.typename}}} = unary::ChargeState<{{{charge_state}}}u>;; + {{/species_initmanager.operations.set_charge_state}} } // namespace pypicongpu } // namespace manipulators } // namespace particles diff --git a/share/picongpu/pypicongpu/template/include/picongpu/param/speciesDefinition.param.mustache b/share/picongpu/pypicongpu/template/include/picongpu/param/speciesDefinition.param.mustache index dbcc974a7c..15f4a730b1 100644 --- a/share/picongpu/pypicongpu/template/include/picongpu/param/speciesDefinition.param.mustache +++ b/share/picongpu/pypicongpu/template/include/picongpu/param/speciesDefinition.param.mustache @@ -62,16 +62,23 @@ namespace picongpu densityRatio, {{/density_ratio}} - {{#ionizers}} + {{#ground_state_ionization}} ionizers, - particles::ionization::ADKCircPol<{{{electron_species.typename}}}, particles::ionization::current::None>, - particles::ionization::ThomasFermi<{{{electron_species.typename}}}>>>, - {{/ionizers}} + {{#ionization_model_list}} + particles::ionization::{{{ionizer_picongpu_name}}}< + {{{ionization_electron_species.typename}}} + {{#ionization_current}} + , particles::ionization::current::{{{picongpu_name}}} + {{/ionization_current}} + >{{^_last}},{{/_last}} + {{/ionization_model_list}} + >>, + {{/ground_state_ionization}} {{#element_properties}} atomicNumbers, ionizationEnergies, + effectiveNuclearCharge, {{/element_properties}} {{/constants}} diff --git a/share/picongpu/pypicongpu/template/include/picongpu/param/speciesInitialization.param.mustache b/share/picongpu/pypicongpu/template/include/picongpu/param/speciesInitialization.param.mustache index 57d896709a..f3ffd58b16 100644 --- a/share/picongpu/pypicongpu/template/include/picongpu/param/speciesInitialization.param.mustache +++ b/share/picongpu/pypicongpu/template/include/picongpu/param/speciesInitialization.param.mustache @@ -82,10 +82,9 @@ namespace picongpu {{/temperature}} {{/operations.simple_momentum}} - {{#operations.set_bound_electrons}} + {{#operations.set_charge_state}} Manipulate, - {{/operations.set_bound_electrons}} - + {{/operations.set_charge_state}} // does nothing -- exists to catch trailing comma left by code generation pypicongpu::nop>; diff --git a/test/python/picongpu/.gitignore b/test/python/picongpu/.gitignore index 24f843267b..9e89b04562 100644 --- a/test/python/picongpu/.gitignore +++ b/test/python/picongpu/.gitignore @@ -1,2 +1,4 @@ # generated by coverage.py .coverage +LWFA +warm_plasma diff --git a/test/python/picongpu/compiling/__init__.py b/test/python/picongpu/compiling/__init__.py index 69c38d59bf..0f5034d2a0 100644 --- a/test/python/picongpu/compiling/__init__.py +++ b/test/python/picongpu/compiling/__init__.py @@ -3,3 +3,4 @@ from .species import * # pyflakes.ignore from .distribution import * # pyflakes.ignore from .simulation import * # pyflakes.ignore +from .compileexamples import * # pyflakes.ignore diff --git a/test/python/picongpu/compiling/compileexamples.py b/test/python/picongpu/compiling/compileexamples.py new file mode 100644 index 0000000000..6db41e8b17 --- /dev/null +++ b/test/python/picongpu/compiling/compileexamples.py @@ -0,0 +1,45 @@ +""" +This file is part of PIConGPU. +Copyright 2021-2023 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from picongpu import pypicongpu + +import importlib.util +import os + +import unittest + + +class TestExamples(unittest.TestCase): + def load_example_script(self, path): + """load and execute example PICMI script from given path""" + module_spec = importlib.util.spec_from_file_location("example", path) + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + + sim = module.sim + + return sim + + def build_simulation(self, sim): + """build the given instance of simulation""" + runner = pypicongpu.Runner(sim) + runner.generate(printDirToConsole=True) + runner.build() + + def test_LWFA_example(self): + """generate a PIConGPU setup from the laser_wakefield PICMI example and compile the setup""" + sim = self.load_example_script( + os.environ["PICSRC"] + "/share/picongpu/pypicongpu/examples/laser_wakefield/main.py" + ) + + self.build_simulation(sim) + + def test_warm_plasma_example(self): + """generate a PIConGPU setup from the warm_plasma PICMI example and compile the setup""" + sim = self.load_example_script(os.environ["PICSRC"] + "/share/picongpu/pypicongpu/examples/warm_plasma/main.py") + + self.build_simulation(sim) diff --git a/test/python/picongpu/compiling/distribution.py b/test/python/picongpu/compiling/distribution.py index 38d8118643..fc52acccd6 100644 --- a/test/python/picongpu/compiling/distribution.py +++ b/test/python/picongpu/compiling/distribution.py @@ -36,6 +36,7 @@ def _compile_distribution(self, distribution): particle_type="H", charge_state=0, initial_distribution=distribution, + picongpu_fixed_charge=True, ) self.sim.add_species(species_hydrogen, random_layout) runner = Runner(self.sim) diff --git a/test/python/picongpu/compiling/species.py b/test/python/picongpu/compiling/species.py index f59471f2ac..2d902de3ac 100644 --- a/test/python/picongpu/compiling/species.py +++ b/test/python/picongpu/compiling/species.py @@ -52,6 +52,7 @@ def test_hydrogen_atoms(self): charge_state=0, initial_distribution=uniform_dist, density_scale=3, + picongpu_fixed_charge=True, ) random_layout = picmi.PseudoRandomLayout(n_macroparticles_per_cell=2) diff --git a/test/python/picongpu/quick/picmi/simulation.py b/test/python/picongpu/quick/picmi/simulation.py index 9b6a503b73..6e2cc8d35f 100644 --- a/test/python/picongpu/quick/picmi/simulation.py +++ b/test/python/picongpu/quick/picmi/simulation.py @@ -6,19 +6,18 @@ """ from picongpu import picmi +from picongpu.pypicongpu import species, customuserinput +from picongpu.picmi.interaction.ionization.fieldionization import ADK, ADKVariant +from picongpu.picmi.interaction import Interaction import unittest - -import typeguard -import typing - -from picongpu.pypicongpu import species, customuserinput -from copy import deepcopy -import logging import tempfile import shutil import os import pathlib +import typeguard +import typing +import copy @typeguard.typechecked @@ -164,9 +163,11 @@ def test_explicit_typical_ppc(self): layout4 = picmi.PseudoRandomLayout(n_macroparticles_per_cell=4) # placed with entire placement and 3ppc - sim.add_species(picmi.Species(name="dummy2", mass=3, density_scale=4, initial_distribution=profile), layout3) + sim.add_species( + picmi.Species(name="dummy2", mass=3, charge=4, density_scale=4, initial_distribution=profile), layout3 + ) # placed with default ratio of 1 and 4ppc - sim.add_species(picmi.Species(name="dummy3", mass=3, initial_distribution=profile), layout4) + sim.add_species(picmi.Species(name="dummy3", mass=3, charge=4, initial_distribution=profile), layout4) picongpu = sim.get_as_pypicongpu() self.assertEqual(2, len(picongpu.init_manager.all_species)) @@ -200,18 +201,18 @@ def test_invalid_placement(self): # both profile and layout must be given with self.assertRaisesRegex(Exception, ".*initial.*distribution.*"): # no profile - sim = deepcopy(self.sim) + sim = copy.deepcopy(self.sim) sim.add_species(picmi.Species(name="dummy3"), layout) sim.get_as_pypicongpu() with self.assertRaisesRegex(Exception, ".*layout.*"): # no layout - sim = deepcopy(self.sim) + sim = copy.deepcopy(self.sim) sim.add_species(picmi.Species(name="dummy3", initial_distribution=profile), None) sim.get_as_pypicongpu() with self.assertRaisesRegex(Exception, ".*initial.*distribution.*"): # neither profile nor layout, but ratio - sim = deepcopy(self.sim) + sim = copy.deepcopy(self.sim) sim.add_species(picmi.Species(name="dummy3", density_scale=7), None) sim.get_as_pypicongpu() @@ -291,7 +292,7 @@ def test_operations_simple_density_translated(self): def test_operation_not_placed_translated(self): """non-placed species are correctly translated""" - self.sim.add_species(picmi.Species(name="notplaced", initial_distribution=None), None) + self.sim.add_species(picmi.Species(name="notplaced", mass=1, initial_distribution=None), None) pypicongpu = self.sim.get_as_pypicongpu() @@ -363,242 +364,45 @@ def test_moving_window(self): self.assertAlmostEqual(pypic.moving_window.move_point, 0.9) self.assertEqual(pypic.moving_window.stop_iteration, None) - def test_ionization_electron_explicit(self): - """electrons for ionization can be specified explicitly""" - # note: the difficulty here is preserving the PICMI- -> PICMI-object - # relationship and translating it into a PyPIConGPU- -> PyPIConGPU - # relationship - - electrons1 = picmi.Species(name="e1", mass=picmi.constants.m_e, charge=-picmi.constants.q_e) - electrons2 = picmi.Species(name="e2", charge=2, mass=3) - ion = picmi.Species( - name="ion", - particle_type="N", - charge_state=0, - picongpu_ionization_electrons=electrons2, - ) - - sim = self.sim - sim.add_species(ion, None) - sim.add_species(electrons1, None) - sim.add_species(electrons2, None) - - with self.assertLogs(level=logging.INFO) as caught_logs: - # required b/c self.assertNoLogs is not yet available - logging.info("TESTINFO") - pypic_sim = sim.get_as_pypicongpu() - # no logs on electrons at all - electron_logs = list(filter(lambda line: "electron" in line, caught_logs.output)) - self.assertEqual([], electron_logs) - - # ensure species actually exists - pypic_species_by_name = dict( - map( - lambda species: (species.name, species), - pypic_sim.init_manager.all_species, - ) - ) - self.assertEqual({"e1", "e2", "ion"}, set(pypic_species_by_name.keys())) - - pypic_ion = pypic_species_by_name["ion"] - self.assertTrue(pypic_ion.has_constant_of_type(species.constant.Ionizers)) - - ionizers = pypic_ion.get_constant_by_type(species.constant.Ionizers) - - # relationship preserved: - self.assertTrue(ionizers.electron_species is pypic_species_by_name["e2"]) - - def test_ionization_electron_resolution_added(self): - """add electron species if one is required but missing""" - # if electrons to use for ionization are not given explicitly they are - # guessed - - profile = picmi.UniformDistribution(3) - - # no electrons exist -> create one species - ## - hydrogen = picmi.Species( - name="hydrogen", - particle_type="H", - charge_state=+1, - initial_distribution=profile, - ) - sim = self.__get_sim() - sim.add_species(hydrogen, self.layout) - - with self.assertLogs(level=logging.INFO) as caught_logs: - pypic_sim = sim.get_as_pypicongpu() - - # electron species has been added to **PICMI** object - self.assertNotEqual(None, hydrogen.picongpu_ionization_electrons) - - # info that electron species has been added - self.assertNotEqual([], caught_logs.output) - electron_logs = list(filter(lambda line: "electron" in line, caught_logs.output)) - self.assertEqual(1, len(electron_logs)) - - # extra species exists - self.assertEqual(2, len(pypic_sim.init_manager.all_species)) - - # pypic_sim works - pypic_sim.init_manager.bake() - self.assertNotEqual({}, pypic_sim.get_rendering_context()) - - def test_ionization_electron_resolution_guessed(self): - """electron species for ionization is guessed if one exists""" - # two methods for electrons: create electron by setting mass & charge - # like electrons, or by setting the particle type explicitly - for electron_explicit in [True, False]: - profile = picmi.UniformDistribution(2) - hydrogen = picmi.Species( - name="hydrogen", - particle_type="H", - charge_state=+1, - initial_distribution=profile, - ) - - if electron_explicit: - # case A: electrons identified by particle_type - electron = picmi.Species(name="my_e", particle_type="electron") - else: - # case B: electrons identified by mass & charge - electron = picmi.Species(name="my_e", mass=picmi.constants.m_e, charge=-picmi.constants.q_e) - - # note: - # guessing only works if there is **exactly one** electron species - picmi_sim = self.__get_sim() - picmi_sim.add_species(hydrogen, self.layout) - picmi_sim.add_species(electron, None) - - pypic_sim = picmi_sim.get_as_pypicongpu() - - # association happened inside PICMI - self.assertEqual(electron, hydrogen.picongpu_ionization_electrons) - - # only 2 species total - self.assertEqual(2, len(pypic_sim.init_manager.all_species)) - - # association correct inside of pypicongpu - for pypic_species in pypic_sim.init_manager.all_species: - if "my_e" == pypic_species.name: - continue - self.assertEqual("hydrogen", pypic_species.name) - - ionizers_const = pypic_species.get_constant_by_type(species.constant.Ionizers) - self.assertEqual("my_e", ionizers_const.electron_species.name) - - # pypic_sim works - pypic_sim.init_manager.bake() - self.assertNotEqual({}, pypic_sim.get_rendering_context()) - - def test_ionization_electron_resolution_guess_ambiguous(self): - """electron species for ionization is not guessed if multiple exist""" - e1 = picmi.Species(name="the_first_electrons", particle_type="electron") - e2 = picmi.Species( - name="the_other_electrons", - mass=picmi.constants.m_e, - charge=-picmi.constants.q_e, - ) - profile = picmi.UniformDistribution(7) - helium = picmi.Species( - name="helium", - particle_type="He", - charge_state=+1, - initial_distribution=profile, - ) - - sim = self.sim - sim.add_species(e1, None) - sim.add_species(e2, None) - sim.add_species(helium, self.layout) - - # two electron species exist, therefore can't guess which one to use - # for ionization -> raise - with self.assertRaisesRegex(Exception, ".*ambiguous.*"): - sim.get_as_pypicongpu() - - def test_ionization_electron_not_added(self): - """electrons must be used, even if not added via add_species()""" - e1 = picmi.Species(name="my_e", particle_type="electron") - ion = picmi.Species( - name="helium", - particle_type="He", - charge_state=+2, - picongpu_ionization_electrons=e1, - ) - sim = self.sim - - # **ONLY** ion is added to sim - sim.add_species(ion, None) - - with self.assertRaisesRegex(AssertionError, ".*my_e.*helium.*picongpu_ionization_species.*"): - sim.get_as_pypicongpu() - - def test_ionization_added_electron_namecollision(self): - """automatically added electron species avoids name collisions""" - existing_electron_names = ["electron", "e", "e_", "E", "e__"] - - for name in existing_electron_names: - self.sim.add_species(picmi.Species(name=name, mass=1, charge=1), None) - - # add ion species so electrons are actually guessed - self.sim.add_species(picmi.Species(name="ion", particle_type="He", charge_state=2), None) - - # catch logs so they don't show up - with self.assertLogs(level="INFO"): - pypic_sim = self.sim.get_as_pypicongpu() - - # one extra species: the electrons generated - self.assertEqual( - 1 + len(existing_electron_names + ["ion"]), - len(pypic_sim.init_manager.all_species), - ) - - # still works, i.e. there are no name conflicts - pypic_sim.init_manager.bake() - self.assertNotEqual({}, pypic_sim.get_rendering_context()) - - def test_ionization_electrons_guess_not_invoked(self): - """ionization electron guessing is only invoked if required""" - # produce ambiguous guess, but do not add species that would require - # guessing -> must work - - e1 = picmi.Species(name="e1", particle_type="electron") - e2 = picmi.Species(name="e2", particle_type="electron") - - sim = self.sim - sim.add_species(e1, None) - sim.add_species(e2, None) - - # just works: - pypic_sim = sim.get_as_pypicongpu() - self.assertEqual(2, len(pypic_sim.init_manager.all_species)) - self.assertNotEqual({}, pypic_sim.get_rendering_context()) - - def test_ionization_methods_added(self): - """ionization methods are added as applicable""" + def test_add_ionization_model(self): + """ionization model is added correctly""" e = picmi.Species(name="e", particle_type="electron") ion1 = picmi.Species(name="hydrogen", particle_type="H", charge_state=+1) ion2 = picmi.Species(name="nitrogen", particle_type="N", charge_state=+2) + ionization_model_1 = ADK( + ADK_variant=ADKVariant.LinearPolarization, + ionization_current=None, + ion_species=ion1, + ionization_electron_species=e, + ) + ionization_model_2 = ADK( + ADK_variant=ADKVariant.LinearPolarization, + ionization_current=None, + ion_species=ion2, + ionization_electron_species=e, + ) + interaction = Interaction(ground_state_ionization_model_list=[ionization_model_1, ionization_model_2]) + sim = self.sim sim.add_species(e, None) sim.add_species(ion1, None) sim.add_species(ion2, None) + # in use should be set via simulation constructor + sim.picongpu_interaction = interaction + pypic_sim = sim.get_as_pypicongpu() initmgr = pypic_sim.init_manager operation_types = list(map(lambda op: type(op), initmgr.all_operations)) - self.assertEqual(1, operation_types.count(species.operation.NoBoundElectrons)) - self.assertEqual(1, operation_types.count(species.operation.SetBoundElectrons)) + self.assertEqual(2, operation_types.count(species.operation.SetChargeState)) for op in initmgr.all_operations: - if isinstance(op, species.operation.NoBoundElectrons): - self.assertEqual("hydrogen", op.species.name) - elif isinstance(op, species.operation.SetBoundElectrons): - self.assertEqual("nitrogen", op.species.name) + if isinstance(op, species.operation.SetChargeState) and op.species.name == "Nitrogen": self.assertEqual(5, op.bound_electrons) + if isinstance(op, species.operation.SetChargeState) and op.species.name == "Hydrogen": + self.assertEqual(0, op.bound_electrons) # other ops (position...): ignore def test_write_input_file(self): diff --git a/test/python/picongpu/quick/picmi/species.py b/test/python/picongpu/quick/picmi/species.py index 1614fee951..496f1189c2 100644 --- a/test/python/picongpu/quick/picmi/species.py +++ b/test/python/picongpu/quick/picmi/species.py @@ -11,6 +11,8 @@ import typeguard from picongpu.pypicongpu import species +from picongpu.picmi.interaction import Interaction +from picongpu.picmi.interaction.ionization.fieldionization import ADK, ADKVariant from copy import deepcopy import re import logging @@ -30,6 +32,7 @@ def setUp(self): name="nitrogen", charge_state=+3, particle_type="N", + picongpu_fixed_charge=True, initial_distribution=self.profile_uniform, ) @@ -51,7 +54,8 @@ def test_basic(self): """check that all params are translated""" # check that translation works for s in [self.species_electron, self.species_nitrogen]: - pypic = s.get_as_pypicongpu() + pypic, rest = s.get_as_pypicongpu(None) + del rest self.assertEqual(pypic.name, s.name) def test_mandatory(self): @@ -63,14 +67,14 @@ def test_mandatory(self): for invalid_species in species_invalid_list: with self.assertRaises(AssertionError): - invalid_species.get_as_pypicongpu() + invalid_species.get_as_pypicongpu(None) # (everything else is optional) def test_mass_charge(self): """mass & charge are passed through""" picmi_s = picmi.Species(name="any", mass=17, charge=-4) - pypicongpu_s = picmi_s.get_as_pypicongpu() + pypicongpu_s, rest = picmi_s.get_as_pypicongpu(None) mass_const = pypicongpu_s.get_constant_by_type(species.constant.Mass) self.assertEqual(17, mass_const.mass_si) @@ -82,87 +86,82 @@ def test_density_scale(self): """density scale is correctly transformed""" # simple example picmi_s = picmi.Species(name="any", density_scale=37.2) - pypicongpu_s = picmi_s.get_as_pypicongpu() + pypicongpu_s, rest = picmi_s.get_as_pypicongpu(None) ratio_const = pypicongpu_s.get_constant_by_type(species.constant.DensityRatio) self.assertAlmostEqual(37.2, ratio_const.ratio) # no density scale picmi_s = picmi.Species(name="any") - pypicongpu_s = picmi_s.get_as_pypicongpu() + pypicongpu_s, rest = picmi_s.get_as_pypicongpu(None) self.assertTrue(not pypicongpu_s.has_constant_of_type(species.constant.DensityRatio)) def test_get_independent_operations(self): """operations which can be set without external dependencies work""" picmi_s = picmi.Species(name="any", mass=1, charge=2) - pypicongpu_s = picmi_s.get_as_pypicongpu() + pypicongpu_s, rest = picmi_s.get_as_pypicongpu(None) # note: placement is not considered independent (it depends on also # having no layout) - self.assertNotEqual(None, picmi_s.get_independent_operations(pypicongpu_s)) + self.assertNotEqual(None, picmi_s.get_independent_operations(pypicongpu_s, None)) def test_get_independent_operations_type(self): """arg type is checked""" picmi_s = picmi.Species(name="any", mass=1, charge=2) for invalid_species in [[], None, picmi_s, "name"]: with self.assertRaises(typeguard.TypeCheckError): - picmi_s.get_independent_operations(invalid_species) + picmi_s.get_independent_operations(invalid_species, None) def test_get_independent_operations_different_name(self): """only generate operations for pypicongpu species of same name""" picmi_s = picmi.Species(name="any", mass=1, charge=2) - pypicongpu_s = picmi_s.get_as_pypicongpu() + pypicongpu_s, rest = picmi_s.get_as_pypicongpu(None) pypicongpu_s.name = "different" with self.assertRaisesRegex(AssertionError, ".*name.*"): - picmi_s.get_independent_operations(pypicongpu_s) + picmi_s.get_independent_operations(pypicongpu_s, None) # same name is okay: pypicongpu_s.name = "any" - self.assertNotEqual(None, picmi_s.get_independent_operations(pypicongpu_s)) + self.assertNotEqual(None, picmi_s.get_independent_operations(pypicongpu_s, None)) - def test_get_independent_operations_ionization_set_bound_electrons(self): + def test_get_independent_operations_ionization_set_charge_state(self): """SetBoundElectrons is properly generated""" picmi_species = picmi.Species(name="nitrogen", particle_type="N", charge_state=2) - pypic_species = picmi_species.get_as_pypicongpu() + e = picmi.Species(name="e", particle_type="electron") + interaction = Interaction( + ground_state_ionization_model_list=[ + ADK( + ion_species=picmi_species, + ionization_current=None, + ionization_electron_species=e, + ADK_variant=ADKVariant.LinearPolarization, + ) + ] + ) - ops = picmi_species.get_independent_operations(pypic_species) + pypic_species, rest = picmi_species.get_as_pypicongpu(interaction) + ops = picmi_species.get_independent_operations(pypic_species, interaction) ops_types = list(map(lambda op: type(op), ops)) - self.assertEqual(1, ops_types.count(species.operation.SetBoundElectrons)) + self.assertEqual(1, ops_types.count(species.operation.SetChargeState)) self.assertEqual(0, ops_types.count(species.operation.NoBoundElectrons)) for op in ops: - if not isinstance(op, species.operation.SetBoundElectrons): - continue - - self.assertEqual(pypic_species, op.species) - self.assertEqual(5, op.bound_electrons) - - def test_get_independent_operations_ionization_no_bound_electrons(self): - """fully ionized ions get NoBoundElectrons""" - picmi_species = picmi.Species(name="hydrogen", particle_type="H", charge_state=1) - pypic_species = picmi_species.get_as_pypicongpu() - - ops = picmi_species.get_independent_operations(pypic_species) - ops_types = list(map(lambda op: type(op), ops)) - self.assertEqual(1, ops_types.count(species.operation.NoBoundElectrons)) - self.assertEqual(0, ops_types.count(species.operation.SetBoundElectrons)) - - for op in ops: - if not isinstance(op, species.operation.NoBoundElectrons): + if not isinstance(op, species.operation.SetChargeState): continue self.assertEqual(pypic_species, op.species) + self.assertEqual(2, op.charge_state) def test_get_independent_operations_ionization_not_ionizable(self): """ionization operation is not returned if there is no ionization""" - picmi_species = picmi.Species(name="hydrogen", particle_type="H", picongpu_fully_ionized=True) - pypic_species = picmi_species.get_as_pypicongpu() + picmi_species = picmi.Species(name="hydrogen", particle_type="H", picongpu_fixed_charge=True) + pypic_species, rest = picmi_species.get_as_pypicongpu(None) - ops = picmi_species.get_independent_operations(pypic_species) + ops = picmi_species.get_independent_operations(pypic_species, None) ops_types = list(map(lambda op: type(op), ops)) self.assertEqual(0, ops_types.count(species.operation.NoBoundElectrons)) - self.assertEqual(0, ops_types.count(species.operation.SetBoundElectrons)) + self.assertEqual(0, ops_types.count(species.operation.SetChargeState)) def test_get_independent_operations_momentum(self): """momentum is correctly translated""" @@ -190,8 +189,8 @@ def test_get_independent_operations_momentum(self): picmi_s = picmi.Species(name="name", mass=1, initial_distribution=dist) - pypicongpu_s = picmi_s.get_as_pypicongpu() - ops = picmi_s.get_independent_operations(pypicongpu_s) + pypicongpu_s, rest = picmi_s.get_as_pypicongpu(None) + ops = picmi_s.get_independent_operations(pypicongpu_s, None) momentum_ops = list( filter( @@ -242,13 +241,26 @@ def get_rms_species(rms_velocity): for invalid_rms_vector in invalid_rms_vectors: rms_species = get_rms_species(invalid_rms_vector) with self.assertRaisesRegex(Exception, ".*(equal|same).*"): - pypicongpu_species = rms_species.get_as_pypicongpu() - rms_species.get_independent_operations(pypicongpu_species) + pypicongpu_species, rest = rms_species.get_as_pypicongpu(None) + rms_species.get_independent_operations(pypicongpu_species, None) def test_from_speciestype(self): - """mass & charge weill be derived from species type""" - picmi_species = picmi.Species(name="nitrogen", particle_type="N") - pypic_species = picmi_species.get_as_pypicongpu() + """mass & charge will be derived from species type""" + picmi_species = picmi.Species(name="nitrogen", particle_type="N", charge_state=5) + e = picmi.Species(name="e", particle_type="electron") + + interaction = Interaction( + ground_state_ionization_model_list=[ + ADK( + ion_species=picmi_species, + ionization_current=None, + ionization_electron_species=e, + ADK_variant=ADKVariant.LinearPolarization, + ) + ] + ) + + pypic_species, rest = picmi_species.get_as_pypicongpu(interaction) # mass & charge derived self.assertTrue(pypic_species.has_constant_of_type(species.constant.Mass)) @@ -257,7 +269,7 @@ def test_from_speciestype(self): mass_const = pypic_species.get_constant_by_type(species.constant.Mass) charge_const = pypic_species.get_constant_by_type(species.constant.Charge) - nitrogen = species.util.Element.N + nitrogen = species.util.Element("N") self.assertAlmostEqual(mass_const.mass_si, nitrogen.get_mass_si()) self.assertAlmostEqual(charge_const.charge_si, nitrogen.get_charge_si()) @@ -267,41 +279,35 @@ def test_from_speciestype(self): def test_charge_state_without_element_forbidden(self): """charge state is not allowed without element name""" with self.assertRaisesRegex(Exception, ".*particle_type.*"): - picmi.Species(name="abc", charge=1, mass=1, charge_state=-1).get_as_pypicongpu() + picmi.Species(name="abc", charge=1, mass=1, charge_state=-1, picongpu_fixed_charge=True).get_as_pypicongpu( + None + ) # allowed with particle species # (actual charge state is inserted by ) - picmi.Species(name="abc", particle_type="H", charge_state=+1).get_as_pypicongpu() + picmi.Species(name="abc", particle_type="H", charge_state=+1, picongpu_fixed_charge=True).get_as_pypicongpu( + None + ) def test_has_ionizers(self): """generated species gets ionizers when appropriate""" # only mass & charge: no ionizers no_ionizers_picmi = picmi.Species(name="simple", mass=1, charge=2) - self.assertTrue(not no_ionizers_picmi.has_ionizers()) - - no_ionizers_pypic = no_ionizers_picmi.get_as_pypicongpu() - self.assertTrue(not no_ionizers_pypic.has_constant_of_type(species.constant.Ionizers)) - - # explicit charge state: has ionizers - explicit_picmi = picmi.Species(name="nitrogen", particle_type="N", charge_state=0) - self.assertTrue(explicit_picmi.has_ionizers()) - - explicit_pypic = explicit_picmi.get_as_pypicongpu() - self.assertTrue(explicit_pypic.has_constant_of_type(species.constant.Ionizers)) + no_ionizers_pypic, rest = no_ionizers_picmi.get_as_pypicongpu(None) + self.assertTrue(not no_ionizers_pypic.has_constant_of_type(species.constant.GroundStateIonization)) # no charge state, but (theoretically) ionization levels known (as # particle type is given): with self.assertLogs(level=logging.WARNING) as implicit_logs: - with_warn_picmi = picmi.Species(name="HELIUM", particle_type="He") - self.assertTrue(not with_warn_picmi.has_ionizers()) + with_warn_picmi = picmi.Species(name="HELIUM", particle_type="He", picongpu_fixed_charge=True) - with_warn_pypic = with_warn_picmi.get_as_pypicongpu() - self.assertTrue(not with_warn_pypic.has_constant_of_type(species.constant.Ionizers)) + with_warn_pypic, rest = with_warn_picmi.get_as_pypicongpu(None) + self.assertTrue(not with_warn_pypic.has_constant_of_type(species.constant.GroundStateIonization)) self.assertEqual(1, len(implicit_logs.output)) self.assertTrue( re.match( - ".*HELIUM.*fully.*ionized.*picongpu_fully_ionized.*", + ".*HELIUM.*fixed charge state.*", implicit_logs.output[0], ) ) @@ -309,12 +315,11 @@ def test_has_ionizers(self): with self.assertLogs(level=logging.WARNING) as explicit_logs: # workaround b/c self.assertNoLogs() is not available yet logging.warning("TESTWARN") - no_warn_picmi = picmi.Species(name="HELIUM", particle_type="He", picongpu_fully_ionized=True) - self.assertTrue(not no_warn_picmi.has_ionizers()) - no_warn_pypic = no_warn_picmi.get_as_pypicongpu() - self.assertTrue(not no_warn_pypic.has_constant_of_type(species.constant.Ionizers)) + no_warn_picmi = picmi.Species(name="HELIUM", particle_type="He", picongpu_fixed_charge=True) + no_warn_pypic, rest = no_warn_picmi.get_as_pypicongpu(None) + self.assertTrue(not no_warn_pypic.has_constant_of_type(species.constant.GroundStateIonization)) - self.assertEqual(1, len(explicit_logs.output)) + self.assertTrue(1 <= len(explicit_logs.output)) self.assertTrue("TESTWARN" in explicit_logs.output[0]) def test_fully_ionized_warning_electrons(self): @@ -324,37 +329,22 @@ def test_fully_ionized_warning_electrons(self): logging.warning("TESTWARN") no_warn_picmi = picmi.Species(name="ELECTRON", particle_type="electron") - self.assertTrue(not no_warn_picmi.has_ionizers()) - no_warn_pypic = no_warn_picmi.get_as_pypicongpu() - self.assertTrue(not no_warn_pypic.has_constant_of_type(species.constant.Ionizers)) + no_warn_pypic, rest = no_warn_picmi.get_as_pypicongpu(None) + self.assertTrue(not no_warn_pypic.has_constant_of_type(species.constant.GroundStateIonization)) self.assertEqual(1, len(explicit_logs.output)) self.assertTrue("TESTWARN" in explicit_logs.output[0]) - def test_fully_ionized_charge_state_conflict(self): - """picongpu_fully_ionized may only be used if charge_state is None""" - # charge state is not none - with self.assertRaisesRegex(AssertionError, ".*charge_state.*"): - picmi.Species(name="x", particle_type="H", charge_state=1, picongpu_fully_ionized=True).get_as_pypicongpu() - - # particle_type is missing - with self.assertRaisesRegex(AssertionError, ".*particle_type.*"): - picmi.Species(name="x", mass=3, charge=2, picongpu_fully_ionized=True).get_as_pypicongpu() - - # non-elements may generally not be ionized - with self.assertRaisesRegex(AssertionError, ".*[Ee]lement.*"): - picmi.Species(name="x", particle_type="electron", picongpu_fully_ionized=False).get_as_pypicongpu() - def test_ionize_non_elements(self): """non-elements may not have a charge_state""" - with self.assertRaisesRegex(Exception, ".*[Ee]lement.*"): - picmi.Species(name="e", particle_type="electron", charge_state=-1).get_as_pypicongpu() + with self.assertRaisesRegex(Exception, ".*charge_state may only be set for ions.*"): + picmi.Species(name="e", particle_type="electron", charge_state=-1).get_as_pypicongpu(None) def test_electron_from_particle_type(self): """electron is correctly constructed from particle_type""" picmi_e = picmi.Species(name="e", particle_type="electron") - pypic_e = picmi_e.get_as_pypicongpu() - self.assertTrue(not pypic_e.has_constant_of_type(species.constant.Ionizers)) + pypic_e, rest = picmi_e.get_as_pypicongpu(None) + self.assertTrue(not pypic_e.has_constant_of_type(species.constant.GroundStateIonization)) self.assertTrue(not pypic_e.has_constant_of_type(species.constant.ElementProperties)) mass_const = pypic_e.get_constant_by_type(species.constant.Mass) @@ -363,52 +353,29 @@ def test_electron_from_particle_type(self): self.assertAlmostEqual(mass_const.mass_si, picmi.constants.m_e) self.assertAlmostEqual(charge_const.charge_si, -picmi.constants.q_e) - def test_fully_ionized_typesafety(self): - """picongpu_fully_ioinized is type safe""" + def test_fixed_charge_typesafety(self): + """picongpu_fixed_charge is type safe""" for invalid in [1, "yes", [], {}]: with self.assertRaises(typeguard.TypeCheckError): - picmi.Species(name="x", picongpu_fully_ionized=invalid) + picmi.Species(name="x", picongpu_fixed_charge=invalid) # works: - picmi_species = picmi.Species(name="x", particle_type="He", picongpu_fully_ionized=True) + picmi_species = picmi.Species(name="x", particle_type="He", picongpu_fixed_charge=True) for invalid in [0, "no", [], {}]: with self.assertRaises(typeguard.TypeCheckError): - picmi_species.picongpu_fully_ionized = invalid + picmi_species.picongpu_fixed_charge = invalid - # None is allowed as value in general (but not in constructor) - picmi_species.picongpu_fully_ionized = None - - def test_ionization_electron_explicit_types(self): - """explicit electron specification requires a PICMI species""" - for invalid in [[], {}, "electron"]: - with self.assertRaises(typeguard.TypeCheckError): - picmi.Species(name="ion", picongpu_ionization_electrons=invalid) - - # with correct type works - electrons = picmi.Species(name="electron", mass=1, charge=2) - picmi.Species(name="ion", picongpu_ionization_electrons=electrons) + # False is allowed + picmi_species.picongpu_fixed_charge = False def test_particle_type_invalid(self): """unkown particle type rejects""" for invalid in ["", "elektron", "e", "e-", "Uux"]: - with self.assertRaisesRegex(NameError, ".*unkown.*"): - picmi.Species(name="x", particle_type=invalid).get_as_pypicongpu() - - def test_ionization_electrons_attribute_present(self): - """picongpu_ionization_electrons is always present""" - self.assertEqual(None, picmi.Species(name="x").picongpu_ionization_electrons) - self.assertEqual( - None, - picmi.Species(name="x", particle_type="H").picongpu_ionization_electrons, - ) - - self.assertEqual( - None, - picmi.Species(name="x", particle_type="H", charge_state=-1).picongpu_ionization_electrons, - ) + with self.assertRaisesRegex(ValueError, ".*not a valid openPMD particle type.*"): + picmi.Species(name="x", particle_type=invalid).get_as_pypicongpu(None) def test_ionization_charge_state_too_large(self): """charge state must be <= number of protons""" with self.assertRaises(AssertionError): - picmi.Species(name="x", particle_type="N", charge_state=8).get_as_pypicongpu() + picmi.Species(name="x", particle_type="N", charge_state=8).get_as_pypicongpu(None) diff --git a/test/python/picongpu/quick/pypicongpu/species/constant/__init__.py b/test/python/picongpu/quick/pypicongpu/species/constant/__init__.py index 1d938abcfd..b5baa09000 100644 --- a/test/python/picongpu/quick/pypicongpu/species/constant/__init__.py +++ b/test/python/picongpu/quick/pypicongpu/species/constant/__init__.py @@ -1,7 +1,9 @@ # flake8: noqa -from .constant import * # pyflakes.ignore -from .mass import * # pyflakes.ignore -from .charge import * # pyflakes.ignore -from .densityratio import * # pyflakes.ignore -from .ionizers import * # pyflakes.ignore -from .elementproperties import * # pyflakes.ignore +from .constant import * +from .mass import * +from .charge import * +from .densityratio import * +from .elementproperties import * +from .groundstateionization import * +from .elementproperties import * +from .ionizationmodel import * diff --git a/test/python/picongpu/quick/pypicongpu/species/constant/constant.py b/test/python/picongpu/quick/pypicongpu/species/constant/constant.py index c2d1d9244d..a25b9823b2 100644 --- a/test/python/picongpu/quick/pypicongpu/species/constant/constant.py +++ b/test/python/picongpu/quick/pypicongpu/species/constant/constant.py @@ -10,43 +10,17 @@ import unittest -class DummyConstant(Constant): - def __init__(self): - pass - - def check(self): - pass - - class TestConstant(unittest.TestCase): - def test_abstract(self): - """methods are not implemented""" - with self.assertRaises(NotImplementedError): - Constant() - - # must pass silently - dc = DummyConstant() - dc.check() - - def test_check_abstract(self): - class ConstantCheckAbstract(Constant): - def __init__(self): - pass - - # check() not overwritten - - cca = ConstantCheckAbstract() - with self.assertRaises(NotImplementedError): - cca.check() - def test_rendering_abstract(self): """rendering context not implemented, but available""" - dc = DummyConstant() + dc = Constant() with self.assertRaises(NotImplementedError): dc.get_rendering_context() def test_dependencies_abstract(self): - dc = DummyConstant() + dc = Constant() + with self.assertRaises(NotImplementedError): + Constant().check() with self.assertRaises(NotImplementedError): dc.get_species_dependencies() with self.assertRaises(NotImplementedError): diff --git a/test/python/picongpu/quick/pypicongpu/species/constant/elementproperties.py b/test/python/picongpu/quick/pypicongpu/species/constant/elementproperties.py index c3081e422f..b55b5cf7c2 100644 --- a/test/python/picongpu/quick/pypicongpu/species/constant/elementproperties.py +++ b/test/python/picongpu/quick/pypicongpu/species/constant/elementproperties.py @@ -17,7 +17,7 @@ class TestElementProperties(unittest.TestCase): def test_basic(self): """basic operation""" ep = ElementProperties() - ep.element = Element.H + ep.element = Element("H") ep.check() @@ -29,7 +29,7 @@ def test_basic(self): def test_rendering(self): """members are exposed""" ep = ElementProperties() - ep.element = Element.N + ep.element = Element("N") context = ep.get_rendering_context() @@ -42,7 +42,7 @@ def test_mandatory(self): with self.assertRaises(Exception): ep.check() - ep.element = Element.H + ep.element = Element("H") # now passes ep.check() @@ -51,6 +51,6 @@ def test_typesafety(self): """typesafety is ensured""" ep = ElementProperties() - for invalid in [None, "H", 1, [], {}]: + for invalid in [None, 1, "H", [], {}]: with self.assertRaises(typeguard.TypeCheckError): ep.element = invalid diff --git a/test/python/picongpu/quick/pypicongpu/species/constant/groundstateionization.py b/test/python/picongpu/quick/pypicongpu/species/constant/groundstateionization.py new file mode 100644 index 0000000000..b8961e07f3 --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/species/constant/groundstateionization.py @@ -0,0 +1,142 @@ +""" +This file is part of PIConGPU. +Copyright 2021-2024 PIConGPU contributors +Authors: Hannes Troepgen, Brian Edward Marre +License: GPLv3+ +""" + + +from picongpu.pypicongpu.species import Species +from picongpu.pypicongpu.species.attribute import Position, Momentum, BoundElectrons +from picongpu.pypicongpu.species.constant import Mass, Charge, GroundStateIonization, ElementProperties +from picongpu.pypicongpu.species.constant.ionizationmodel import BSI, BSIStarkShifted, ThomasFermi +from picongpu.pypicongpu.species.constant.ionizationcurrent import None_ +from picongpu.picmi import constants + +import unittest +import pydantic_core + + +class TestGroundStateIonization(unittest.TestCase): + # set maximum length to infinite to get sensible error message on fail + maxDiff = None + + def setUp(self): + electron = Species() + electron.name = "e" + mass_constant = Mass() + mass_constant.mass_si = constants.m_e + charge_constant = Charge() + charge_constant.charge_si = constants.m_e + electron.constants = [ + charge_constant, + mass_constant, + ] + + self.electron = electron + + self.BSI_instance = BSI(ionization_electron_species=self.electron, ionization_current=None_()) + self.BSIstark_instance = BSIStarkShifted(ionization_electron_species=self.electron, ionization_current=None_()) + self.thomas_fermi_instance = ThomasFermi(ionization_electron_species=self.electron) + + def test_basic(self): + """we may create basic Instance""" + # test we may create GroundStateIonization + GroundStateIonization(ionization_model_list=[self.BSI_instance]) + + def test_type_safety(self): + """may only add list of IonizationModel instances""" + + for invalid in ["BSI", ["BSI"], [1], 1.0, self.BSI_instance]: + with self.assertRaises(pydantic_core._pydantic_core.ValidationError): + GroundStateIonization(ionization_model_list=invalid) + + def test_check_empty_ionization_model_list(self): + """empty ionization model list is not allowed""" + + # assignment is possible + instance = GroundStateIonization(ionization_model_list=[]) + + with self.assertRaisesRegex( + ValueError, ".*at least one ionization model must be specified if ground_state_ionization is not none.*" + ): + # but check throws error + instance.check() + + def test_check_doubled_up_model_group(self): + """may not assign more than one ionization model from the same group""" + + # assignment is possible + instance = GroundStateIonization( + ionization_model_list=[self.BSI_instance, self.BSIstark_instance, self.thomas_fermi_instance] + ) + + with self.assertRaisesRegex(ValueError, ".*ionization model group already represented: BSI.*"): + # but check throws + instance.check() + + def test_check_call_on_ionization_model(self): + """check method of ionization models is called""" + + # creation is possible will only raise in check method + invalid_ionization_model = BSI(ionization_electron_species=None, ionization_current=None_()) + + # assignment is allowed + instance = GroundStateIonization(ionization_model_list=[invalid_ionization_model]) + with self.assertRaisesRegex(TypeError, ".*ionization_electron_species must be of type pypicongpu Species.*"): + # but check throws error + instance.check() + + def test_species_dependencies(self): + """correct return""" + self.assertEqual( + GroundStateIonization(ionization_model_list=[self.BSI_instance]).get_species_dependencies(), [self.electron] + ) + + def test_attribute_dependencies(self): + """correct return""" + self.assertEqual( + GroundStateIonization(ionization_model_list=[self.BSI_instance]).get_attribute_dependencies(), + [BoundElectrons], + ) + + def test_constant_dependencies(self): + """correct return""" + self.assertEqual( + GroundStateIonization(ionization_model_list=[self.BSI_instance]).get_constant_dependencies(), + [ElementProperties], + ) + + def test_rendering(self): + """rendering may be called and returns correct context""" + # complete configuration of electron species + electron = self.BSI_instance.ionization_electron_species + electron.attributes = [Position(), Momentum()] + + context = GroundStateIonization(ionization_model_list=[self.BSI_instance]).get_rendering_context() + + expected_context = { + "ionization_model_list": [ + { + "ionizer_picongpu_name": "BSI", + "ionization_electron_species": { + "name": "e", + "typename": "species_e", + "attributes": [ + {"picongpu_name": "position"}, + {"picongpu_name": "momentum"}, + ], + "constants": { + "mass": {"mass_si": constants.m_e}, + "charge": {"charge_si": constants.m_e}, + "density_ratio": None, + "element_properties": None, + "ground_state_ionization": None, + }, + }, + "ionization_current": {"picongpu_name": "None"}, + } + ] + } + + self.assertEqual(context, expected_context) diff --git a/test/python/picongpu/quick/pypicongpu/species/constant/ionizationmodel/__init__.py b/test/python/picongpu/quick/pypicongpu/species/constant/ionizationmodel/__init__.py new file mode 100644 index 0000000000..7c484dcb6b --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/species/constant/ionizationmodel/__init__.py @@ -0,0 +1,4 @@ +# flake8: noqa +from .ionizationmodelgroups import * +from .ionizationmodel import * +from .ionizationmodelimplementations import * diff --git a/test/python/picongpu/quick/pypicongpu/species/constant/ionizers.py b/test/python/picongpu/quick/pypicongpu/species/constant/ionizationmodel/ionizationmodel.py similarity index 51% rename from test/python/picongpu/quick/pypicongpu/species/constant/ionizers.py rename to test/python/picongpu/quick/pypicongpu/species/constant/ionizationmodel/ionizationmodel.py index a346e7d8db..7ceb100129 100644 --- a/test/python/picongpu/quick/pypicongpu/species/constant/ionizers.py +++ b/test/python/picongpu/quick/pypicongpu/species/constant/ionizationmodel/ionizationmodel.py @@ -1,21 +1,28 @@ """ This file is part of PIConGPU. -Copyright 2021-2023 PIConGPU contributors -Authors: Hannes Troepgen, Brian Edward Marre +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre License: GPLv3+ """ -from picongpu.pypicongpu.species.constant import Ionizers, ElementProperties - -import unittest +from picongpu.pypicongpu.species.constant.ionizationmodel import IonizationModel from picongpu.pypicongpu.species import Species -from picongpu.pypicongpu.species.constant import Mass, Charge +from picongpu.pypicongpu.species.constant import Mass, Charge, ElementProperties, GroundStateIonization from picongpu.pypicongpu.species.attribute import Position, Momentum, BoundElectrons from picongpu.picmi import constants +import pydantic_core + +import unittest + -class TestIonizers(unittest.TestCase): +# raw implementation for testing +class Implementation(IonizationModel): + PICONGPU_NAME: str = "test" + + +class Test_IonizationModel(unittest.TestCase): def setUp(self): electron = Species() electron.name = "e" @@ -31,43 +38,56 @@ def setUp(self): self.electron = electron + def test_not_constructible(self): + with self.assertRaises(Exception): + IonizationModel() + def test_basic(self): """simple operation""" # note: electrons are not checked, because they are not fully # initialized yet - ionizers = Ionizers() - ionizers.electron_species = self.electron - ionizers.check() + instance = Implementation() + instance.ionization_electron_species = self.electron + instance.check() - self.assertEqual([self.electron], ionizers.get_species_dependencies()) - self.assertEqual([BoundElectrons], ionizers.get_attribute_dependencies()) - self.assertEqual([ElementProperties], ionizers.get_constant_dependencies()) + self.assertEqual("test", instance.PICONGPU_NAME) - def test_typesafety(self): - """types are checked""" - ionizers = Ionizers() - for invalid in ["electron", {}, [], 0, None]: - with self.assertRaises(TypeError): - # note: circular imports would be required to use the - # pypicongpu-standard build_typesafe_property, hence the type - # is checked by check() instead of on assignment (as usual) - ionizers.electron_species = invalid - ionizers.check() + self.assertEqual([self.electron], instance.get_species_dependencies()) + self.assertEqual([BoundElectrons], instance.get_attribute_dependencies()) + self.assertEqual([ElementProperties], instance.get_constant_dependencies()) def test_empty(self): """electron species is mandatory""" - ionizers = Ionizers() + instance = Implementation() # must fail: with self.assertRaises(Exception): - ionizers.check() + instance.check() with self.assertRaises(Exception): - ionizers.get_species_dependencies() + instance.get_species_dependencies() # now passes - ionizers.electron_species = self.electron - ionizers.check() + instance.ionization_electron_species = self.electron + instance.check() + + def test_typesafety(self): + """types are checked""" + instance = Implementation() + for invalid in ["electron", {}, [], 0, None]: + with self.assertRaises(TypeError): + # note: circular imports would be required to use the + # pypicongpu-standard build_typesafe_property, hence the type + # is checked by check() instead of on assignment (as usual) + instance.ionization_electron_species = invalid + instance.check() + + for invalid in ["ionization_current", {}, [], 0]: + with self.assertRaises(pydantic_core._pydantic_core.ValidationError): + # note: circular imports would be required to use the + # pypicongpu-standard build_typesafe_property, hence the type + # is checked by check() instead of on assignment (as usual) + Implementation(ionization_electron_species=self.electron, ionization_current=invalid) def test_circular_ionization(self): """electron species must not be ionizable itself""" @@ -83,47 +103,38 @@ def test_circular_ionization(self): ] # note: attributes not set yet, as would be case in init manager - ionizers_transitive_const = Ionizers() - ionizers_transitive_const.electron_species = other_electron + instance_transitive_const = Implementation() + instance_transitive_const.ionization_electron_species = other_electron - self.electron.constants.append(ionizers_transitive_const) + self.electron.constants.append(GroundStateIonization(ionization_model_list=[instance_transitive_const])) - # original ionizers is valid - ionizers_transitive_const.check() + # original instance is valid + instance_transitive_const.check() # ...but a constant using an ionizable species as electrons must reject - ionizers = Ionizers() - ionizers.electron_species = self.electron + instance = Implementation() + instance.ionization_electron_species = self.electron with self.assertRaisesRegex(ValueError, ".*ionizable.*"): - ionizers.check() + instance.check() def test_check_passthru(self): """calls check of electron species & checks during rendering""" - ionizers = Ionizers() - - # must raise (b/c no electron species) - with self.assertRaises(Exception): - ionizers.check() - - # subsequently, dependency retrieval mus also raise - with self.assertRaises(Exception): - ionizers.get_species_dependencies() - - ionizers.electron_species = self.electron + instance = Implementation() + instance.ionization_electron_species = self.electron # both pass: - ionizers.check() - self.assertNotEqual([], ionizers.get_species_dependencies()) + instance.check() + self.assertNotEqual([], instance.get_species_dependencies()) # with a broken species... - ionizers.electron_species = None + instance.ionization_electron_species = None # ...check()... with self.assertRaises(Exception): - ionizers.check() + instance.check() # ...and get dependencies fail with self.assertRaises(Exception): - ionizers.get_species_dependencies() + instance.get_species_dependencies() def test_rendering(self): """renders to rendering context""" @@ -133,27 +144,27 @@ def test_rendering(self): self.electron.check() self.assertNotEqual({}, self.electron.get_rendering_context()) - ionizers = Ionizers() - ionizers.electron_species = self.electron + instance = Implementation() + instance.ionization_electron_species = self.electron - context = ionizers.get_rendering_context() + context = instance.get_rendering_context() self.assertNotEqual({}, context) - self.assertEqual(self.electron.get_rendering_context(), context["electron_species"]) + self.assertEqual(self.electron.get_rendering_context(), context["ionization_electron_species"]) # do *NOT* render if check() does not pass - ionizers.electron_species = None + instance.ionization_electron_species = None with self.assertRaises(TypeError): - ionizers.check() + instance.check() with self.assertRaises(TypeError): - ionizers.get_rendering_context() + instance.get_rendering_context() # pass again - ionizers.electron_species = self.electron - ionizers.check() + instance.ionization_electron_species = self.electron + instance.check() # do *NOT* render if electron species is broken - ionizers.electron_species.attributes = [] + instance.ionization_electron_species.attributes = [] with self.assertRaises(ValueError): - ionizers.electron_species.check() + instance.ionization_electron_species.check() with self.assertRaises(ValueError): - ionizers.get_rendering_context() + instance.get_rendering_context() diff --git a/test/python/picongpu/quick/pypicongpu/species/constant/ionizationmodel/ionizationmodelgroups.py b/test/python/picongpu/quick/pypicongpu/species/constant/ionizationmodel/ionizationmodelgroups.py new file mode 100644 index 0000000000..0e99b92e4f --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/species/constant/ionizationmodel/ionizationmodelgroups.py @@ -0,0 +1,104 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from picongpu.pypicongpu.species.constant.ionizationmodel import IonizationModelGroups + +from picongpu.pypicongpu.species.constant.ionizationmodel import BSI, BSIEffectiveZ, BSIStarkShifted +from picongpu.pypicongpu.species.constant.ionizationmodel import ADKLinearPolarization, ADKCircularPolarization +from picongpu.pypicongpu.species.constant.ionizationmodel import Keldysh, ThomasFermi + +import unittest +import copy + + +class Test_IonizationModelGroups(unittest.TestCase): + def setUp(self): + self.expected_groups_custom = { + "1": [BSI], + "2": [ADKLinearPolarization, ADKCircularPolarization], + } + + self.expected_groups_standard = { + "BSI_like": [BSI, BSIEffectiveZ, BSIStarkShifted], + "ADK_like": [ADKLinearPolarization, ADKCircularPolarization], + "Keldysh_like": [Keldysh], + "electronic_collisional_equilibrium": [ThomasFermi], + } + + self.expected_by_model_custom = { + BSI: "1", + ADKCircularPolarization: "2", + ADKLinearPolarization: "2", + } + + def test_creation(self): + """may be constructed""" + # default value construction + IonizationModelGroups() + + # custom value construction + IonizationModelGroups(by_group=self.expected_groups_custom) + + def test_get_by_group(self): + """by_group is correctly returned""" + self.assertEqual(IonizationModelGroups().get_by_group(), self.expected_groups_standard) + self.assertEqual( + IonizationModelGroups(by_group=self.expected_groups_custom).get_by_group(), self.expected_groups_custom + ) + + def test_get_by_model(self): + """by_group is correctly converted to by_model""" + self.assertEqual( + IonizationModelGroups(by_group=self.expected_groups_custom).get_by_model(), self.expected_by_model_custom + ) + + def _switch_groups(self, result, one, two): + keys = list(result.keys()) + values = list(result.values()) + + first_group = keys[one] + second_group = keys[two] + + first_models = values[one] + second_models = values[two] + + result[first_group] = second_models + result[second_group] = first_models + + return result + + def test_get_by_group_returns_copy(self): + """get_by_group() return copies only""" + ionization_model_group = IonizationModelGroups(by_group=self.expected_groups_custom) + + # get result + result = ionization_model_group.get_by_group() + + # make copy for reference + result_copy = copy.copy(result) + + # manipulate result + result = self._switch_groups(result, 0, 1) + + # check output is unchanged + self.assertEqual(result_copy, ionization_model_group.get_by_group()) + + def test_get_by_model_returns_copy(self): + """get_by_model returns copies only""" + ionization_model_group = IonizationModelGroups(by_group=self.expected_groups_custom) + + # get result + result = ionization_model_group.get_by_model() + + # make copy for reference + result_copy = copy.copy(result) + + # manipulate result + result = self._switch_groups(result, 0, 1) + + # check output is unchanged + self.assertEqual(result_copy, ionization_model_group.get_by_model()) diff --git a/test/python/picongpu/quick/pypicongpu/species/constant/ionizationmodel/ionizationmodelimplementations.py b/test/python/picongpu/quick/pypicongpu/species/constant/ionizationmodel/ionizationmodelimplementations.py new file mode 100644 index 0000000000..9f4fd906b0 --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/species/constant/ionizationmodel/ionizationmodelimplementations.py @@ -0,0 +1,71 @@ +""" +This file is part of PIConGPU. +Copyright 2024 PIConGPU contributors +Authors: Brian Edward Marre +License: GPLv3+ +""" + +from picongpu.pypicongpu.species.constant.ionizationmodel import BSI, BSIEffectiveZ, BSIStarkShifted +from picongpu.pypicongpu.species.constant.ionizationmodel import ADKLinearPolarization, ADKCircularPolarization +from picongpu.pypicongpu.species.constant.ionizationmodel import Keldysh, ThomasFermi +from picongpu.pypicongpu.species.constant.ionizationcurrent import None_ +from picongpu.pypicongpu.species.constant import Charge, Mass +from picongpu.pypicongpu.species import Species +from picongpu.picmi import constants + +import unittest + + +class Test_IonizationModelImplementations(unittest.TestCase): + implementations_withIonizationCurrent = { + BSI: "BSI", + BSIEffectiveZ: "BSIEffectiveZ", + BSIStarkShifted: "BSIStarkShifted", + ADKCircularPolarization: "ADKLinPol", + ADKLinearPolarization: "ADKCircPol", + Keldysh: "Keldysh", + } + + implementations_withoutIonizationCurrent = {ThomasFermi: "ThomasFermi"} + + def setUp(self): + electron = Species() + electron.name = "e" + mass_constant = Mass() + mass_constant.mass_si = constants.m_e + charge_constant = Charge() + charge_constant.charge_si = constants.m_e + electron.constants = [ + charge_constant, + mass_constant, + ] + # note: attributes not set yet (as would be in init manager) + + self.electron = electron + + def test_ionizationCurrentRequired(self): + """ionization current must be explicitly configured""" + for Implementation in self.implementations_withIonizationCurrent.keys(): + with self.assertRaisesRegex(Exception, ".*ionization_current.*"): + implementation = Implementation(ionization_electron_species=self.electron) + # do not call get_rendering_context, since species not completely initialized yet + implementation.check() + + def test_basic(self): + """may create and serialize""" + for Implementation in self.implementations_withIonizationCurrent.keys(): + implementation = Implementation(ionization_electron_species=self.electron, ionization_current=None_()) + implementation.check() + + for Implementation in self.implementations_withoutIonizationCurrent.keys(): + implementation = Implementation(ionization_electron_species=self.electron) + implementation.check() + + def test_picongpu_name(self): + for Implementation, name in self.implementations_withoutIonizationCurrent.items(): + self.assertEqual( + name, + Implementation(ionization_electron_species=self.electron, ionization_current=None_()).PICONGPU_NAME, + ) + for Implementation, name in self.implementations_withoutIonizationCurrent.items(): + self.assertEqual(name, Implementation(ionization_electron_species=self.electron).PICONGPU_NAME) diff --git a/test/python/picongpu/quick/pypicongpu/species/initmanager.py b/test/python/picongpu/quick/pypicongpu/species/initmanager.py index d81734f572..9034c5ac3f 100644 --- a/test/python/picongpu/quick/pypicongpu/species/initmanager.py +++ b/test/python/picongpu/quick/pypicongpu/species/initmanager.py @@ -83,6 +83,9 @@ def __init__(self, unique_id="", species_list=[]): self.species_list = species_list self.unique_id = unique_id + def __hash__(self) -> int: + return hash(self.unique_id) + def get_attr_name(self): return "tracer_attr_" + self.unique_id @@ -104,6 +107,12 @@ class OperationAddMandatoryAttributes(species.operation.Operation): def __init__(self, species_list=[]): self.species_list = species_list + def __hash__(self): + return_hash_value = hash(type(hash)) + for species_ in self.species_list: + return_hash_value += hash(species_) + return return_hash_value + def check_preconditions(self): pass @@ -856,7 +865,7 @@ def test_constant_constant_dependencies_typechecked(self): with self.assertRaises(typeguard.TypeCheckError): initmgr.bake() - def test_set_bound_electrons_passthrough(self): + def test_set_charge_state_passthrough(self): """bound electrons operation is included in rendering context""" # create full electron species electron = species.Species() @@ -865,15 +874,17 @@ def test_set_bound_electrons_passthrough(self): ion = species.Species() ion.name = "ion" - ionizers_const = species.constant.Ionizers() - ionizers_const.electron_species = electron + ionizers_const = species.constant.GroundStateIonization( + ionization_model_list=[species.constant.ionizationmodel.ThomasFermi()] + ) + ionizers_const.ionization_model_list[0].ionization_electron_species = electron element_const = species.constant.ElementProperties() - element_const.element = species.util.Element.N + element_const.element = species.util.Element("N") ion.constants = [ionizers_const, element_const] - ion_op = species.operation.SetBoundElectrons() + ion_op = species.operation.SetChargeState() ion_op.species = ion - ion_op.bound_electrons = 2 + ion_op.charge_state = 2 initmgr = InitManager() initmgr.all_species = [electron, ion] @@ -886,5 +897,5 @@ def test_set_bound_electrons_passthrough(self): self.assertEqual( [ion_op.get_rendering_context()], - context["operations"]["set_bound_electrons"], + context["operations"]["set_charge_state"], ) diff --git a/test/python/picongpu/quick/pypicongpu/species/operation/__init__.py b/test/python/picongpu/quick/pypicongpu/species/operation/__init__.py index edf495cbc3..1876a93608 100644 --- a/test/python/picongpu/quick/pypicongpu/species/operation/__init__.py +++ b/test/python/picongpu/quick/pypicongpu/species/operation/__init__.py @@ -6,4 +6,4 @@ from .momentum import * # pyflakes.ignore from .simplemomentum import * # pyflakes.ignore from .noboundelectrons import * # pyflakes.ignore -from .setboundelectrons import * # pyflakes.ignore +from .setchargestate import * # pyflakes.ignore diff --git a/test/python/picongpu/quick/pypicongpu/species/operation/noboundelectrons.py b/test/python/picongpu/quick/pypicongpu/species/operation/noboundelectrons.py index 50f191744e..717137aba7 100644 --- a/test/python/picongpu/quick/pypicongpu/species/operation/noboundelectrons.py +++ b/test/python/picongpu/quick/pypicongpu/species/operation/noboundelectrons.py @@ -11,15 +11,25 @@ import typeguard from picongpu.pypicongpu.species import Species -from picongpu.pypicongpu.species.constant import Ionizers +from picongpu.pypicongpu.species.constant import GroundStateIonization +from picongpu.pypicongpu.species.constant.ionizationmodel import BSI +from picongpu.pypicongpu.species.constant.ionizationcurrent import None_ from picongpu.pypicongpu.species.attribute import BoundElectrons class TestNoBoundElectrons(unittest.TestCase): def setUp(self): + electron = Species() + electron.name = "e" + self.electron = electron + self.species1 = Species() self.species1.name = "ion" - self.species1.constants = [Ionizers()] + self.species1.constants = [ + GroundStateIonization( + ionization_model_list=[BSI(ionization_electron_species=self.electron, ionization_current=None_())] + ) + ] def test_no_rendering_context(self): """results in no rendered code, hence no rendering context available""" @@ -46,7 +56,7 @@ def test_ionizers_required(self): nbe = NoBoundElectrons() nbe.species = self.species1 - self.assertTrue(self.species1.has_constant_of_type(Ionizers)) + self.assertTrue(self.species1.has_constant_of_type(GroundStateIonization)) # passes nbe.check_preconditions() @@ -55,7 +65,7 @@ def test_ionizers_required(self): self.species1.constants = [] # now raises b/c ionizers constant is missing - with self.assertRaisesRegex(AssertionError, ".*[Ii]onizers.*"): + with self.assertRaisesRegex(AssertionError, ".*[Gg]roundStateIonization.*"): nbe.check_preconditions() def test_empty(self): diff --git a/test/python/picongpu/quick/pypicongpu/species/operation/setboundelectrons.py b/test/python/picongpu/quick/pypicongpu/species/operation/setboundelectrons.py deleted file mode 100644 index 21811e13b0..0000000000 --- a/test/python/picongpu/quick/pypicongpu/species/operation/setboundelectrons.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -This file is part of PIConGPU. -Copyright 2021-2023 PIConGPU contributors -Authors: Hannes Troepgen, Brian Edward Marre -License: GPLv3+ -""" - -from picongpu.pypicongpu.species.operation import SetBoundElectrons - -import unittest -import typeguard - -from picongpu.pypicongpu.species import Species -from picongpu.pypicongpu.species.constant import Ionizers -from picongpu.pypicongpu.species.attribute import BoundElectrons, Position, Momentum - - -class TestSetBoundElectrons(unittest.TestCase): - def setUp(self): - self.species1 = Species() - self.species1.name = "ion" - self.species1.constants = [Ionizers()] - - def test_basic(self): - """basic operation""" - sbe = SetBoundElectrons() - sbe.species = self.species1 - sbe.bound_electrons = 2 - - # checks pass - sbe.check_preconditions() - - def test_typesafety(self): - """typesafety is ensured""" - sbe = SetBoundElectrons() - for invalid_species in [None, 1, "a", []]: - with self.assertRaises(typeguard.TypeCheckError): - sbe.species = invalid_species - - for invalid_number in [None, "a", [], self.species1, 2.3]: - with self.assertRaises(typeguard.TypeCheckError): - sbe.bound_electrons = invalid_number - - # works: - sbe.species = self.species1 - sbe.bound_electrons = 1 - - def test_empty(self): - """all parameters are mandatory""" - for set_species in [True, False]: - for set_bound_electrons in [True, False]: - sbe = SetBoundElectrons() - - if set_species: - sbe.species = self.species1 - if set_bound_electrons: - sbe.bound_electrons = 1 - - if set_species and set_bound_electrons: - # must pass - sbe.check_preconditions() - else: - # mandatory missing -> must raise - with self.assertRaises(Exception): - sbe.check_preconditions() - - def test_attribute_generated(self): - """creates bound electrons attribute""" - sbe = SetBoundElectrons() - sbe.species = self.species1 - sbe.bound_electrons = 1 - - # emulate initmanager - sbe.check_preconditions() - self.species1.attributes = [] - sbe.prebook_species_attributes() - - self.assertEqual(1, len(sbe.attributes_by_species)) - self.assertTrue(self.species1 in sbe.attributes_by_species) - self.assertEqual(1, len(sbe.attributes_by_species[self.species1])) - self.assertTrue(isinstance(sbe.attributes_by_species[self.species1][0], BoundElectrons)) - - def test_ionizers_required(self): - """ionizers constant must be present""" - sbe = SetBoundElectrons() - sbe.species = self.species1 - sbe.bound_electrons = 1 - - # passes: - self.assertTrue(sbe.species.has_constant_of_type(Ionizers)) - sbe.check_preconditions() - - # without constants does not pass: - sbe.species.constants = [] - with self.assertRaisesRegex(AssertionError, ".*[Ii]onizers.*"): - sbe.check_preconditions() - - def test_values(self): - """bound electrons must be >0""" - sbe = SetBoundElectrons() - sbe.species = self.species1 - - with self.assertRaisesRegex(ValueError, ".*>0.*"): - sbe.bound_electrons = -1 - sbe.check_preconditions() - - with self.assertRaisesRegex(ValueError, ".*NoBoundElectrons.*"): - sbe.bound_electrons = 0 - sbe.check_preconditions() - - # silently passes - sbe.bound_electrons = 1 - sbe.check_preconditions() - - def test_rendering(self): - """rendering works""" - # create full electron species - electron = Species() - electron.name = "e" - electron.constants = [] - electron.attributes = [Position(), Momentum()] - - # can be rendered: - self.assertNotEqual({}, electron.get_rendering_context()) - - ion = Species() - ion.name = "ion" - ionizers_const = Ionizers() - ionizers_const.electron_species = electron - ion.constants = [ionizers_const] - ion.attributes = [Position(), Momentum(), BoundElectrons()] - - # can be rendered - self.assertNotEqual({}, ion.get_rendering_context()) - - sbe = SetBoundElectrons() - sbe.species = ion - sbe.bound_electrons = 1 - - context = sbe.get_rendering_context() - self.assertEqual(1, context["bound_electrons"]) - self.assertEqual(ion.get_rendering_context(), context["species"]) diff --git a/test/python/picongpu/quick/pypicongpu/species/operation/setchargestate.py b/test/python/picongpu/quick/pypicongpu/species/operation/setchargestate.py new file mode 100644 index 0000000000..4cc78673d7 --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/species/operation/setchargestate.py @@ -0,0 +1,152 @@ +""" +This file is part of PIConGPU. +Copyright 2021-2023 PIConGPU contributors +Authors: Hannes Troepgen, Brian Edward Marre +License: GPLv3+ +""" + +from picongpu.pypicongpu.species.operation import SetChargeState + +import unittest +import typeguard + +from picongpu.pypicongpu.species import Species +from picongpu.pypicongpu.species.constant import GroundStateIonization +from picongpu.pypicongpu.species.constant.ionizationmodel import BSI +from picongpu.pypicongpu.species.constant.ionizationcurrent import None_ +from picongpu.pypicongpu.species.attribute import BoundElectrons, Position, Momentum + + +class TestSetChargeState(unittest.TestCase): + def setUp(self): + electron = Species() + electron.name = "e" + # note: attributes not set yet (as would be in init manager) + + self.electron = electron + + self.species1 = Species() + self.species1.name = "ion" + self.species1.constants = [ + GroundStateIonization( + ionization_model_list=[BSI(ionization_electron_species=self.electron, ionization_current=None_())] + ) + ] + + def test_basic(self): + """basic operation""" + scs = SetChargeState() + scs.species = self.species1 + scs.charge_state = 2 + + # checks pass + scs.check_preconditions() + + def test_typesafety(self): + """typesafety is ensured""" + scs = SetChargeState() + for invalid_species in [None, 1, "a", []]: + with self.assertRaises(typeguard.TypeCheckError): + scs.species = invalid_species + + for invalid_number in [None, "a", [], self.species1, 2.3]: + with self.assertRaises(typeguard.TypeCheckError): + scs.charge_state = invalid_number + + # works: + scs.species = self.species1 + scs.charge_state = 1 + + def test_empty(self): + """all parameters are mandatory""" + for set_species in [True, False]: + for set_charge_state in [True, False]: + scs = SetChargeState() + + if set_species: + scs.species = self.species1 + if set_charge_state: + scs.charge_state = 1 + + if set_species and set_charge_state: + # must pass + scs.check_preconditions() + else: + # mandatory missing -> must raise + with self.assertRaises(Exception): + scs.check_preconditions() + + def test_attribute_generated(self): + """creates bound electrons attribute""" + scs = SetChargeState() + scs.species = self.species1 + scs.charge_state = 1 + + # emulate initmanager + scs.check_preconditions() + self.species1.attributes = [] + scs.prebook_species_attributes() + + self.assertEqual(1, len(scs.attributes_by_species)) + self.assertTrue(self.species1 in scs.attributes_by_species) + self.assertEqual(1, len(scs.attributes_by_species[self.species1])) + self.assertTrue(isinstance(scs.attributes_by_species[self.species1][0], BoundElectrons)) + + def test_ionizers_required(self): + """ionizers constant must be present""" + scs = SetChargeState() + scs.species = self.species1 + scs.charge_state = 1 + + # passes: + self.assertTrue(scs.species.has_constant_of_type(GroundStateIonization)) + scs.check_preconditions() + + # without constants does not pass: + scs.species.constants = [] + with self.assertRaisesRegex(AssertionError, ".*BoundElectrons requires GroundStateIonization.*"): + scs.check_preconditions() + + def test_values(self): + """bound electrons must be >0""" + scs = SetChargeState() + scs.species = self.species1 + + with self.assertRaisesRegex(ValueError, ".*> 0.*"): + scs.charge_state = -1 + scs.check_preconditions() + + # silently passes + scs.charge_state = 1 + scs.check_preconditions() + + def test_rendering(self): + """rendering works""" + # create full electron species + electron = Species() + electron.name = "e" + electron.constants = [] + electron.attributes = [Position(), Momentum()] + + # can be rendered: + self.assertNotEqual({}, electron.get_rendering_context()) + + ion = Species() + ion.name = "ion" + ion.constants = [ + GroundStateIonization( + ionization_model_list=[BSI(ionization_electron_species=electron, ionization_current=None_())] + ), + ] + ion.attributes = [Position(), Momentum(), BoundElectrons()] + + # can be rendered + self.assertNotEqual({}, ion.get_rendering_context()) + + scs = SetChargeState() + scs.species = ion + scs.charge_state = 1 + + context = scs.get_rendering_context() + self.assertEqual(1, context["charge_state"]) + self.assertEqual(ion.get_rendering_context(), context["species"]) diff --git a/test/python/picongpu/quick/pypicongpu/species/species.py b/test/python/picongpu/quick/pypicongpu/species/species.py index cf8e5e24dd..cf9501e46d 100644 --- a/test/python/picongpu/quick/pypicongpu/species/species.py +++ b/test/python/picongpu/quick/pypicongpu/species/species.py @@ -12,13 +12,15 @@ Mass, Charge, DensityRatio, - Ionizers, ElementProperties, + Constant, + GroundStateIonization, ) +from picongpu.pypicongpu.species.constant.ionizationcurrent import None_ +from picongpu.pypicongpu.species.constant.ionizationmodel import BSI from picongpu.pypicongpu.species.util import Element from .attribute import DummyAttribute -from .constant import DummyConstant import itertools import unittest @@ -39,18 +41,19 @@ def setUp(self): self.electron.attributes = [Position(), Momentum()] self.electron.constants = [] - self.const = DummyConstant() + self.const = Constant() self.const_charge = Charge() self.const_charge.charge_si = 1 self.const_mass = Mass() self.const_mass.mass_si = 2 self.const_density_ratio = DensityRatio() self.const_density_ratio.ratio = 4.2 - self.const_ionizers = Ionizers() - self.const_ionizers.electron_species = self.electron + self.const_ground_state_ionization = GroundStateIonization( + ionization_model_list=[BSI(ionization_electron_species=self.electron, ionization_current=None_())] + ) self.const_element_properties = ElementProperties() - self.const_element_properties.element = Element.H + self.const_element_properties.element = Element("H") def test_basic(self): """setup provides working species""" @@ -79,12 +82,12 @@ def test_types(self): with self.assertRaises(typeguard.TypeCheckError): species.name = invalid_name - invalid_attr_lists = [None, {}, set(), [DummyConstant()], DummyAttribute()] + invalid_attr_lists = [None, {}, set(), [Constant()], DummyAttribute()] for invalid_attr_list in invalid_attr_lists: with self.assertRaises(typeguard.TypeCheckError): species.attributes = invalid_attr_list - invalid_const_lists = [None, {}, set(), [DummyAttribute()], DummyConstant()] + invalid_const_lists = [None, {}, set(), [DummyAttribute()], Constant()] for invalid_const_list in invalid_const_lists: with self.assertRaises(typeguard.TypeCheckError): species.constants = invalid_const_list @@ -135,7 +138,8 @@ def test_constants_unique(self): const1.charge_si = 17 const2 = Charge() const2.charge_si = 18 - other_const = DummyConstant() + other_const = Mass() + other_const.mass_si = 19 species.constants = [const1, const2, other_const] @@ -151,7 +155,7 @@ def test_constants_unique(self): def test_check_constant_passthhru(self): """species check also calls constants check""" - class ConstantFail(DummyConstant): + class ConstantFail(Constant): ERROR_STR: str = "IDSTRING_XKCD_927_BEST" def check(self): @@ -205,7 +209,7 @@ def test_get_constant_by_type(self): species.constants = [self.const, self.const_mass, self.const_charge] # note: check for *identity* with is (instead of pure equality) - self.assertTrue(self.const is species.get_constant_by_type(DummyConstant)) + self.assertTrue(self.const is species.get_constant_by_type(Constant)) self.assertTrue(self.const_charge is species.get_constant_by_type(Charge)) self.assertTrue(self.const_mass is species.get_constant_by_type(Mass)) @@ -275,7 +279,7 @@ def test_rendering_constants(self): "density_ratio": self.const_density_ratio, "charge": self.const_charge, "mass": self.const_mass, - "ionizers": self.const_ionizers, + "ground_state_ionization": self.const_ground_state_ionization, "element_properties": self.const_element_properties, } diff --git a/test/python/picongpu/quick/pypicongpu/species/util/element.py b/test/python/picongpu/quick/pypicongpu/species/util/element.py index 7ef2211304..49a9e65e68 100644 --- a/test/python/picongpu/quick/pypicongpu/species/util/element.py +++ b/test/python/picongpu/quick/pypicongpu/species/util/element.py @@ -7,65 +7,90 @@ from picongpu.pypicongpu.species.util import Element +import scipy + import unittest from picongpu.pypicongpu.rendering import RenderedObject -import re -import typeguard class TestElement(unittest.TestCase): - def test_exists(self): - """there is at least one element""" - self.assertNotEqual([], list(Element)) - - def test_openpmd_names(self): - """elements can be requested by openPMD name""" - expected_element_by_name = { - "H": Element.H, - "He": Element.He, - "N": Element.N, - } - for name, element in expected_element_by_name.items(): - self.assertEqual(element, Element.get_by_openpmd_name(name)) - - for invalid_type in [[], None, 3]: - with self.assertRaises(typeguard.TypeCheckError): - Element.get_by_openpmd_name(invalid_type) - - for unknown_name in ["", " H", "abc"]: - with self.assertRaisesRegex(NameError, ".*unkown.*"): - Element.get_by_openpmd_name(unknown_name) - - def test_periodic_table_names(self): - """names must follow the periodic table""" - element_re = re.compile("^[A-Z][a-z]?$") - for element in list(Element): - self.assertTrue(element_re.match(element.name)) + def setUp(self): + # create test case data + self.test_element = ["H", "#2H", "Cu", "#12C", "C"] + self.name = ["H", "D", "Cu", "C", "C"] + self.picongpu_names = ["Hydrogen", "Deuterium", "Copper", "Carbon", "Carbon"] + self.mass = [ + 1.00794 * scipy.constants.atomic_mass, + 2.014101778 * scipy.constants.atomic_mass, + 63.546 * scipy.constants.atomic_mass, + 12.0 * scipy.constants.atomic_mass, + 12.0107 * scipy.constants.atomic_mass, + ] + self.charge = [ + 1.0 * scipy.constants.elementary_charge, + 1.0 * scipy.constants.elementary_charge, + 27.0 * scipy.constants.elementary_charge, + 6.0 * scipy.constants.elementary_charge, + 6.0 * scipy.constants.elementary_charge, + ] + self.atomic_number = [1, 1, 29, 6, 6] + + def test_parse_openpmd(self): + valid_test_strings = ["#3H", "#15He", "#1H", "#3He", "#56Cu"] + mass_number_results = [3, 15, 1, 3, 56] + name_results = ["H", "He", "H", "He", "Cu"] + + for i, string in enumerate(valid_test_strings): + mass_number, name = Element.parse_openpmd_isotopes(string) + self.assertEqual(name, name_results[i]) + self.assertEqual(mass_number, mass_number_results[i]) + + invalid_test_strings = ["#Htest", "#He3", "#Cu-56", "H3", "Fe-56"] + for i, string in enumerate(invalid_test_strings): + with self.assertRaisesRegex(ValueError, string + " is not a valid openPMD particle type"): + name, massNumber = Element.parse_openpmd_isotopes(string) + + def test_basic_use(self): + for name in self.test_element: + Element(name) + + def test_symbol(self): + for openpmd_name, name in zip(self.test_element, self.name): + e = Element(openpmd_name) + self.assertEqual(e.get_symbol(), name) + + def test_is_element(self): + for name in self.test_element: + self.assertTrue(Element.is_element(name)) + self.assertFalse(Element.is_element("n")) def test_picongpu_names(self): """names must be translateable to picongpu""" - all_picongpu_names = set() - # all elements are defined - for element in list(Element): - picongpu_name = element.get_picongpu_name() + for openpmd_name, picongpu_name in zip(self.test_element, self.picongpu_names): + name_test = Element(openpmd_name).get_picongpu_name() self.assertNotEqual("", picongpu_name) - self.assertTrue(picongpu_name not in all_picongpu_names) - all_picongpu_names.add(picongpu_name) + self.assertEqual(name_test, picongpu_name) - def test_mass(self): + def test_get_mass(self): """all elements have mass""" - for element in list(Element): - self.assertTrue(0 < element.get_mass_si()) + for openpmd_name, mass in zip(self.test_element, self.mass): + self.assertAlmostEqual(Element(openpmd_name).get_mass_si(), mass) def test_charge(self): """all elements have charge""" - for element in list(Element): - self.assertTrue(0 < element.get_charge_si()) + for openpmd_name, charge in zip(self.test_element, self.charge): + self.assertAlmostEqual(Element(openpmd_name).get_charge_si(), charge) + + def test_atomic_number(self): + for openpmd_name, atomic_number in zip(self.test_element, self.atomic_number): + e = Element(openpmd_name) + self.assertEqual(e.get_atomic_number(), atomic_number) def test_rendering(self): """all elements can be rendered""" self.assertTrue(issubclass(Element, RenderedObject)) - for element in list(Element): - context = element.get_rendering_context() - self.assertEqual(context["symbol"], element.name) - self.assertEqual(context["picongpu_name"], element.get_picongpu_name()) + for openpmd_name in self.test_element: + e = Element(openpmd_name) + context = e.get_rendering_context() + self.assertEqual(context["symbol"], e.get_symbol()) + self.assertEqual(context["picongpu_name"], e.get_picongpu_name())