Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 33 additions & 21 deletions docs/source/development/design/defining_new_models.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ However, the simulation is designed to be modular:
This page sets out the steps needed to add a new model to the Virtual Ecosystem and
ensure that it can be accessed by the `core` processes in the simulation.

```{important}
When a model is used in the Virtual Ecosystem, the code relies on naming conventions to
access the different model components used in the model. You need to choose a unique
model name that will be used to name the root model directory, submodules within the
model and then two critical model components.

There are two naming conventions:

* Model directory and file names use **snake case** (lower case with underscores): e.g.
`abiotic` or `abiotic_simple`.
* Class names use **camel case** (capitalised words with no spaces): e.g. `Abiotic` and
`AbioticSimple`.

The critical names are the model subclass and configuration subclasses.

* `abiotic_simple.abiotic_simple_model.AbioticSimpleModel`
* `abiotic_simple.model_config.AbioticSimpleConfiguration`
```

The rest of this page assumes a new `freshwater` model.

## Create a new submodule folder

Start by creating a new folder for your model, within the `virtual_ecosystem/models/`
Expand All @@ -49,8 +70,8 @@ to add other python modules containing different parts of the module functionali

* An `__init__.py` file, which tells Python that the folder is a submodule within the
`virtual_ecosystem` package.
* A python module `{model_name}_model.py` that will contain the main model
object.
* A python module `freshwater_model.py` that will contain the main model
object, which must be called `FreshwaterModel`.
* A JSON Schema file defining the model configuration, called `schema.json`.
* A python module `constants.py` that will contain the constants relevant to the model.

Expand Down Expand Up @@ -168,8 +189,10 @@ from virtual_ecosystem.models.freshwater.streamflow import calculate_streamflow

### Defining the new class and class attributes

Now create a new class, that derives from the
{mod}`~virtual_ecosystem.core.base_model.BaseModel`. To begin with, choose a class name
Now create a new class that derives from the
{mod}`~virtual_ecosystem.core.base_model.BaseModel`.

To begin with, choose a class name
for the model and define the following class attributes.

The {attr}`~virtual_ecosystem.core.base_model.BaseModel.model_name` attribute
Expand Down Expand Up @@ -511,29 +534,18 @@ Lastly, you will need to set up the `__init__.py` file in the submodule director
file is used to tell Python that the directory contains a package submodule, but can
also be used to supply code that is automatically run when a module is imported.

In the Virtual Ecosystem, we use the `__init__.py` file in model submodules to:
In the Virtual Ecosystem, we just use the `__init__.py` file in model submodules to
provide a brief overview of the module. It can be used to provide a short description
of any submodules and how they are used within the model. The submodule files should
then have their own docstring progviding more detail. These docstrings are automatically
included in the HTML documentation of the package.

* provide a brief overview of the module, and
* import the model object into the module root to make it easier to import.

The file will look something like:
A docstring should be formatted using block quotes, as below:

```{code-block} python
"""This is the freshwater model module. The module level docstring should contain a
short description of the overall model design and purpose, and link to key components
and how they interact.
""" # noqa: D204, D415

from virtual_ecosystem.models.freshwater.freshwater_model import ( # noqa: F401
FreshwaterModel,
)
```

Under the hood, when a given model is used in a simulation, then the configuration
process automatically loads all of the model components for that model using the
{func}`~virtual_ecosystem.core.registry.register_module` function. This automatically
loads and validates the model schema, discovers any
in the `constants`
submodule and then adds those, along with the BaseModel subclass to a central
{data}`~virtual_ecosystem.core.registry.MODULE_REGISTRY` object, which is used to allow
the simulation code to easily access model components.
4 changes: 1 addition & 3 deletions tests/core/test_modules/bad_name/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
"""A test module providing a single model as expected."""

