Source code for virtual_ecosystem.core.base_model

"""The :mod:`~virtual_ecosystem.core.base_model` module defines the high level API for
the different models within the Virtual Ecosystem. The module creates the
:class:`~virtual_ecosystem.core.base_model.BaseModel` abstract base class (ABC) which
defines a consistent API for subclasses defining an actual model. The API defines
abstract methods for each of the key stages in the workflow of running a model:
individual subclasses are **required** to provide model specific implementations for
each stage, although the specific methods may simply do nothing if no action is needed
at that stage. The stages are:

* Creating a model instance (:class:`~virtual_ecosystem.core.base_model.BaseModel`).
* Setup a model instance (:meth:`~virtual_ecosystem.core.base_model.BaseModel._setup`).
  This method should include any initialization logic including validating and
  populating class attributes.
* Perform any spinup required to get a model state to equilibrate
  (:meth:`~virtual_ecosystem.core.base_model.BaseModel.spinup`).
* Update the model from one time step to the next
  :meth:`~virtual_ecosystem.core.base_model.BaseModel.update`).
* Cleanup any unneeded resources at the end of a simulation
  (:meth:`~virtual_ecosystem.core.base_model.BaseModel.cleanup`).

The :class:`~virtual_ecosystem.core.base_model.BaseModel` class also provides default
implementations for the :meth:`~virtual_ecosystem.core.base_model.BaseModel.__repr__`
and :meth:`~virtual_ecosystem.core.base_model.BaseModel.__str__` special methods.

Declaring new subclasses
------------------------

The :class:`~virtual_ecosystem.core.base_model.BaseModel` has the following class
attributes that must be specified as arguments to the subclass declaration:

* :attr:`~virtual_ecosystem.core.base_model.BaseModel.model_name`,
* :attr:`~virtual_ecosystem.core.base_model.BaseModel.vars_required_for_init`,
* :attr:`~virtual_ecosystem.core.base_model.BaseModel.vars_populated_by_init`,
* :attr:`~virtual_ecosystem.core.base_model.BaseModel.vars_required_for_update`,
* :attr:`~virtual_ecosystem.core.base_model.BaseModel.vars_updated`,
* :attr:`~virtual_ecosystem.core.base_model.BaseModel.model_update_bounds` and
* :attr:`~virtual_ecosystem.core.base_model.BaseModel.vars_updated`.

This behaviour is defined in the :meth:`BaseModel.__init_subclass__()
<virtual_ecosystem.core.base_model.BaseModel.__init_subclass__>` method, which also
gives example code for declaring a new subclass.

The usage of these attributes is described in their docstrings and each is validated
when a new subclass is created using the following private methods of the class:

* :meth:`~virtual_ecosystem.core.base_model.BaseModel._check_model_name`,
* :meth:`~virtual_ecosystem.core.base_model.BaseModel._check_variables_attribute` and
* :meth:`~virtual_ecosystem.core.base_model.BaseModel._check_model_update_bounds`.

Model checking
--------------

The :class:`~virtual_ecosystem.core.base_model.BaseModel` abstract base class defines
the :func:`~virtual_ecosystem.core.base_model.BaseModel.__init_subclass__` class
method. This method is called automatically whenever a subclass of the ABC is imported
and validates the class attributes for the new model class.

The ``BaseModel.__init__`` method
----------------------------------

Each model subclass should include an ``__init__`` method that defines all
model specific attributes. The ``__init__`` should not contain any further
initialization logic, which should happen in the subclass ``_setup`` method instead.
The ``__init__`` method **must** call the
:meth:`BaseModel.__init__() <virtual_ecosystem.core.base_model.BaseModel.__init__>`
method, as this populates core shared model attributes - see the linked method
description for details.

.. code-block:: python

    super().__init__(data, core_components)


The ``from_config`` factory method
----------------------------------

The ABC also defines the abstract class method
:func:`~virtual_ecosystem.core.base_model.BaseModel.from_config`. This method must be
defined by subclasses and must be a factory method that returns an instance of the model
subclass. The method must follow the signature of that method, providing:

* ``data`` as an instance of :class:`~virtual_ecosystem.core.data.Data`.
* ``core_components`` as an instance of
  :class:`~virtual_ecosystem.core.core_components.CoreComponents`.
* ``configuration`` as an instance of
  :class:`~virtual_ecosystem.core.configuration.CompiledConfiguration`.

The method should provide any code to validate the configuration for that model and then
use the configuration to initialise and return a new instance of the class.

Model registration
------------------

Models have two core components: the
:class:`~virtual_ecosystem.core.base_model.BaseModel` subclass itself (``model``) and a
model configuration module that both defines the configuration options and constants
associated with the model and provides validation of configuration data from TOML files.
All model modules must register these components when they are imported: see the
:mod:`~virtual_ecosystem.core.registry` module.
"""  # noqa: D205, D415

