Source code for pisces.extensions.simulation.core.frontends

"""
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 pathlib import Path
from typing import Any

from pisces.utilities.config import ConfigManager

from .initial_conditions import InitialConditions


[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." ) # --- 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]")
@abstractmethod def __post_init__(self): """ 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 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 the necessary initial condition files from this frontend. This method serves as the main entry point for generating initial conditions. It first validates the runtime configuration using :meth:`_validate_runtime_configuration`, and then calls :meth:`_generate_initial_conditions` to perform the actual file generation. Depending on the frontend implementation, this may involve writing files in a specific format, creating metadata, or performing additional setup steps. Parameters ---------- *args Positional arguments forwarded to both validation and generation. **kwargs Keyword arguments forwarded to both validation and generation. Returns ------- The output from :meth:`_generate_initial_conditions`, which may vary depending on the frontend implementation. """ 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