from tests.core.test_modules.bad_name.test_model import ATestModel # noqa: F401
"""A test module providing a single model as expected but misnamed."""
1 change: 1 addition & 0 deletions tests/core/test_modules/no_model/no_model_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""This file contains no BaseModel subclass."""
2 changes: 0 additions & 2 deletions tests/core/test_modules/one_model/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
"""A test module providing a single model as expected."""

from tests.core.test_modules.one_model.test_model import ATestModel # noqa: F401
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from virtual_ecosystem.core.base_model import BaseModel


class ATestModel(
class OneModelModel(
BaseModel,
model_name="one_model",
vars_required_for_init=tuple(),
Expand Down
5 changes: 0 additions & 5 deletions tests/core/test_modules/two_models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1 @@
"""A test module providing a single model as expected."""

from tests.core.test_modules.two_models.test_model import ( # noqa: F401
ATestModel1,
ATestModel2,
)
2 changes: 1 addition & 1 deletion tests/core/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
(
INFO,
"Registering model class for "
"tests.core.test_modules.one_model: ATestModel",
"tests.core.test_modules.one_model: OneModelModel",
),
(
INFO,
Expand Down
104 changes: 59 additions & 45 deletions virtual_ecosystem/core/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@

from dataclasses import dataclass
from importlib import import_module
from inspect import getmembers, isclass
from typing import Any

from virtual_ecosystem.core.configuration import Configuration
from virtual_ecosystem.core.logger import LOGGER
from virtual_ecosystem.core.variables import to_camel_case


@dataclass
Expand Down Expand Up @@ -81,59 +81,20 @@ def register_module(module_name: str) -> None:
Exception: other exceptions can occur when loading the JSON schema fails.
"""

from virtual_ecosystem.core.base_model import BaseModel

# Extract the last component of the module name to act as unique short name
_, _, module_name_short = module_name.rpartition(".")
module_name_short = module_name.rpartition(".")[-1]

if module_name_short in MODULE_REGISTRY:
LOGGER.warning(f"Module already registered: {module_name}")
return

# Try and import the module from the name to get a reference to the module
try:
module = import_module(module_name)
except ModuleNotFoundError as excep:
LOGGER.critical(f"Unknown module - registration failed: {module_name}")
raise excep

is_core = module_name == "virtual_ecosystem.core"

LOGGER.info(f"Registering module: {module_name}")

# Locate _one_ BaseModel class in the module root if this is not the core.
if is_core:
if module_name_short == "core":
is_core = True
model = None
else:
models_found = [
(obj_name, obj)
for obj_name, obj in getmembers(module)
if isclass(obj) and issubclass(obj, BaseModel)
]

# Trap missing and multiple models
if len(models_found) == 0:
msg = f"Model object not found in {module_name}"
LOGGER.critical(msg)
raise RuntimeError(msg)

if len(models_found) > 1:
msg = "More than one model defined in in {module_name}"
LOGGER.critical(msg)
raise RuntimeError(msg)

# Trap models that do not follow the requirement that the BaseModel.model_name
# attribute matches the virtual_ecosystem.models.model_name
# TODO - can we retire the model_name attribute if it just duplicates the module
# name or force it to match programmatically.
_, model = models_found[0]
if module_name_short != model.model_name:
msg = f"Different model_name attribute and module name {module_name}"
LOGGER.critical(msg)
raise RuntimeError(msg)

# Register the resulting single model class
LOGGER.info(f"Registering model class for {module_name}: {model.__name__}")
is_core = False
model = get_model(module_name, module_name_short)

# Find and register the model configuration
model_config_class = get_model_configuration_class(
Expand All @@ -149,6 +110,59 @@ def register_module(module_name: str) -> None:
)


def get_model(module_name: str, module_name_short: str):
"""Get the main model class for a model.

Model classes are discovered by name, following the pattern below:

* ``models.plants`` -> ``models.plants.plants_model.PlantsModel``
* ``models.abiotic_simple`` ->
``models.abiotic_simple.abiotic_simple_model.AbioticSimpleModel``

Args:
module_name: The full module name (e.g. ``virtual_ecosystem.models.plants``)
module_name_short: The short module name (e.g ``plants``)
"""

from virtual_ecosystem.core.base_model import BaseModel

