"""
Abstract base classes for simulation frontends in Pisces.
A simulation frontend is the bridge between Pisces' in-house
:class:`~pisces.extensions.simulation.core.initial_conditions.InitialConditions`
objects and an external simulation code's native input files and configuration
requirements.
"""
import shutil
from abc import ABC, abstractmethod
from copy import deepcopy
from pathlib import Path
from typing import Any
import numpy as np
import unyt
from pisces.particles import ParticleDataset
from pisces.utilities.config import ConfigManager
from .initial_conditions import InitialConditions, InitialConditions3DCartesian
[docs]
class SimulationFrontend(ABC):
"""
Abstract base class defining the behavior of simulation frontends.
This class forms the core logic for all simulation frontends in Pisces,
and includes a number of abstract methods and modifiable hooks to alter
various aspects of the simulation code's initial conditions generation.
The :class:`SimulationFrontend` class takes a
:class:`~pisces.extensions.simulation.core.initial_conditions.InitialConditions`
object as input, which it will then convert into simulation code-specific
files and configurations for running the simulation.
To facilitate this, the frontend class manages a configuration file
in which the user may specify the details of the conversion process.
Each frontend has a default configuration file in ``../frontend_configs``,
which is copied into the initial conditions directory when the frontend
is initialized. This allows the user to modify the configuration
for each simulation run without affecting the default template.
For more detailed information on how to implement a custom frontend,
see :ref:`frontend_dev`. For the user guide documentation, see :ref:`simulation_frontends`.
"""
# --------------------------------------- #
# Class Variables and Constants #
# --------------------------------------- #
# These are class-level variables which define connections to
# the configuration along with some other aspects of the
# frontend's behavior.
__default_configuration_path__: Path = None
"""Path to the default configuration file template for this frontend.
This should be set in subclasses to point to the default YAML
configuration file that defines the expected parameters for
this frontend. The file should be located in the
`pisces/extensions/simulation/core/frontend_configs` directory.
"""
__default_configuration_manager_class__: type = ConfigManager
"""The class used to manage the configuration file for this frontend.
This should be set in subclasses to specify the type of
:class:`~pisces.utilities.config.ConfigManager` or a subclass that
will be used to handle the configuration file. By default,
it is set to :class:`~pisces.utilities.config.ConfigManager`, but
subclasses may override this to use a custom configuration manager
that provides additional functionality or validation specific to
the simulation code being interfaced with.
"""
# --------------------------------------- #
# Initialization and Configuration #
# --------------------------------------- #
# At the core, the frontend initialization is broken down
# into just a few steps:
#
# 1. Validate that the input initial conditions are
# valid / suitable for this frontend. This is done
# by the `_validate_input_ic` method, which can be
# overridden by subclasses to implement specific
# validation rules.
# 2. Copy / ensure that the default configuration
# file for this frontend exists in the initial
# conditions directory. This is done by the
# `_ensure_config` method, which will copy the
# default configuration file from the class-level
# `__default_configuration_path__` to the IC's
# working directory, or reset it if `reset` is True.
# [This generally doesn't need to be overridden!]
# 3. Load the configuration file into a ConfigManager
# instance, which provides a convenient interface
# for accessing and modifying the configuration.
#
# Modify any relevant parts of this process to customize
# the behavior of the frontend. The most common
# modification here is to override _validate_input_ic.
#
@abstractmethod
def _validate_input_ic(self, initial_conditions: InitialConditions) -> bool:
"""
Validate that the provided initial conditions are suitable for this frontend.
This method is called during initialization to ensure that
the initial conditions object meets the requirements of the
specific simulation code this frontend is designed for.
Parameters
----------
initial_conditions : InitialConditions
The initial conditions object to validate.
Returns
-------
bool
True if the initial conditions are valid, False otherwise.
"""
if not isinstance(initial_conditions, InitialConditions):
raise TypeError(f"Expected an InitialConditions object, got {type(initial_conditions)}.")
return True
def _ensure_config(self, ic_directory: Path, reset: bool = False) -> tuple[Path, str]:
"""
Ensure that a configuration file for this frontend exists.
This method checks if a configuration file already exists in the
specified initial conditions directory. If it does not exist,
it copies the default configuration template from the class-level
`__default_configuration_path__` to the IC directory. If a file
already exists and `reset` is True, it overwrites the existing
configuration file with the default template. If `reset` is False,
it leaves the existing configuration file untouched.
Parameters
----------
ic_directory : Path
The directory where the initial conditions are stored.
reset : bool, optional
If True, overwrite any existing configuration file with the
default configuration template.
Returns
-------
Path
The full path to the configuration file in the IC directory.
str
The status of the configuration file. This will be either ``"created"``
or ``"existing"`` depending on whether a new file was created or an existing
file was found. This is used further down in the initialization process to
ensure that the configuration is loaded correctly.
"""
# --- Paths for configuration management --- #
# Destination is always inside the IC's working directory, and
# its filename is derived from the frontend class name to avoid
# collisions across different frontends.
config_destination = ic_directory / f"{self.__class__.__name__}_config.yaml"
# The source path should be a class-level constant defined in subclasses
# pointing to the frontend's default configuration file template.
config_source = self.__class__.__default_configuration_path__
# --- Verify that the default configuration exists --- #
if config_source is None or not config_source.exists():
raise FileNotFoundError(f"Default configuration file not found at {config_source}.")
# --- Handle existing vs. new configuration file --- #
if config_destination.exists():
if reset:
# Overwrite the existing configuration with the default.
self.logger.debug(f"Resetting configuration file at {config_destination}.")
shutil.copy(config_source, config_destination)
status = "created"
else:
# Keep the existing configuration file untouched.
self.logger.debug(f"Using existing configuration file at {config_destination}.")
status = "existing"
else:
# No configuration exists — copy in the default template.
self.logger.debug(f"Copying default configuration file from {config_source} to {config_destination}.")
shutil.copy(config_source, config_destination)
status = "created"
return config_destination, status
def _setup_config(self):
"""
Perform additional setup after the configuration file is ensured.
This method is called after the configuration file has been
ensured and loaded into the ConfigManager. Subclasses may
override this method to perform any additional setup steps
that depend on the configuration being present.
By default, this method does nothing.
"""
return
[docs]
def __init__(self, initial_conditions: InitialConditions, reset_configuration: bool = False, **kwargs):
"""
Construct a simulation frontend and attach it to an IC object.
On initialization, the frontend will:
1. Validate the provided initial conditions object (via
:meth:`_validate_input_ic`).
2. Ensure that a per-frontend configuration file exists in the
initial conditions directory. If no file is present, the default
configuration template is copied in. If a file already exists,
it is either preserved or reset depending on ``reset_configuration``.
3. Load the configuration into a :class:`~pisces.utilities.config.ConfigManager`
instance and apply any keyword overrides passed through ``kwargs``.
4. Call :meth:`__post_init__` to allow subclasses to perform
additional setup.
Parameters
----------
initial_conditions : InitialConditions
The Pisces initial conditions object to bind this frontend to.
Provides access to the working directory, logging, and data
structures that will be translated into native simulation input.
reset_configuration : bool, optional
If ``True``, overwrite any existing configuration file in the
initial conditions directory with the default template for this
frontend. If ``False`` (default), an existing configuration file
will be preserved.
**kwargs
Arbitrary keyword arguments that will be merged into the loaded
configuration after it is created. This allows programmatic
updates without requiring manual edits to the YAML file.
Raises
------
FileNotFoundError
If the default configuration template for this frontend cannot
be found.
"""
# --- Setup IC connection --- #
# At this stage, we just take the IC that we got passed
# and ensure it gets connected to the class.
self.__initial_conditions__ = initial_conditions
initial_conditions.logger.info(f"[{self.__class__.__name__}] Linking IC: {initial_conditions}...")
# --- Input validation hook --- #
# Perform any frontend-specific validation on the
# provided initial conditions object.
if not self._validate_input_ic(initial_conditions):
raise ValueError(
f"Initial conditions {initial_conditions} are not valid for {self.__class__.__name__} frontend."
)
self.logger.debug(f"{initial_conditions} passed validation on {self.__class__.__name__} frontend...")
# --- Setup Configuration file --- #
# We first pass off to _ensure_config to make sure that
# that the configuration file exists in the IC directory. That
# also provides a status indicating whether the file was created
# or already existed. If it was created, then we need to setup the
# configuration from defaults (_setup_config). We also need to update
# with the kwargs.
self.__config_path__, _load_status = self._ensure_config(
ic_directory=initial_conditions.__directory__, reset=reset_configuration
)
self.__config__ = self.__class__.__default_configuration_manager_class__(self.__config_path__, autosave=True)
if _load_status == "created":
# We need to perform further setup procedures.
self._setup_config()
else:
pass
self.__config__.update(kwargs)
# --- Subclass post-initialization hook --- #
self.__post_init__()
initial_conditions.logger.info(f"[{self.__class__.__name__}] Linking IC: {initial_conditions}... [DONE]")
def __post_init__(self): # noqa: B027
"""
Perform any additional setup after initialization.
This is called after the configuration has been loaded and updated
in __init__. Override this in a subclass to perform any setup steps
that depend on the configuration or IC object.
"""
pass
# --------------------------------------- #
# Properties #
# --------------------------------------- #
@property
def initial_conditions(self) -> InitialConditions:
"""
The IC object that this frontend is bound to.
Provides access to:
- The working directory for file generation
- Data structures describing the initial state of the system
- A logger instance for status messages
This is the central object that will be translated into the
simulation code's native input format.
"""
return self.__initial_conditions__
@property
def config(self) -> ConfigManager:
"""
The :class:`~pisces.utilities.config.ConfigManager` instance associated with this frontend.
This object provides a programmatic interface for reading and
updating the YAML configuration file used to guide initial
condition generation. It always corresponds to the file at
:attr:`config_path`.
Notes
-----
Updates made through this object may or may not automatically
persist to disk, depending on the ConfigManager implementation.
Call a dedicated ``save()`` method if persistence is required.
"""
return self.__config__
@property
def config_path(self) -> Path:
"""
Absolute path to the configuration file for this frontend.
The filename is derived from the frontend's class name to
avoid collisions across multiple frontends, e.g.::
<ic_directory>/<FrontendClassName>_config.yaml
Returns
-------
pathlib.Path
Filesystem path to the YAML configuration file.
"""
return self.__config_path__
@property
def default_configuration_path(self) -> Path:
"""
Absolute path to the default configuration file template for this frontend.
This is a class-level constant set by each frontend subclass,
usually pointing to a file in the ``frontend_configs`` resource
directory. It is copied into :attr:`ic_directory` if no existing
configuration file is found.
Returns
-------
pathlib.Path
Filesystem path to the frontend's default YAML template.
"""
return self.__class__.__default_configuration_path__
@property
def ic_directory(self) -> Path:
"""
Filesystem directory where the initial conditions object is stored.
This directory serves as the root for:
- The per-frontend configuration file (:attr:`config_path`)
- Any generated native input files
- Metadata or provenance logs
Returns
-------
pathlib.Path
Directory path backing the connected
:class:`~pisces.extensions.simulation.core.initial_conditions.InitialConditions`.
"""
return self.initial_conditions.__directory__
@property
def logger(self):
"""
Logger instance associated with this frontend.
Delegated from :attr:`initial_conditions`. Provides a consistent
way to emit information, warnings, or debug messages during
configuration handling or IC generation.
"""
return self.initial_conditions.logger
# --------------------------------------- #
# DUNDER METHODS #
# --------------------------------------- #
def __str__(self) -> str:
"""
Return a human-readable string representation of the frontend.
Includes the frontend class name and the path to its configuration file.
Useful for logging and user-facing output.
"""
return f"<{self.__class__.__name__} | IC={self.initial_conditions.directory.name}>"
def __repr__(self) -> str:
"""
Return an unambiguous string representation of the frontend.
Includes the class name, associated InitialConditions object,
and absolute configuration path for debugging.
"""
return (
f"{self.__class__.__name__}("
f"initial_conditions={repr(self.initial_conditions)}, "
f"config_path='{self.config_path}')"
)
def __getitem__(self, key):
"""
Access configuration values by key.
This is a convenience method that delegates directly to the
underlying :class:`~pisces.utilities.config.ConfigManager` object.
Parameters
----------
key : str
The configuration key to access.
Returns
-------
Any
The value associated with the key in the configuration.
"""
return self.config[key]
def __setitem__(self, key, value):
"""
Set a configuration value by key.
This is a convenience method that delegates directly to the
underlying :class:`~pisces.utilities.config.ConfigManager`.
Parameters
----------
key : str
Configuration key.
value : Any
New value to assign.
"""
self.config[key] = value
# --------------------------------------- #
# IC GENERATION UTILITIES #
# --------------------------------------- #
# These are utility methods that can be used in generation procedures
# when they are useful. They are not used in the default lifecycle,
# but may be called by subclasses as needed.
def _validate_models_have_particles(self):
"""
Ensure that all models in the IC have associated particles.
This method checks that each model defined in the
:class:`~pisces.extensions.simulation.core.initial_conditions.InitialConditions`
object has particles generated. If any model is found to be missing
particles, a RuntimeError is raised with a descriptive message.
Raises
------
RuntimeError
If any model in the initial conditions is missing particles.
"""
for model in self.initial_conditions.list_models():
if not self.initial_conditions.has_particles(model):
raise RuntimeError(
f"Model `{model}` is missing particles in the ICs!\n"
"HINT: Ensure that you have generated particles for each model before calling the"
f" {self.__class__.__name__} frontend.\n"
"HINT: You can use `add_particles_to_model` on the IC object to generate particles."
)
# --------------------------------------- #
# IC GENERATION METHODS #
# --------------------------------------- #
# These methods define the lifecycle for generating initial conditions
# through a frontend. Subclasses are expected to override
# `_generate_initial_conditions` with their own logic, and may optionally
# override `_validate_runtime_configuration` for code-specific checks.
#
@abstractmethod
def _validate_runtime_configuration(self, *args, **kwargs):
"""
Validate the runtime configuration before generating initial conditions.
This method is called immediately before initial condition
generation to ensure that the loaded configuration is valid
for the target simulation code. By default, it performs no checks.
Subclasses may override this method to:
- Enforce presence of required configuration keys.
- Check unit consistency or ranges.
- Perform any simulation-specific sanity checks
before writing files.
Parameters
----------
*args, **kwargs
Any arguments passed through from `generate_initial_conditions`.
Returns
-------
None
"""
return None
@abstractmethod
def _generate_initial_conditions(self, *args, **kwargs):
"""
Generate the initial condition files for the target simulation code.
This is the core implementation method that *must* be overridden
by subclasses. It should read from `self.config` and
`self.initial_conditions` and write any necessary files in the format
expected by the simulation code.
Parameters
----------
*args
Positional arguments specific to the subclass implementation.
**kwargs
Keyword arguments specific to the subclass implementation.
"""
pass
[docs]
def generate_initial_conditions(self, *args, **kwargs) -> Any:
"""
Generate all simulation-ready initial condition files for this frontend.
This method serves as the **main entry point** for converting a
Pisces :class:`~pisces.extensions.simulation.core.initial_conditions.InitialConditions`
object into the native input format required by an external simulation code.
It performs a complete two-step lifecycle:
1. **Validate runtime configuration** using
:meth:`_validate_runtime_configuration`, ensuring that the loaded
configuration and attached initial conditions are physically and
structurally consistent with the simulation code's expectations.
2. **Generate native input files** via
:meth:`_generate_initial_conditions`, which performs the actual
file-writing procedure defined by the subclass implementation.
Depending on the specific frontend, this process may involve:
- Writing HDF5 or binary particle data files.
- Generating code-specific metadata or parameter files.
- Performing pre-processing (e.g., orientation, bounding box cuts).
- Registering provenance or logging details to the IC directory.
Parameters
----------
*args
Positional arguments forwarded to both
:meth:`_validate_runtime_configuration` and
:meth:`_generate_initial_conditions`.
**kwargs
Keyword arguments forwarded to both methods. These may include
code-specific runtime options (e.g., ``filename``, ``overwrite=True``).
Returns
-------
Any
The return value of :meth:`_generate_initial_conditions`, which varies
depending on the frontend implementation. For example, Gadget-like
frontends return a :class:`~pisces.particles.gadget.Gadget4ParticleDataset`
instance representing the generated file.
Notes
-----
- This method is **not** meant to be overridden in subclasses.
Instead, subclasses should implement the two private lifecycle methods:
:meth:`_validate_runtime_configuration` and
:meth:`_generate_initial_conditions`.
- Any log output or error handling performed here ensures a consistent
user experience across all simulation frontends.
"""
self.logger.info(f"[{self.__class__.__name__}] Generating ICs - {self.initial_conditions}...")
# Start with the runtime validation procedure.
self._validate_runtime_configuration(*args, **kwargs)
self.logger.info(f"[{self.__class__.__name__}]\t Validating runtime configuration... [DONE]")
# Then generate the initial conditions files.
out = self._generate_initial_conditions(*args, **kwargs)
self.logger.info(f"[{self.__class__.__name__}] Generating ICs - {self.initial_conditions}... [DONE]")
return out
[docs]
class GadgetLikeFrontend(SimulationFrontend, ABC):
"""Abstract skeleton for Gadget-like simulation frontends."""
# --------------------------------------- #
# Class Variables and Constants #
# --------------------------------------- #
# These are class-level variables which define connections to
# the configuration along with some other aspects of the
# frontend's behavior.
__default_configuration_path__: Path = None # FILL IN SUBCLASSES.
"""The path to the default configuration file.
This is the path by which the frontend will attempt to locate the
default configuration file template for this frontend.
"""
__frontend_name__: str = None # FILL IN SUBCLASSES.
"""str: The name of this frontend.
The name of the frontend is substituted into various logging
calls throughout the lifecycle to provide context on which
frontend is being used. This ensures that subclasses do not
need to modify every logging call to include their own
name, and instead can just set this class-level constant.
"""
__particle_dataset_type__: "ParticleDataset" = None # FILL IN SUBCLASSES.
"""type: The particle dataset type used by this frontend.
"""
_allowed_ic_types: tuple[type] = (InitialConditions3DCartesian,)
"""tuple of type: The allowed initial conditions types for this frontend.
This is used on initialization to ensure that the input initial conditions
class is actually compatible with the frontend in question. In general, this can
be used to support many (or few) IC types based on the degree of support the developer
wishes to provide for the frontend.
"""
_simulation_types_map: dict[type, str] = {
InitialConditions3DCartesian: "3D",
}
"""dict: Mapping of initial conditions types to simulation type strings.
These are used throughout the frontend simply to identify the type of simulation
being run. This mapping allows the frontend to convert from an IC class to a
string representation of the simulation type.
"""
# --------------------------------------- #
# Properties #
# --------------------------------------- #
@property
def unit_system(self) -> unyt.unit_systems.UnitSystem:
"""
Fetch the unit system for this Gadget-like frontend.
Returns
-------
unyt.unit_systems.UnitSystem
The unit system used for this frontend.
Notes
-----
This property constructs and returns a unit system based on the
length, mass, and velocity units specified in the configuration. This
allows for easy conversion between the internal units used by the
simulation code and the physical units used in the initial conditions.
By default, Gadget-Like codes utilize a length, mass, and velocity based unit system
where the time unit is derived from the other three. This is the method used here.
"""
# Fetch the unit values from the configuration file so that we have them
# on deck.
length_unit, mass_unit, velocity_unit = (
self.config["parameters.units.length"],
self.config["parameters.units.mass"],
self.config["parameters.units.velocity"],
)
# Use the velocity and the length units to construct
# the time unit.
time_unit = (1 * length_unit) / (1 * velocity_unit)
time_unit = time_unit.to("Myr")
# Construct the unit system object from unyt.
_gadget_unit_system = unyt.unit_systems.UnitSystem(
"gadget_simulation_system",
length_unit=length_unit,
mass_unit=mass_unit,
time_unit=unyt.Unit(time_unit).simplify(),
)
return _gadget_unit_system
# --------------------------------------- #
# Initialization and Configuration #
# --------------------------------------- #
# When running a Gadget-like code, there are a few standard things that
# should be checked during the validation step. The first is that the input initial
# conditions are valid initial conditions at all, and then we check that they
# are supported by this frontend.
#
# DEVELOPERS could add further refinements to subclasses to even more finely
# control what ICs are supported, or to add additional validation steps.
def _validate_input_ic(self, initial_conditions: InitialConditions) -> bool:
"""
Validate that the provided initial conditions are suitable for this frontend.
This method is called during initialization to ensure that
the initial conditions object meets the requirements of the
specific simulation code this frontend is designed for.
Parameters
----------
initial_conditions : InitialConditions
The initial conditions object to validate. This object is
checked to ensure that it is actually a
:class:`~pisces.extensions.simulation.core.initial_conditions.InitialConditions`
object and that it is one of the supported types for this frontend.
Returns
-------
bool
True if the initial conditions are valid, False otherwise.
"""
# Check that this is even an InitialConditions object.
if not isinstance(initial_conditions, InitialConditions):
raise TypeError(f"Expected an InitialConditions object, got {type(initial_conditions)}.")
# For Gadget-like frontends, there are some specific IC types that are
# permitted and these correspond to very specific values of the simulation type.
# Depending on the simulation code in question, these may or may not be the same.
if not isinstance(initial_conditions, self._allowed_ic_types):
raise TypeError(
f"{self.__frontend_name__} frontend only supports the following initial conditions types:\n"
f" - " + "\n - ".join([cls.__name__ for cls in self._allowed_ic_types]) + "\n"
f"Got {initial_conditions.__class__.__name__}."
)
return True
def _setup_config(self):
"""
Set up the configuration for this frontend after it has been generated in the IC directory.
This method is called after the initial configuration has been
generated in the initial conditions directory. It modifies
the configuration in-place to ensure that it is suitable for
the specific initial conditions being used.
Notes
-----
In Gadget-like codes, each particle is assigned to a particular particle type and each particle
type has some set of fields. The names of these particle types and their fields may vary from code
to code and the names of those fields / particles on the Pisces side of the API may also vary. As such,
we include ``"model_template"`` in the configuration file which contains a "default" set of particle types
and fields for a single model.
Once the frontend is generated for a particular set of initial conditions, we copy this template out
so that each model has a copy of it. The user may then edit the mapping as needed before generating the
simulation input files.
Gadget-like codes also specify a number of different "simulation types" based on the type of initial conditions
being used. This is typically a string value that is set in the Makefile or parameters file. We automatically
set this value based on the type of initial conditions being used by mapping the IC class to a simulation type
string.
"""
# Extract the model template configuration from the data. If it doesn't exist,
# we need to raise an error.
if "model_template" in self.config:
default_model_config = dict(self.config["model_template"])
else:
raise RuntimeError(
f"Failed to find the `model_template` section in the {self.__frontend_name__} configuration!"
" This section is required to setup the model configurations."
)
# Now duplicate the model template for each of the models in the initial conditions.
self.config["models"] = {}
for model_name in self.initial_conditions.list_models():
self.logger.debug(f"Added {model_name} to the {self.__frontend_name__} configuration models...")
self.config["models"][model_name] = deepcopy(dict(default_model_config))
# We also need to automatically set the simulation type based on the type of
# initial condition being used.
self.config["makefile.simulation_type"] = self._simulation_types_map.get(
self.initial_conditions.__class__, None
)
if self.config["makefile.simulation_type"] is None:
raise RuntimeError(
f"Failed to map initial conditions type {self.initial_conditions.__class__.__name__} to a "
f"simulation type for the {self.__frontend_name__} frontend!"
)
# --------------------------------------- #
# IC GENERATION METHODS (STRUCTURES) #
# --------------------------------------- #
# These methods are used when converting PISCES ICs into simulation-ready
# input files. They handle the various steps needed to preprocess the
# particle datasets prior to writing them out to disk.
#
# Subclasses may need to make any number of modifications to this section of the
# code.
def _construct_bounding_box(self):
"""
Construct the simulation bounding box from the configuration.
Notes
-----
In Gadget-like codes, the simulation box is typically defined by a single
"BoxSize" parameter which defines the length of one side of the cubic
simulation volume. We convert this into a bounding box format that can
be used for particle cuts and other operations.
"""
# Extract the simulation boxsize from the configuration and ensure that
# it actually exists.
simulation_boxsize = self.config.get("parameters.boxsize", None)
if simulation_boxsize is None:
raise RuntimeError(
f"Failed to load the boxsize (parameters.boxsize) from the {self.__frontend_name__} configuration!"
)
if not isinstance(simulation_boxsize, unyt.unyt_quantity):
raise TypeError(f"Boxsize must be a `unyt_quantity` with length units, got {type(simulation_boxsize)}!")
# With the boxsize parameter available, we can start building the bounding box.
simulation_boxsize = simulation_boxsize.to_value(self.config["parameters.units.length"])
simulation_bbox = np.stack([[0, simulation_boxsize] for _ in range(3)], axis=1)
simulation_bbox = unyt.unyt_array(simulation_bbox, self.config["parameters.units.length"])
return simulation_bbox
def _reorient_particles(self, model_name):
"""
Reorient the particles for a given model based on its configuration.
Notes
-----
In Gadget-like codes, particles can be reoriented and offset based on
the model parameters specified in the initial conditions. This method
handles the reorientation and offsetting of particles for a specific model.
"""
# Extract the particles corresponding to this particular model
# so that we can modify them. Then look up the simulation type
# so that we can differentiate between different geometric
# scenarios.
particle_dataset = self.initial_conditions.load_particles(model_name)
_simulation_type = self.config["makefile.simulation_type"]
if _simulation_type not in self.__class__._simulation_types_map.values():
raise RuntimeError(
f"Failed to map initial conditions type {self.initial_conditions.__class__.__name__} to a "
f"simulation type for the {self.__frontend_name__} frontend!"
)
# Determine the parameters for the model based on the simulation type and
# the initial conditions. We track the position, velocity, orientation, and spin
# of the model so that we can apply the relevant transformations.
mpos, mvel, more, mspin = None, None, None, None # <- minimum information case (spherical)
# for particular simulation types, we need to fill these out.
if _simulation_type in ["1D", "2D", "3D"]:
mpos = self.initial_conditions.config[f"models.{model_name}.position"]
mvel = self.initial_conditions.config[f"models.{model_name}.velocity"]
if _simulation_type in ["2D", "3D"]:
more = self.initial_conditions.config[f"models.{model_name}.orientation"]
if _simulation_type == "3D":
mspin = self.initial_conditions.config[f"models.{model_name}.spin"]
self.logger.debug(
f"Model `{model_name}` parameters:\n"
f"\tPosition: {mpos}\n"
f"\tVelocity: {mvel}\n"
f"\tOrientation: {more}\n"
f"\tSpin: {mspin}\n"
)
# --- Reorient model --- #
# We are now ready to perform the reorientation of the particle dataset. We do
# this, by default, just for the position and velocity fields since we don't natively
# support B fields. If B fields need to be added, one can simply pass another call to
# particle_dataset.reorient_particles for the B field (gas) only.
particle_dataset.reorient_particles(
more,
spin=mspin,
)
# --- Offset Model --- #
# We now move the model to the correct position and velocity in the simulation volume.
particle_dataset.offset_particle_positions(mpos)
particle_dataset.offset_particle_velocities(mvel)
particle_dataset.handle.close()
def _preprocess_particle_datasets(self):
"""
Preprocess the particle datasets for all models prior to writing simulation input files.
Notes
-----
This method performs the necessary preprocessing steps on the particle datasets
for all models in the initial conditions. This includes reorienting the particles
based on the model parameters and cutting the particles down to the simulation
bounding box.
"""
# Construct the bounding box for the simulation so that
# we can use it for cutting the particle datasets.
simulation_bbox = self._construct_bounding_box()
# --- Cut Particle Files to Bounding Box --- #
# We now cycle through the models and fetch their particle datasets
# so that we can begin the reductions. For each of the ICs, we'll need
# to check the dimension to determine how to handle the bounding box cut.
for model_name in self.initial_conditions.list_models():
# --- Reorient Particles --- #
# First, we need to reorient the particles for this model
# so that they are in the correct position and orientation
# for the simulation.
self._reorient_particles(model_name)
# --- Cut to bounding box --- #
# With the particles reoriented and offset, we can now
# cut them down to the bounding box of the simulation.
particle_dataset = self.initial_conditions.load_particles(model_name)
particle_dataset.cut_particles_to_bbox(simulation_bbox)
# We now quickly log to the user that we have completed
# the model preprocessing step. This includes catching the
# new particle counts for each model and logging them.
_pcount_log_statements = "\n".join(
[
f"\tModel `{mname}` has {self.initial_conditions.get_particle_count(mname)} particles"
f" after preprocessing."
for mname in self.initial_conditions.list_models()
]
)
self.logger.info(f"{self.__class__.__name__}:\t Preprocessing... [DONE]\n" + _pcount_log_statements)
def _count_particles(self):
"""Count particles of each Gadget-4 type across all models.
Returns
-------
np.ndarray
1D array of length = number of Gadget-4 particle types,
dtype is uint64.
"""
# Number of Gadget-4 particle types (typically 6)
n_types = self.config["makefile.number_of_particle_types"]
final_count = np.zeros(n_types, dtype=int)
# Iterate over models defined in initial conditions
for model_name in self.initial_conditions.list_models():
model_config = self.config[f"models.{model_name}"]
particle_counts = self.initial_conditions.get_particle_count(model_name)
# Map model's native particle types into Gadget-4 ordering
for i, (_gadget_type, cfg) in enumerate(model_config.items()):
native_type = cfg["name"]
count = particle_counts.get(native_type, 0)
final_count[i] += count
return final_count
# --------------------------------------- #
# IC GENERATION METHODS #
# --------------------------------------- #
def _validate_runtime_configuration(self, *args, **kwargs):
"""Validate the runtime configuration for the Gadget-like frontend."""
# Start by ensuring that we have particles for each model in the
# initial conditions.
self._validate_models_have_particles()
# All of the models have particle files. We now just ensure that each
# model appears in the configuration list before allowing things
# to proceed. We DON'T check for the full structure (too much logical complexity),
# and instead let errors arise naturally if the user misconfigures things.
for model in self.initial_conditions.list_models():
if model not in self.config["models"]:
raise RuntimeError(
f"Model `{model}` is missing from the {self.__frontend_name__} frontend configuration!\n"
"HINT: Ensure that you have added an entry for each model in the `models` "
"section of the"
" configuration file.\n"
"HINT: You can use the `model_template` section to set default"
" values for each model."
)
# Everything looks good! We'll log this and return.
return None
@abstractmethod
def _generate_particle_dataset(self, *args, **kwargs):
"""
Generate the particle dataset for the entire simulation.
Notes
-----
For Gadget-like frontends, this method constructs the complete
particle dataset by iterating over each model in the initial conditions,
mapping their native particle types and fields to the Gadget-4
equivalents, and writing the data into the appropriate locations
in the overall particle dataset.
It will need to be reimplemented in subclasses if the particle dataset
construction requires different parameters or additional steps.
"""
# Begin by creating the IC particles dataset skeleton so that we can
# populate it with data from each of the models.
#
# THIS MAY NEED MODIFICATION in subclasses to handle new / different
# inputs to the particle dataset builder.
# noinspection PyArgumentList
ic_particles = self.__class__.__particle_dataset_type__.build_particle_dataset(
*args,
**kwargs,
)
# With the skeleton written, we now cycle through each of the models,
# extract their particle datasets, cast the names to the right things, and
# then proceed to write the data into the file.
_particle_count_offsets = np.zeros(self.config["makefile.number_of_particle_types"], dtype=np.uint64)
for model_name in self.initial_conditions.list_models():
# Extract the model's configuration data from the frontend
# configuration file and load the particle dataset that we
# are going to be using.
model_config = self.config[f"models.{model_name}"]
particle_dataset = self.initial_conditions.load_particles(model_name)
# We iterate through each of the initial conditions' particle
# types and map them to Gadget-4 types.
for gptype in range(self.config["makefile.number_of_particle_types"]):
# Extract relevant metadata.
gpkey = f"ParticleType{gptype}"
ipkey = model_config[gpkey]["name"]
# Check if the model even includes this particle type.
if ipkey not in particle_dataset.particle_groups:
self.logger.debug(
f"Model `{model_name}` does not have particles of type `{ipkey}`!"
f" Skipping {self.__frontend_name__} type `{gpkey}`."
)
continue
for gfield, ifield in model_config[gpkey]["fields"].items():
# Skip the ID column because we are going to handle
# that ourselves at the very end once all the
# particles have been written.
if gfield == "ParticleIDs":
continue
# Extract the particle array from the particle file
# for this model.
if f"{ipkey}.{ifield}" not in particle_dataset:
raise RuntimeError(
f"Model `{model_name}` is missing required field"
f" `{ifield}` for particle type `{ipkey}`!\n"
f"HINT: If it exists, is it named something different? Modify the"
f" configuration file to match the field name in the particle dataset.\n"
f"HINT: If it doesn't exist, you may need to derive it manually."
)
# Access the dataset in the Gadget-4 particle dataset and
# dump our data into it.
field_handle = ic_particles.get_particle_field_handle(gpkey, gfield)
field_unit = ic_particles.get_field_units(gpkey, gfield)
# Now we write the array data into the dataset by virtue of
# the tracked slicing.
_slc = slice(
int(_particle_count_offsets[gptype]),
int(_particle_count_offsets[gptype] + particle_dataset.num_particles[ipkey]),
)
field_handle[_slc, ...] = particle_dataset.get_particle_field(ipkey, ifield).to_value(field_unit)
# After writing all the fields, we need to increment the particle offsets
_particle_count_offsets[gptype] += particle_dataset.num_particles[ipkey]
self.logger.info(
f"[{self.__class__.__name__}]: Model `{model_name}` wrote"
f" {particle_dataset.num_particles[ipkey]} particles of type"
f" `{ipkey}` to {self.__frontend_name__} type `{gpkey}`."
)
return ic_particles
def _generate_initial_conditions(self, filename: str, *args, overwrite: bool, **kwargs):
# Begin by handling the file checking and overwrite language
# before proceeding to the actual generation element of the method.
path = self.ic_directory / filename
if path.exists() and not overwrite:
raise FileExistsError(f"Output file {path} already exists and overwrite is set to False!")
elif path.exists() and overwrite:
self.logger.debug(f"Overwriting existing {self.__frontend_name__} IC file at {path}...")
path.unlink()
else:
pass
# --- Pre-Process Particle Data --- #
# We can now pre-process our particle datasets so that
# they are contained within the specified bounding box and
# so that they have the correct offsets, rotations, and velocities.
# This is a universal process that should need minimal (if any) manipulations
# when passing down to subclasses.
self._preprocess_particle_datasets()
# Fetch the unit system so that we have it available
# for the relevant manipulations.
unit_system = self.unit_system
self.logger.debug(
f"Using {self.__frontend_name__} unit system:\n"
f"\tLength Unit: {unit_system['length']}\n"
f"\tMass Unit: {unit_system['mass']}\n"
f"\tTime Unit: {unit_system['time']}\n"
f"\tVelocity Unit: {unit_system['velocity']}\n"
)
# Load / generate the particle dataset for the entire simulation.
ic_particles = self._generate_particle_dataset(path, overwrite=overwrite)
# --- Add IDs --- #
# With all of the other fields written, we now need to handle
# the particle IDs. Gadget-4 expects these to be unique across
# all particle types, so we need to be a bit careful about how
# we generate them.
if self.config["makefile.nbits_id"] == 64:
dtype = np.uint64
elif self.config["makefile.nbits_id"] == 32:
dtype = np.uint32
else:
raise RuntimeError(
f"Unsupported ID bit-width {self.config['makefile.nbits_id']} in {self.__frontend_name__} "
f"configuration!"
)
ic_particles.add_particle_ids(policy="global", overwrite=True, start_id=1, dtype=dtype)
return ic_particles
[docs]
def generate_initial_conditions(self, filename: str, *args, overwrite: bool = False, **kwargs):
"""
Generate Gadget-compatible initial condition files for this frontend.
This method provides a convenience wrapper around the base
:meth:`~pisces.extensions.simulation.core.frontends.SimulationFrontend.generate_initial_conditions`
method, adding a standard ``filename`` and ``overwrite`` interface for
Gadget-like simulation codes.
It performs the complete frontend lifecycle:
1. **Validate runtime configuration** using
:meth:`_validate_runtime_configuration`, ensuring that the current
configuration and particle data are consistent and complete.
2. **Write the simulation input files** by calling the subclass-specific
:meth:`_generate_initial_conditions`, which performs file creation,
unit system setup, particle preprocessing, and ID assignment.
Parameters
----------
filename : str
Name of the output file to be written within the initial conditions
directory (e.g., ``"ClusterICs.hdf5"``). The full path is automatically
resolved from :attr:`ic_directory`.
*args
Additional positional arguments forwarded to both
:meth:`_validate_runtime_configuration` and
:meth:`_generate_initial_conditions`.
overwrite : bool, default=False
If ``True``, any existing file with the same name will be replaced.
If ``False``, an existing file will raise a
:class:`FileExistsError`.
**kwargs
Additional keyword arguments forwarded to both
:meth:`_validate_runtime_configuration` and
:meth:`_generate_initial_conditions`. These may include
code-specific options (e.g., compression flags, chunking behavior, etc.).
Returns
-------
Any
The object returned by :meth:`_generate_initial_conditions`. For most
Gadget-like frontends, this will be a
:class:`~pisces.particles.gadget.Gadget4ParticleDataset` representing
the generated simulation file.
Notes
-----
- This method should not be overridden in subclasses unless the frontend
requires a fundamentally different call signature.
- The ``filename`` argument is always interpreted relative to
:attr:`ic_directory`.
- Logging messages will automatically indicate progress through
validation and file generation stages.
"""
return super().generate_initial_conditions(filename, *args, overwrite=overwrite, **kwargs)