from __future__ import annotations

import pkgutil
from abc import ABC, abstractmethod
from importlib import import_module
from types import ModuleType
from typing import Any

import pint

from virtual_ecosystem.core.configuration import CompiledConfiguration
from virtual_ecosystem.core.core_components import (
    CoreComponents,
    DisturbanceTiming,
    LayerStructure,
    ModelTiming,
)
from virtual_ecosystem.core.data import Data, Grid
from virtual_ecosystem.core.exceptions import ConfigurationError
from virtual_ecosystem.core.logger import LOGGER
from virtual_ecosystem.core.model_config import CoreConstants


[docs] class BaseModel(ABC): """A superclass for all Virtual Ecosystem models. This abstract base class defines the shared common methods and attributes used as an API across all Virtual Ecosystem models. This includes functions to setup, spin up and update the specific model, as well as a function to cleanup redundant model data. The base class defines the core abstract methods that must be defined in subclasses as well as shared helper functions. Args: data: A :class:`~virtual_ecosystem.core.data.Data` instance containing variables to be used in the model. core_components: A :class:`~virtual_ecosystem.core.core_components.CoreComponents` instance containing shared core elements used throughout models. """ model_name: str """The model name. This class attribute sets the name used to refer to identify the model class in the :data:`~virtual_ecosystem.core.registry.MODULE_REGISTRY`, within the configuration settings and in logging messages. """ model_update_bounds: tuple[pint.Quantity, pint.Quantity] """Bounds on model update frequencies. This class attribute defines two time intervals that define a lower and upper bound on the update frequency that can reasonably be used with a model. Models updated more often than the lower bound may fail to capture transient dynamics and models updated more slowly than the upper bound may fail to capture important temporal patterns. """ vars_required_for_init: tuple[str, ...] """Required variables for model initialisation. This class property defines a set of variable names that must be present in the :class:`~virtual_ecosystem.core.data.Data` instance used to initialise an instance of this class. It is a tuple containing zero or more tuples, each providing a variable name and then a tuple of zero or more core axes that the variable must map onto. For example: ``(('temperature', ('spatial', 'temporal')),)`` """ vars_updated: tuple[str, ...] """Variables that are updated by the model. At the moment, this tuple is used to decide which variables to output from the :class:`~virtual_ecosystem.core.data.Data` object, i.e. every variable updated by a model used in the specific simulation. It is also be used warn if multiple models will be updating the same variable and to verify that these variables are indeed initialised by another model, and therefore will be available. """ vars_required_for_update: tuple[str, ...] """Variables that are required by the update method of the model. These variables should have been initialised by another model or loaded from external sources, but in either case they will be available in the data object. """ vars_populated_by_init: tuple[str, ...] """Variables that are initialised by the model during the setup. These are the variables that are initialised by the model and stored in the data object when running the setup method and that will be available for other models to use in their own setup or update methods. """ vars_populated_by_first_update: tuple[str, ...] """Variables that are initialised by the model during the first update. These are the variables that are initialised by the model and stored in the data object when running the update method for the first time. They will be available for other models to use in their update methods but not in the setup methods. """
[docs] def __init__( self, data: Data, core_components: CoreComponents, static: bool = False, *args: Any, ): """Performs core initialisation for BaseModel subclasses. This method **must** be called in the ``__init__`` method of all subclasses. It populates a set of shared instance attributes from the provided :class:`~virtual_ecosystem.core.core_components.CoreComponents` and :class:`~virtual_ecosystem.core.data.Data` value: * ``data``: the provided :class:`~virtual_ecosystem.core.data.Data` instance. * ``model_timing``: the :class:`~virtual_ecosystem.core.core_components.ModelTiming` instance from the ``core_components`` argument. * ``grid``: the :class:`~virtual_ecosystem.core.grid.Grid` instance from the ``core_components`` argument. * ``layer_structure``: the :class:`~virtual_ecosystem.core.core_components.LayerStructure` instance from the ``core_components`` argument. * ``core_constants``: the :class:`~virtual_ecosystem.core.model_config.CoreConstants` instance from the ``core_components`` argument. It then uses the :meth:`~virtual_ecosystem.core.base_model.BaseModel.check_init_data` method to confirm that the required variables for the model are present in the provided :attr:`~virtual_ecosystem.core.base_model.BaseModel.data` attribute. """ self.data: Data = data """A Data instance providing access to the shared simulation data.""" self.model_timing: ModelTiming = core_components.model_timing """The ModelTiming details used in the model.""" self.grid: Grid = core_components.grid """The Grid details used in the model.""" self.layer_structure: LayerStructure = core_components.layer_structure """The LayerStructure details used in the model.""" self.core_constants: CoreConstants = core_components.core_constants """The core constants used in the model.""" self._repr: list[tuple[str, ...]] = [("model_timing", "update_interval")] """A list of attributes to be included in the class __repr__ output""" self._static = static """Flag indicating if the model is static, i.e. does not change with time.""" self._run_setup: bool """Flag indicating if the setup method should run when model static.""" self._run_initial_static_update: bool """Flag indicating if the update method should be run once when model static.""" # Check the required init variables self.check_init_data() # Check the configured update interval is within model bounds self._check_update_speed() # Set the static configuration settings self._set_static_config()
def _set_static_config(self): """Set the static configuration . The method checks that the model static configuration and provided data are a valid configuration and then sets the `_run_setup` and `_run_initial_static_update` flags as appropriate. Raises: ConfigurationError: If there is any error in the static configuration of the model. """ self._run_setup = self._run_setup_due_to_static_configuration() self._run_initial_static_update = self._run_update_due_to_static_configuration() # Bypassing the setup and running the update is not valid if not self._run_setup and self._run_initial_static_update: raise ConfigurationError( f"Static model {self.model_name} will not run the setup method, but " "requires the update method to run once. This is an invalid " "configuration. Please, make sure that either both methods are run " "once by not providing any variables in vars_populated_by_first_update " "and vars_updated or that both are bypassed by providing all variables " "in vars_populated_by_init." ) # Flag indicating if the setup method will setup any variable any_var_to_setup = len(self.vars_populated_by_init) > 0 # Flag indicating if the update method will update any variable any_var_to_update = ( len(self.vars_populated_by_first_update) > 0 or len(self.vars_updated) > 0 ) # Running the setup but not the update is only valid if # - There are no variables to setup (setup is always run in this case), or # - There are no variables to update (unusual case) if ( self._run_setup and not self._run_initial_static_update and any_var_to_setup and any_var_to_update ): raise ConfigurationError( f"Static model {self.model_name} will run the setup method, but " "not the update method. This is an invalid configuration. " "Please, make sure that either both methods are run once" " by not providing any variables in vars_populated_by_init or that both" " are bypassed by providing all variables in " "vars_populated_by_first_update and vars_updated." ) def _run_setup_due_to_static_configuration(self) -> bool: """Decide if the setup should be run based on the static flag. In particular, it checks that the appropriate variables populated by init are present or not in the data object. Based on this, an exception is raised is there is a problem or a decision is made on whether or not bypass the setup. Raises: ConfigurationError: If the model is static and some but not all the variables or None in vars_populated_by_init are present in the data object or if the model is not static and some of the variables in vars_populated_by_init are not present in the data object. Returns: False if the model is static and all variables are present, such that the setup method can be bypassed. True otherwise. """ present = [var for var in self.vars_populated_by_init if var in self.data] found = len(present) expected = len(self.vars_populated_by_init) if not self._static and present: raise ConfigurationError( f"Non-static model {self.model_name} requires none of the variables in " "vars_populated_by_init to be present in the data object. " f"Present variables: {', '.join(present)}" ) elif self._static: if 0 < found < expected: raise ConfigurationError( f"Static model {self.model_name} requires to either all variables " "in vars_populated_by_init to be present in the data object or " f"all to be absent. {found} out of {expected} found: " f"{', '.join(present)}." ) elif found == 0: # The case when static is true and no init vars provided return True else: # The case when static is true and all init vars provided return False return True def _run_update_due_to_static_configuration(self) -> bool: """Decides if the update should be bypassed based on the static flag. In particular, it checks that the appropriate variables created or updated in the update method are present or not in the data object. Based on this, an exception is raised is there is a problem or a decision is made on whether or not running the update method once. Raises: ConfigurationError: If the model is static and some but not all the variables or None in vars_populated_by_first_update or vars_updated are present in the data object or if the model is not static and some of the variables in vars_populated_by_init are not present in the data object. Returns: True if the model is static and the update method needs to run once. False otherwise. """ required = set(self.vars_populated_by_first_update + self.vars_updated) - set( self.vars_required_for_init ) present = [var for var in required if var in self.data] found = len(present) expected = len(required) if not self._static and present: raise ConfigurationError( f"Non-static model {self.model_name} requires none of the variables in " "vars_populated_by_first_update or vars_updated to be present in the " f"data object. Present variables: {', '.join(present)}" ) elif self._static: if found == 0 and expected > 0: return True elif found == expected: return False else: raise ConfigurationError( f"Static model {self.model_name} requires to either all variables " "in vars_populated_by_first_update and vars_updated to be present " f"in the data object or all to be absent. {found} out of {expected}" f" found: {', '.join(present)}." ) return True
[docs] @abstractmethod def _setup(self, *args: Any) -> None: """Function to setup the model during initialisation."""
[docs] @abstractmethod def spinup(self) -> None: """Function to spin up the model."""
[docs] def update(self, time_index: int, **kwargs: Any) -> None: """Function to update the model. If the model is static, the inner update method, self._update will only run once, at most. Args: time_index: The index representing the current time step in the data object. **kwargs: Further arguments to the update method. """ log_message = f"Updating {self.model_name} model" if self._static: if not self._run_initial_static_update: LOGGER.info(f"Model {self.model_name} in static mode, no update.") return else: self._run_initial_static_update = False log_message = ( f"Running initial update for {self.model_name} model in static mode" ) LOGGER.info(log_message) self._update(time_index, **kwargs)
@abstractmethod def _update(self, time_index: int, **kwargs: Any) -> None: """Function to update the model. Args: time_index: The index representing the current time step in the data object. **kwargs: Further arguments to the update method. """
[docs] @abstractmethod def cleanup(self) -> None: """Function to delete objects within the class that are no longer needed."""
[docs] @classmethod @abstractmethod def from_config( cls, data: Data, configuration: CompiledConfiguration, core_components: CoreComponents, ) -> BaseModel: """Factory function to unpack config and initialise a model instance."""
[docs] @classmethod def _check_model_name(cls, model_name: str) -> str: """Check the model_name attribute is valid. Args: model_name: The :attr:`~virtual_ecosystem.core.base_model.BaseModel.model_name` attribute to be used for a subclass. Raises: ValueError: the model_name is not a string. Returns: The provided ``model_name`` if valid """ if not isinstance(model_name, str): excep = TypeError( f"Class attribute model_name in {cls.__name__} is not a string" ) LOGGER.error(excep) raise excep return model_name
[docs] @classmethod def _check_variables_attribute( cls, variables_attribute_name: str, variables_attribute_value: tuple[str, ...], ) -> tuple[str, ...]: """Check a model variables attribute property is valid. Creating an instance of the BaseModel class requires that several variables attributes are set. Each of these provides a list of variable names that are required or updated by the model at various points. This method is used to validate the structure of the new instance and ensure the resulting model structure is consistent. Args: variables_attribute_name: The name of the variables attribute variables_attribute_value: The provided value for the variables attribute Raises: TypeError: the value of the model variables attribute has the wrong type structure. Returns: The validated variables attribute value """ # Check the structure if isinstance(variables_attribute_value, tuple) and all( isinstance(vname, str) for vname in variables_attribute_value ): return variables_attribute_value to_raise = TypeError( f"Class attribute {variables_attribute_name} has the wrong " f"structure in {cls.__name__}" ) LOGGER.error(to_raise) raise to_raise
[docs] @classmethod def _check_model_update_bounds( cls, model_update_bounds: tuple[str, str] ) -> tuple[pint.Quantity, pint.Quantity]: """Check that the model_update_bounds attribute is valid. This is used to validate the class attribute :attr:`~virtual_ecosystem.core.base_model.BaseModel.model_update_bounds`, which describes the lower and upper bounds on model update frequency. The lower bound must be less than the upper bound. Args: model_update_bounds: A tuple of two strings representing time periods that can be parsed using :class:`pint.Quantity`. Raises: ValueError: If the provided model_update_bounds cannot be parsed as :class:`pint.Quantity` with time units or if the lower bound is not less than the upper bound. Returns: The validated model_update_bounds, converted to a tuple of :class:`pint.Quantity` values. """ # Check the conversion try: model_update_bounds_pint: tuple[pint.Quantity, pint.Quantity] = ( pint.Quantity(model_update_bounds[0]), pint.Quantity(model_update_bounds[1]), ) except pint.errors.UndefinedUnitError: to_raise = ValueError( f"Class attribute model_update_bounds for {cls.__name__} " "contains undefined units." ) LOGGER.error(to_raise) raise to_raise # Check time units if not all(val.check("[time]") for val in model_update_bounds_pint): to_raise = ValueError( f"Class attribute model_update_bounds for {cls.__name__} " "contains non-time units." ) LOGGER.error(to_raise) raise to_raise # Check lower less than upper bound if model_update_bounds_pint[0] >= model_update_bounds_pint[1]: to_raise = ValueError( f"Lower time bound for {cls.__name__} is not less than the upper bound." ) LOGGER.error(to_raise) raise to_raise return model_update_bounds_pint
def _check_update_speed(self) -> None: """Method to check that the configure update speed is within the model bounds. Raises: ConfigurationError: If the update interval does not fit with the model's time bounds """ # Check if either bound is violated if self.model_timing.update_interval_quantity < self.model_update_bounds[0]: to_raise = ConfigurationError( f"The update interval is faster than the {self.model_name} " f"lower bound of {self.model_update_bounds[0]}." ) LOGGER.error(to_raise) raise to_raise if self.model_timing.update_interval_quantity > self.model_update_bounds[1]: to_raise = ConfigurationError( f"The update interval is slower than the {self.model_name} " f"upper bound of {self.model_update_bounds[1]}." ) LOGGER.error(to_raise) raise to_raise
[docs] @classmethod def __init_subclass__( cls, model_name: str, model_update_bounds: tuple[str, str], vars_required_for_init: tuple[str, ...], vars_updated: tuple[str, ...], vars_required_for_update: tuple[str, ...], vars_populated_by_init: tuple[str, ...], vars_populated_by_first_update: tuple[str, ...], ) -> None: """Initialise subclasses deriving from BaseModel. This method runs when a new BaseModel subclass is imported. It adds the new subclasses to the model registry and populates the values of the class attributes. Subclasses of the BaseModel need to provide the values for class attributes in their signatures. Those values are defined by the arguments to this method, which validates and sets the class attributes for the subclass. See :class:`~virtual_ecosystem.core.base_model.BaseModel` for details on the class attributes. For example: .. code-block:: python class ExampleModel( BaseModel, model_name='example', model_update_bounds= ("30 minutes", "3 months"), vars_required_for_init=(("required_variable", ("spatial",)),), vars_updated=("updated_variable"), ): ... Args: model_name: The model name to be used model_update_bounds: Bounds on update intervals handled by the model vars_required_for_init: A tuple of the variables required to create a model instance. vars_populated_by_init: A tuple of the variables initialised when a model instance is created. vars_populated_by_first_update: A tuple of the variables initialised when a model update method first run. vars_required_for_update: A tuple of the variables required to update a model instance. vars_updated: A tuple of the variable names updated by the model. Raises: ValueError: If the model_name or vars_required_for_init properties are not defined TypeError: If model_name is not a string """ if cls.update != BaseModel.update: raise NotImplementedError( "Model subclasses cannot override the update method." ) try: cls.model_name = cls._check_model_name(model_name=model_name) # Validate the structure of the variables attributes for name, attr in ( ("vars_required_for_init", vars_required_for_init), ("vars_populated_by_init", vars_populated_by_init), ("vars_required_for_update", vars_required_for_update), ("vars_updated", vars_updated), ("vars_populated_by_first_update", vars_populated_by_first_update), ): setattr(cls, name, cls._check_variables_attribute(name, attr)) cls.model_update_bounds = cls._check_model_update_bounds( model_update_bounds=model_update_bounds ) except (NotImplementedError, TypeError, ValueError) as excep: LOGGER.critical( f"Errors in defining {cls.__name__} class attributes: see log" ) raise excep
[docs] def __repr__(self) -> str: """Represent a Model as a string from the attributes listed in _repr. Each entry in self._repr is a tuple of strings providing a path through the model hierarchy. The method assembles the tips of each path into a repr string. """ repr_elements: list[str] = [] for repr_entry in self._repr: obj = self for attr in repr_entry: obj = getattr(obj, attr) repr_elements.append(f"{attr}={obj}") # Add all args to the function signature repr_string = ", ".join(repr_elements) return f"{self.__class__.__name__}({repr_string})"
[docs] def __str__(self) -> str: """Inform user what the model type is.""" return f"A {self.model_name} model instance"
[docs] def check_init_data(self) -> None: """Check the init data contains the required variables. This method is used to check that the set of variables defined in the :attr:`~virtual_ecosystem.core.base_model.BaseModel.vars_required_for_init` class attribute are present in the :attr:`~virtual_ecosystem.core.data.Data` instance used to create a new instance of the class. Raises: ValueError: If the Data instance does not contain all the required variables or if those variables do not map onto the required axes. """ # Canary variable for failed checks init_data_ok = True # Check for missing init variables provided_variable_names = set(self.data.data.data_vars.variables) missing_vars = set(self.vars_required_for_init).difference( provided_variable_names ) if missing_vars: init_data_ok = False error = ValueError( f"{self.model_name} model: input data is missing required " f"initialisation variables: {','.join(missing_vars)}" ) LOGGER.error(error) # TODO: Check required axes on provided variables but this needs fixing up axis # requirements in data variables TOML file. # # Get a list of missing axes # bad_axes = [] # # Could use try: here and let on_core_axis report errors but easier to # # provide more clearly structured feedback this way # for axis in axes: # if not self.data.on_core_axis(var, axis): # bad_axes.append(axis) # Log the outcome # if bad_axes: # LOGGER.error( # f"{self.model_name} model: required var '{var}' " # f"not on required axes: {','.join(bad_axes)}" # ) # all_axes_ok = False # else: # Raise if any problems found if not init_data_ok: error = ValueError( f"{self.model_name} model: Problems with initial model data: check log." ) LOGGER.error(error) raise error # Log successful data checking LOGGER.info(f"{self.model_name} model: required initial data variables checked")
[docs] def to_camel_case(snake_str: str) -> str: """Convert a snake_case string to CamelCase. Args: snake_str: The snake case string to convert. Returns: The camel case string. """ return "".join(x.capitalize() for x in snake_str.lower().split("_"))
def _discover_models[T](models: ModuleType, of_type: type[T]) -> list[type[T]]: """Discover all the models in Virtual Ecosystem. We use the generic T type to ensure that the types of the inputs and the outputs are linked together. In practice, T will be either :attr:`~virtual_ecosystem.core.base_model.BaseModel` or :attr:`~virtual_ecosystem.core.base_model.BaseDisturbance`. """ models_found = [] for mod in pkgutil.iter_modules(models.__path__): if not mod.ispkg: continue try: module = import_module(f"{models.__name__}.{mod.name}.{mod.name}_model") except ImportError: LOGGER.warning( f"No model file found for model {models.__name__}.{mod.name}." ) continue mod_class_name = to_camel_case(mod.name) + "Model" if hasattr(module, mod_class_name) and issubclass( getattr(module, mod_class_name), of_type ): models_found.append(getattr(module, mod_class_name)) else: LOGGER.warning( f"No model class '{mod_class_name}' of type `{of_type}` found in module" f" '{models.__name__}.{mod.name}.{mod.name}_model'." ) continue return models_found
[docs] def discover_models() -> list[type[BaseModel]]: """Discover all the models in Virtual Ecosystem.""" import virtual_ecosystem.models as models return _discover_models(models, BaseModel) # type: ignore[type-abstract]
[docs] class BaseDisturbance(ABC): """A superclass for all Virtual Ecosystem disturbance models. This abstract base class defines the shared common methods and attributes used as an API across all Virtual Ecosystem disturbance models. This includes functions to setup and run the specific model. The base class defines the core abstract methods that must be defined in subclasses as well as shared helper functions. Args: data: A :class:`~virtual_ecosystem.core.data.Data` instance containing variables to be used in the model. core_components: A :class:`~virtual_ecosystem.core.core_components.CoreComponents` instance containing shared core elements used throughout models. """ model_name: str """The model name. This class attribute sets the name used to refer to identify the disturbance class in the disturbance registry, within the configuration settings and in logging messages. """ disturbed_models: tuple[str, ...] """A list of model names that this disturbance will affect. This list will be used to validate the configuration - check that all the models to disturb are available in the simulation - as well at runtime to select those models when creating an instance of the disturbance.""" data_variables_disturbed: tuple[str, ...] """A list of data variables that will be updated. This list will be used to validate the configuration and ensure that all the variables to be disturbed will be available in the simulation. Disturbance models do not create new variables. """
[docs] def __init__( self, data: Data, models: dict[str, BaseModel], disturbance_timing: DisturbanceTiming, **kwargs, ): """Performs core initialization for BaseModel subclasses. This method **must** be called in the ``__init__`` method of all subclasses. * ``data``: the provided :class:`~virtual_ecosystem.core.data.Data` instance. * ``models``: dictionary of :class:`~virtual_ecosystem.core.base_model.BaseModel` instances of the models available in the simulation. * ``disturbance_timing``: the :class:`~virtual_ecosystem.core.core_components.DisturbanceTiming` instance. """ self.data = data """A Data instance providing access to the shared simulation data.""" self.timing = disturbance_timing """The DisturbanceTiming details used in the model.""" self._repr: list[tuple[str, ...]] = [("timing", "_run_at")] """A list of attributes to be included in the class __repr__ output""" missing = set(self.disturbed_models).difference(models.keys()) if missing: raise ConfigurationError( f"Models {missing} required by disturbance {self.model_name}" "not available." ) self.models = { name: model for name, model in models.items() if name in self.disturbed_models } """The models this disturbance will disturb."""
[docs] @classmethod def __init_subclass__( cls, model_name: str, disturbed_models: tuple[str, ...], data_variables_disturbed: tuple[str, ...], ): """Checks the disturbed models and variables are all known. If so, it adds the disturbance to the registry. """ cls.model_name = cls._check_model_name(model_name) cls.disturbed_models = cls._check_attributes(disturbed_models) cls.data_variables_disturbed = cls._check_attributes(data_variables_disturbed)
[docs] @classmethod def _check_model_name(cls, model_name: str) -> str: """Check the model_name attribute is valid. Args: model_name: The :attr:`~virtual_ecosystem.core.base_model.BaseModel.model_name` attribute to be used for a subclass. Raises: ValueError: the model_name is not a string. Returns: The provided ``model_name`` if valid """ if not isinstance(model_name, str): excep = TypeError( f"Class attribute model_name in {cls.__name__} is not a string" ) LOGGER.error(excep) raise excep return model_name
@classmethod def _check_attributes(cls, attribute_value: tuple[str, ...]) -> tuple[str, ...]: """Check that disturbance variables and models attributes are valid. They both need to be tuples of strings, so we make sure that is the case when creating the class. Args: attribute_value: The provided value for the attribute Raises: TypeError: the value of the model attribute has the wrong type structure. Returns: The validated variables attribute value """ # Check the structure if isinstance(attribute_value, tuple) and all( isinstance(vname, str) for vname in attribute_value ): return attribute_value to_raise = TypeError( f"Class attribute {attribute_value} has the wrong " f"structure in {cls.__name__}" ) LOGGER.error(to_raise) raise to_raise
[docs] @classmethod @abstractmethod def from_config( cls, data: Data, configuration: CompiledConfiguration, core_components: CoreComponents, models: dict[str, BaseModel], ) -> BaseDisturbance: """Factory function to unpack config and initialise a model instance."""
[docs] def disturb(self, time_index: int) -> None: """Run the disturbance, updating the self.data and/or self.models as needed. First, the timing is checked, returning if the disturbance shall not be run at this timestep. Otherwise, it calls the inner _disturb method where the actual disturbance is executed. Args: time_index: The index of the current timestep. """ if not self.timing.check_run(time_index): return self._disturb(time_index)
@abstractmethod def _disturb(self, time_index: int) -> None: """Run the disturbance, updating the self.data and/or self.models as needed. Args: time_index: The index of the current timestep. """
[docs] def __repr__(self) -> str: """Represent a Disturbance as a string from the attributes listed in _repr. Each entry in self._repr is a tuple of strings providing a path through the model hierarchy. The method assembles the tips of each path into a repr string. """ repr_elements: list[str] = [] for repr_entry in self._repr: obj = self for attr in repr_entry: obj = getattr(obj, attr) repr_elements.append(f"{attr}={obj}") # Add all args to the function signature repr_string = ", ".join(repr_elements) return f"{self.__class__.__name__}({repr_string})"
[docs] def discover_disturbances() -> list[type[BaseDisturbance]]: """Discover all the disturbances in Virtual Ecosystem.""" import virtual_ecosystem.disturbances as disturbances return _discover_models(disturbances, BaseDisturbance) # type: ignore[type-abstract]