# Try and import the submodule containing the model
model_submodule_name = module_name + f".{module_name_short}_model"
try:
module = import_module(model_submodule_name)
except ModuleNotFoundError as excep:
LOGGER.critical(f"Registration failed, cannot import {model_submodule_name}")
raise excep

# Try and get the model by name
try:
expected_model_name = to_camel_case(module_name_short) + "Model"
model = getattr(module, expected_model_name)
except AttributeError:
raise RuntimeError(
f"The {model_submodule_name} module does "
f"not define the {expected_model_name} class."
)

# Raises a runtime error if the retrieved class is not a Configuration.
if not issubclass(model, BaseModel):
raise RuntimeError(f"Model is not a BaseModel subclass: {expected_model_name}")

# Trap models that do not follow the requirement that the BaseModel.model_name
# attribute matches the virtual_ecosystem.models.model_name
# TODO - can we retire the model_name attribute if it just duplicates the module
# name or force it to match programmatically.
Comment on lines +153 to +154
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably.

Copy link
Copy Markdown
Collaborator Author

@davidorme davidorme Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll park it for now. Again.

if module_name_short != model.model_name:
msg = f"Different model_name attribute and module name {module_name}"
LOGGER.critical(msg)
raise RuntimeError(msg)

# Register the resulting single model class
LOGGER.info(f"Registering model class for {module_name}: {model.__name__}")

return model


def get_model_configuration_class(module_name: str, module_name_short: str):
"""Get the root configuration class for a model.

Expand Down
2 changes: 0 additions & 2 deletions virtual_ecosystem/models/abiotic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,3 @@
fluxes. The model also provides vertical profiles of atmospheric pressure and
:math:`\ce{CO_{2}}`.
""" # noqa: D205

from virtual_ecosystem.models.abiotic.abiotic_model import AbioticModel # noqa: F401
4 changes: 0 additions & 4 deletions virtual_ecosystem/models/abiotic_simple/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,3 @@
abiotic model including the regression parameters for deriving vertical profiles.

""" # noqa: D205

from virtual_ecosystem.models.abiotic_simple.abiotic_simple_model import ( # noqa: F401
AbioticSimpleModel,
)
2 changes: 0 additions & 2 deletions virtual_ecosystem/models/animal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,3 @@
:class:`~virtual_ecosystem.models.animal.plant_resources.PlantResources` class,
which provides an API for exposing plant model data via the animal model protocols.
""" # noqa: D205

from virtual_ecosystem.models.animal.animal_model import AnimalModel # noqa: F401
4 changes: 0 additions & 4 deletions virtual_ecosystem/models/hydrology/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,3 @@
for example by preselecting relevant layers, distributing monthly rainfall over 30
days, and so on.
""" # noqa: D205

from virtual_ecosystem.models.hydrology.hydrology_model import ( # noqa: F401
HydrologyModel,
)
2 changes: 0 additions & 2 deletions virtual_ecosystem/models/litter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,3 @@
* :mod:`~virtual_ecosystem.models.litter.model_config` submodule provides configuration
options for the model along with constants required by the broader litter model.
""" # noqa: D205

from virtual_ecosystem.models.litter.litter_model import LitterModel # noqa: F401
2 changes: 0 additions & 2 deletions virtual_ecosystem/models/plants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,3 @@ class as the main API to initialise and update the plants model.
calculate the complete canopy structure across all cohorts for the plant community
present in a particular grid cell.
""" # noqa: D205

from virtual_ecosystem.models.plants.plants_model import PlantsModel # noqa: F401
2 changes: 0 additions & 2 deletions virtual_ecosystem/models/soil/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,3 @@
configuration options for the model along with constants required by the broader soil
model.
""" # noqa: D205

from virtual_ecosystem.models.soil.soil_model import SoilModel # noqa: F401
2 changes: 0 additions & 2 deletions virtual_ecosystem/models/testing/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
"""A minimal model for testing."""

from virtual_ecosystem.models.testing.testing_model import TestingModel # noqa: F401