From fde9442ba0f7e137bea519f4a6292a7670d4cacf Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Thu, 4 Dec 2025 14:43:00 +0100 Subject: [PATCH 01/19] remove unused elements --- src/diffwofost/physical_models/utils.py | 26 ------------------------- 1 file changed, 26 deletions(-) diff --git a/src/diffwofost/physical_models/utils.py b/src/diffwofost/physical_models/utils.py index 3d5750c..41ce3ef 100644 --- a/src/diffwofost/physical_models/utils.py +++ b/src/diffwofost/physical_models/utils.py @@ -3,8 +3,6 @@ It contains: - VariableKioskTestHelper: A subclass of the VariableKiosk that can use externally forced states/rates - - ConfigurationLoaderTestHelper: An subclass of ConfigurationLoader that allows to - specify the simbojects to be test dynamically - EngineTestHelper: engine specifically for running the YAML tests. - WeatherDataProviderTestHelper: a weatherdata provides that takes the weather inputs from the YAML file. @@ -13,7 +11,6 @@ """ import logging -import os from collections.abc import Iterable import torch import yaml @@ -34,14 +31,6 @@ logging.disable(logging.CRITICAL) -this_dir = os.path.dirname(__file__) - - -def nothing(*args, **kwargs): - """A function that does nothing.""" - pass - - class VariableKioskTestHelper(VariableKiosk): """Variable Kiosk for testing purposes which allows to use external states.""" @@ -97,21 +86,6 @@ def __contains__(self, key): return key in self.current_externals or dict.__contains__(self, key) -class ConfigurationLoaderTestHelper(ConfigurationLoader): - def __init__(self, YAML_test_inputs, simobject, waterbalance=None): - self.model_config_file = "Test config" - self.description = "Configuration loader for running YAML tests" - self.CROP = simobject - self.SOIL = waterbalance - self.AGROMANAGEMENT = AgroManager - self.OUTPUT_INTERVAL = "daily" - self.OUTPUT_INTERVAL_DAYS = 1 - self.OUTPUT_WEEKDAY = 0 - self.OUTPUT_VARS = list(YAML_test_inputs["Precision"].keys()) - self.SUMMARY_OUTPUT_VARS = [] - self.TERMINAL_OUTPUT_VARS = [] - - class EngineTestHelper(Engine): """An engine which is purely for running the YAML unit tests.""" From af1c2f3fdea238884a4b539a71914578c83580f7 Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Thu, 4 Dec 2025 14:44:06 +0100 Subject: [PATCH 02/19] small unrelated fix --- src/diffwofost/physical_models/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/diffwofost/physical_models/utils.py b/src/diffwofost/physical_models/utils.py index 41ce3ef..6791ed2 100644 --- a/src/diffwofost/physical_models/utils.py +++ b/src/diffwofost/physical_models/utils.py @@ -36,7 +36,7 @@ class VariableKioskTestHelper(VariableKiosk): external_state_list = None - def __init__(self, external_state_list): + def __init__(self, external_state_list=None): super().__init__() self.current_externals = {} if external_state_list is not None: From 98eac2af775c7768431390d6febd13621d180abe Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Thu, 4 Dec 2025 14:45:05 +0100 Subject: [PATCH 03/19] add config and scaffold for new engine --- src/diffwofost/physical_models/config.py | 126 +++++++++++++++++++++++ src/diffwofost/physical_models/engine.py | 66 ++++++++++++ src/diffwofost/physical_models/utils.py | 16 +-- 3 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 src/diffwofost/physical_models/config.py create mode 100644 src/diffwofost/physical_models/engine.py diff --git a/src/diffwofost/physical_models/config.py b/src/diffwofost/physical_models/config.py new file mode 100644 index 0000000..8081569 --- /dev/null +++ b/src/diffwofost/physical_models/config.py @@ -0,0 +1,126 @@ +from dataclasses import dataclass +from dataclasses import field +from pathlib import Path +from typing import Self +import pcse +from pcse.base import AncillaryObject +from pcse.base import SimulationObject + + +@dataclass(frozen=True) +class Configuration: + """Class to store model configuration from a PCSE configuration files.""" + CROP: type[SimulationObject] + AGROMANAGEMENT: type[AncillaryObject] + SOIL: type[SimulationObject] | None = None + OUTPUT_VARS: list = field(default_factory=list) + SUMMARY_OUTPUT_VARS: list = field(default_factory=list) + TERMINAL_OUTPUT_VARS: list = field(default_factory=list) + OUTPUT_INTERVAL: str = "daily" # "daily"|"dekadal"|"monthly" + OUTPUT_INTERVAL_DAYS: int = 1 + OUTPUT_WEEKDAY: int = 0 + model_config_file: str | Path | None = None + description: str | None = None + + @classmethod + def from_pcse_config_file(cls, filename: str | Path) -> Self: + """Load the model configuration from a PCSE configuration file. + + Args: + filename (str | pathlib.Path): Path to the configuraiton file. The path is first + interpreted with respect to the current working directory and, if not found, it will + then be interpreted with respect to the `conf` folder in the PCSE package. + + Returns: + Configuration: Model configuration instance + + Raises: + FileNotFoundError: if the configuraiton file does not exist + RuntimeError: if parsing the configuration file fails + """ + config = dict() + + path = Path(filename) + if path.is_file(): + model_config_file = path + else: + pcse_dir = Path(pcse.__path__) + model_config_file = pcse_dir / "conf" / path + model_config_file = model_config_file.resolve() + + # check that configuration file exists + if not model_config_file.exists(): + msg = f"PCSE model configuration file does not exist: {model_config_file}" + raise FileNotFoundError(msg) + # store for later use + config["model_config_file"] = model_config_file + + # Load file using execfile + try: + loc = {} + bytecode = compile(open(model_config_file).read(), model_config_file, 'exec') + exec(bytecode, {}, loc) + except Exception as e: + msg = f"Failed to load configuration from file {model_config_file}" + raise RuntimeError(msg) from e + + # Add the descriptive header for later use + if "__doc__" in loc: + desc = loc.pop("__doc__") + if len(desc) > 0: + description = desc + if description[-1] != "\n": + description += "\n" + config["descrition"] + + # Loop through the attributes in the configuration file + for key, value in list(loc.items()): + if key.isupper(): + config[key] = value + return cls(**config) + + def update_output_variable_lists( + self, + output_vars: str | list | tuple | set | None = None, + summary_vars: str | list | tuple | set | None = None, + terminal_vars: str | list | tuple | set | None = None, + ): + """Updates the lists of output variables that are defined in the configuration file. + + This is useful because sometimes you want the flexibility to get access to an additional + model variable which is not in the standard list of variables defined in the model + configuration file. The more elegant way is to define your own configuration file, but this + adds some flexibility particularly for use in jupyter notebooks and exploratory analysis. + + Note that there is a different behaviour given the type of the variable provided. List and + string inputs will extend the list of variables, while set/tuple inputs will replace the + current list. + + Args: + output_vars: the variable names to add/replace for the OUTPUT_VARS configuration + variable + summary_vars: the variable names to add/replace for the SUMMARY_OUTPUT_VARS + configuration variable + terminal_vars: the variable names to add/replace for the TERMINAL_OUTPUT_VARS + configuration variable + + Raises: + TypeError: if the type of the input arguments is not recognized + """ + config_varnames = ["OUTPUT_VARS", "SUMMARY_OUTPUT_VARS", "TERMINAL_OUTPUT_VARS"] + for varitems, config_varname in zip([output_vars, summary_vars, terminal_vars], + config_varnames, strict=True): + if varitems is None: + continue + else: + if isinstance(varitems, str): # A string: we extend the current list + getattr(self, config_varname).extend(varitems.split()) + elif isinstance(varitems, list): # a list: we extend the current list + getattr(self, config_varname).extend(varitems) + elif isinstance(varitems, tuple | set): # tuple/set we replace the current list + attr = getattr(self, config_varname) + attr.clear() + attr.extend(list(varitems)) + else: + msg = f"Unrecognized input for `output_vars` to engine(): {output_vars}" + raise TypeError(msg) diff --git a/src/diffwofost/physical_models/engine.py b/src/diffwofost/physical_models/engine.py new file mode 100644 index 0000000..f7d6e40 --- /dev/null +++ b/src/diffwofost/physical_models/engine.py @@ -0,0 +1,66 @@ +from pathlib import Path +from pcse import signals +from pcse.base import BaseEngine +from pcse.base.variablekiosk import VariableKiosk +from pcse.engine import Engine +from pcse.timer import Timer +from pcse.traitlets import Instance +from .config import Configuration + + +class Engine(Engine): + mconf = Instance(Configuration) + def __init__( + self, + parameterprovider, + weatherdataprovider, + agromanagement, + config: str | Path | Configuration, + ): + BaseEngine.__init__(self) + + # If a path is given, load the model configuration from a PCSE config file + if isinstance(config, str | Path): + self.mconf = Configuration.from_pcse_config_file(config) + else: + self.mconf = config + + self.parameterprovider = parameterprovider + + # Variable kiosk for registering and publishing variables + self.kiosk = VariableKiosk() + + # Placeholder for variables to be saved during a model run + self._saved_output = list() + self._saved_summary_output = list() + self._saved_terminal_output = dict() + + # register handlers for starting/finishing the crop simulation, for + # handling output and terminating the system + self._connect_signal(self._on_CROP_START, signal=signals.crop_start) + self._connect_signal(self._on_CROP_FINISH, signal=signals.crop_finish) + self._connect_signal(self._on_OUTPUT, signal=signals.output) + self._connect_signal(self._on_TERMINATE, signal=signals.terminate) + + # Component for agromanagement + self.agromanager = self.mconf.AGROMANAGEMENT(self.kiosk, agromanagement) + start_date = self.agromanager.start_date + end_date = self.agromanager.end_date + + # Timer: starting day, final day and model output + self.timer = Timer(self.kiosk, start_date, end_date, self.mconf) + self.day, _ = self.timer() + + # Driving variables + self.weatherdataprovider = weatherdataprovider + self.drv = self._get_driving_variables(self.day) + + # Component for simulation of soil processes + if self.mconf.SOIL is not None: + self.soil = self.mconf.SOIL(self.day, self.kiosk, parameterprovider) + + # Call AgroManagement module for management actions at initialization + self.agromanager(self.day, self.drv) + + # Calculate initial rates + self.calc_rates(self.day, self.drv) diff --git a/src/diffwofost/physical_models/utils.py b/src/diffwofost/physical_models/utils.py index 6791ed2..1752f40 100644 --- a/src/diffwofost/physical_models/utils.py +++ b/src/diffwofost/physical_models/utils.py @@ -12,20 +12,20 @@ import logging from collections.abc import Iterable +from pathlib import Path import torch import yaml from pcse import signals -from pcse.agromanager import AgroManager -from pcse.base import ConfigurationLoader from pcse.base.parameter_providers import ParameterProvider from pcse.base.variablekiosk import VariableKiosk from pcse.base.weather import WeatherDataContainer from pcse.base.weather import WeatherDataProvider from pcse.engine import BaseEngine -from pcse.engine import Engine from pcse.settings import settings from pcse.timer import Timer from pcse.traitlets import TraitType +from .config import Configuration +from .engine import Engine DTYPE = torch.float64 # Default data type for tensors in this module @@ -94,13 +94,17 @@ def __init__( parameterprovider, weatherdataprovider, agromanagement, - test_config, + config, external_states=None, ): BaseEngine.__init__(self) - # Load the model configuration - self.mconf = ConfigurationLoader(test_config) + # If a path is given, load the model configuration from a PCSE config file + if isinstance(config, str | Path): + self.mconf = Configuration.from_pcse_config_file(config) + else: + self.mconf = config + self.parameterprovider = parameterprovider # Variable kiosk for registering and publishing variables From c80132cca4a3298d70da7a0e38d2897ef8c8b44f Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Thu, 4 Dec 2025 14:45:44 +0100 Subject: [PATCH 04/19] parse model config only once in tests --- tests/physical_models/crop/test_leaf_dynamics.py | 7 +++++-- tests/physical_models/crop/test_root_dynamics.py | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/physical_models/crop/test_leaf_dynamics.py b/tests/physical_models/crop/test_leaf_dynamics.py index 699979a..2cc8607 100644 --- a/tests/physical_models/crop/test_leaf_dynamics.py +++ b/tests/physical_models/crop/test_leaf_dynamics.py @@ -6,6 +6,7 @@ from numpy.testing import assert_array_almost_equal from pcse.engine import Engine from pcse.models import Wofost72_PP +from diffwofost.physical_models.config import Configuration from diffwofost.physical_models.crop.leaf_dynamics import WOFOST_Leaf_Dynamics from diffwofost.physical_models.utils import EngineTestHelper from diffwofost.physical_models.utils import calculate_numerical_grad @@ -16,6 +17,9 @@ # Ignore deprecation warnings from pcse.base.simulationobject pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning:pcse.base.simulationobject") +leaf_dynamics_config = Configuration.from_pcse_config_file( + phy_data_folder / "WOFOST_Leaf_Dynamics.conf" +) def get_test_diff_leaf_model(): test_data_url = f"{phy_data_folder}/test_leafdynamics_wofost72_01.yaml" @@ -24,12 +28,11 @@ def get_test_diff_leaf_model(): (crop_model_params_provider, weather_data_provider, agro_management_inputs, external_states) = ( prepare_engine_input(test_data, crop_model_params) ) - config_path = str(phy_data_folder / "WOFOST_Leaf_Dynamics.conf") return DiffLeafDynamics( copy.deepcopy(crop_model_params_provider), weather_data_provider, agro_management_inputs, - config_path, + leaf_dynamics_config, copy.deepcopy(external_states), ) diff --git a/tests/physical_models/crop/test_root_dynamics.py b/tests/physical_models/crop/test_root_dynamics.py index 63af26b..32c6829 100644 --- a/tests/physical_models/crop/test_root_dynamics.py +++ b/tests/physical_models/crop/test_root_dynamics.py @@ -6,6 +6,7 @@ from numpy.testing import assert_array_almost_equal from pcse.engine import Engine from pcse.models import Wofost72_PP +from diffwofost.physical_models.config import Configuration from diffwofost.physical_models.crop.root_dynamics import WOFOST_Root_Dynamics from diffwofost.physical_models.utils import EngineTestHelper from diffwofost.physical_models.utils import calculate_numerical_grad @@ -16,6 +17,9 @@ # Ignore deprecation warnings from pcse.base.simulationobject pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning:pcse.base.simulationobject") +root_dynamics_config = Configuration.from_pcse_config_file( + phy_data_folder / "WOFOST_Root_Dynamics.conf" +) def get_test_diff_root_model(): test_data_url = f"{phy_data_folder}/test_rootdynamics_wofost72_01.yaml" @@ -24,12 +28,11 @@ def get_test_diff_root_model(): (crop_model_params_provider, weather_data_provider, agro_management_inputs, external_states) = ( prepare_engine_input(test_data, crop_model_params) ) - config_path = str(phy_data_folder / "WOFOST_Root_Dynamics.conf") return DiffRootDynamics( copy.deepcopy(crop_model_params_provider), weather_data_provider, agro_management_inputs, - config_path, + root_dynamics_config, copy.deepcopy(external_states), ) From bb300de5857aeb76ad45103e04a8e0753a2027e7 Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Thu, 4 Dec 2025 14:46:29 +0100 Subject: [PATCH 05/19] lint and format --- src/diffwofost/physical_models/config.py | 10 ++++++---- src/diffwofost/physical_models/engine.py | 1 + src/diffwofost/physical_models/utils.py | 1 + 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/diffwofost/physical_models/config.py b/src/diffwofost/physical_models/config.py index 8081569..45a3f68 100644 --- a/src/diffwofost/physical_models/config.py +++ b/src/diffwofost/physical_models/config.py @@ -10,13 +10,14 @@ @dataclass(frozen=True) class Configuration: """Class to store model configuration from a PCSE configuration files.""" + CROP: type[SimulationObject] AGROMANAGEMENT: type[AncillaryObject] SOIL: type[SimulationObject] | None = None OUTPUT_VARS: list = field(default_factory=list) SUMMARY_OUTPUT_VARS: list = field(default_factory=list) TERMINAL_OUTPUT_VARS: list = field(default_factory=list) - OUTPUT_INTERVAL: str = "daily" # "daily"|"dekadal"|"monthly" + OUTPUT_INTERVAL: str = "daily" # "daily"|"dekadal"|"monthly" OUTPUT_INTERVAL_DAYS: int = 1 OUTPUT_WEEKDAY: int = 0 model_config_file: str | Path | None = None @@ -58,7 +59,7 @@ def from_pcse_config_file(cls, filename: str | Path) -> Self: # Load file using execfile try: loc = {} - bytecode = compile(open(model_config_file).read(), model_config_file, 'exec') + bytecode = compile(open(model_config_file).read(), model_config_file, "exec") exec(bytecode, {}, loc) except Exception as e: msg = f"Failed to load configuration from file {model_config_file}" @@ -108,8 +109,9 @@ def update_output_variable_lists( TypeError: if the type of the input arguments is not recognized """ config_varnames = ["OUTPUT_VARS", "SUMMARY_OUTPUT_VARS", "TERMINAL_OUTPUT_VARS"] - for varitems, config_varname in zip([output_vars, summary_vars, terminal_vars], - config_varnames, strict=True): + for varitems, config_varname in zip( + [output_vars, summary_vars, terminal_vars], config_varnames, strict=True + ): if varitems is None: continue else: diff --git a/src/diffwofost/physical_models/engine.py b/src/diffwofost/physical_models/engine.py index f7d6e40..4530050 100644 --- a/src/diffwofost/physical_models/engine.py +++ b/src/diffwofost/physical_models/engine.py @@ -10,6 +10,7 @@ class Engine(Engine): mconf = Instance(Configuration) + def __init__( self, parameterprovider, diff --git a/src/diffwofost/physical_models/utils.py b/src/diffwofost/physical_models/utils.py index 1752f40..9856b3e 100644 --- a/src/diffwofost/physical_models/utils.py +++ b/src/diffwofost/physical_models/utils.py @@ -31,6 +31,7 @@ logging.disable(logging.CRITICAL) + class VariableKioskTestHelper(VariableKiosk): """Variable Kiosk for testing purposes which allows to use external states.""" From 8b575ac3fbd3ee02edd9d0277234934b3c8d0927 Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Thu, 4 Dec 2025 15:09:22 +0100 Subject: [PATCH 06/19] provide default for agromanagement --- src/diffwofost/physical_models/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/diffwofost/physical_models/config.py b/src/diffwofost/physical_models/config.py index 45a3f68..99ae5d8 100644 --- a/src/diffwofost/physical_models/config.py +++ b/src/diffwofost/physical_models/config.py @@ -3,6 +3,7 @@ from pathlib import Path from typing import Self import pcse +from pcse.agromanager import AgroManager from pcse.base import AncillaryObject from pcse.base import SimulationObject @@ -12,8 +13,8 @@ class Configuration: """Class to store model configuration from a PCSE configuration files.""" CROP: type[SimulationObject] - AGROMANAGEMENT: type[AncillaryObject] SOIL: type[SimulationObject] | None = None + AGROMANAGEMENT: type[AncillaryObject] = AgroManager OUTPUT_VARS: list = field(default_factory=list) SUMMARY_OUTPUT_VARS: list = field(default_factory=list) TERMINAL_OUTPUT_VARS: list = field(default_factory=list) From f87abdc349f73941656a246258d4484296520633 Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Thu, 4 Dec 2025 15:10:33 +0100 Subject: [PATCH 07/19] lint and format tests --- tests/physical_models/crop/test_leaf_dynamics.py | 1 + tests/physical_models/crop/test_root_dynamics.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/physical_models/crop/test_leaf_dynamics.py b/tests/physical_models/crop/test_leaf_dynamics.py index 2cc8607..2a3d993 100644 --- a/tests/physical_models/crop/test_leaf_dynamics.py +++ b/tests/physical_models/crop/test_leaf_dynamics.py @@ -21,6 +21,7 @@ phy_data_folder / "WOFOST_Leaf_Dynamics.conf" ) + def get_test_diff_leaf_model(): test_data_url = f"{phy_data_folder}/test_leafdynamics_wofost72_01.yaml" test_data = get_test_data(test_data_url) diff --git a/tests/physical_models/crop/test_root_dynamics.py b/tests/physical_models/crop/test_root_dynamics.py index 32c6829..a2830b5 100644 --- a/tests/physical_models/crop/test_root_dynamics.py +++ b/tests/physical_models/crop/test_root_dynamics.py @@ -21,6 +21,7 @@ phy_data_folder / "WOFOST_Root_Dynamics.conf" ) + def get_test_diff_root_model(): test_data_url = f"{phy_data_folder}/test_rootdynamics_wofost72_01.yaml" test_data = get_test_data(test_data_url) From e480d7f9e94837e07d030ff81904991a79702e77 Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Tue, 9 Dec 2025 10:35:55 +0100 Subject: [PATCH 08/19] drop py 3.10, add 3.13 --- .github/workflows/build.yml | 2 +- pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3a337ad..23ad619 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: ['ubuntu-latest', 'windows-latest'] - python-version: ['3.10', '3.11', '3.12'] + python-version: ['3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/pyproject.toml b/pyproject.toml index 61ac91a..51301e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,9 +20,9 @@ classifiers = [ "Intended Audience :: Science/Research", "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] dependencies = [ "torch", @@ -33,7 +33,7 @@ keywords = ["wofost", "pytorch", "differentiable", "crop", "optimization"] license = {file = "LICENSE"} name = "diffwofost" readme = {file = "README.md", content-type = "text/markdown"} -requires-python = ">=3.10" +requires-python = ">=3.11" version = "0.2.0" [project.optional-dependencies] From 090496376fa8fbbd32b8532e9daf52864debd3c0 Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Tue, 9 Dec 2025 11:15:45 +0100 Subject: [PATCH 09/19] apply sonarqube review --- src/diffwofost/physical_models/config.py | 6 +++--- src/diffwofost/physical_models/engine.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/diffwofost/physical_models/config.py b/src/diffwofost/physical_models/config.py index 99ae5d8..75b9bf8 100644 --- a/src/diffwofost/physical_models/config.py +++ b/src/diffwofost/physical_models/config.py @@ -40,7 +40,7 @@ def from_pcse_config_file(cls, filename: str | Path) -> Self: FileNotFoundError: if the configuraiton file does not exist RuntimeError: if parsing the configuration file fails """ - config = dict() + config = {} path = Path(filename) if path.is_file(): @@ -73,10 +73,10 @@ def from_pcse_config_file(cls, filename: str | Path) -> Self: description = desc if description[-1] != "\n": description += "\n" - config["descrition"] + config["descrition"] = description # Loop through the attributes in the configuration file - for key, value in list(loc.items()): + for key, value in loc.items(): if key.isupper(): config[key] = value return cls(**config) diff --git a/src/diffwofost/physical_models/engine.py b/src/diffwofost/physical_models/engine.py index 4530050..4b6920e 100644 --- a/src/diffwofost/physical_models/engine.py +++ b/src/diffwofost/physical_models/engine.py @@ -32,9 +32,9 @@ def __init__( self.kiosk = VariableKiosk() # Placeholder for variables to be saved during a model run - self._saved_output = list() - self._saved_summary_output = list() - self._saved_terminal_output = dict() + self._saved_output = [] + self._saved_summary_output = [] + self._saved_terminal_output = {} # register handlers for starting/finishing the crop simulation, for # handling output and terminating the system From a40be7127d6c72d990001be0087ec4d1b5441fa2 Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Fri, 12 Dec 2025 10:24:05 +0100 Subject: [PATCH 10/19] add tests for config module --- tests/physical_models/test_config.py | 39 ++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/physical_models/test_config.py diff --git a/tests/physical_models/test_config.py b/tests/physical_models/test_config.py new file mode 100644 index 0000000..98f2f14 --- /dev/null +++ b/tests/physical_models/test_config.py @@ -0,0 +1,39 @@ +from pcse.agromanager import AgroManager +from pcse.soil.classic_waterbalance import WaterbalancePP +from diffwofost.physical_models.config import Configuration +from diffwofost.physical_models.crop.leaf_dynamics import WOFOST_Leaf_Dynamics +from . import phy_data_folder + + +class TestConfiguration: + def test_basic_config_requires_only_crop_model(self): + config = Configuration(CROP=WOFOST_Leaf_Dynamics) + assert isinstance(config, Configuration) + + def test_config_accept_other_optional_input_args(self): + config = Configuration( + CROP=WOFOST_Leaf_Dynamics, + SOIL=WaterbalancePP, + AGROMANAGEMENT=AgroManager, + OUTPUT_VARS=[], + SUMMARY_OUTPUT_VARS=[], + TERMINAL_OUTPUT_VARS=[], + OUTPUT_INTERVAL="weekly", + OUTPUT_INTERVAL_DAYS=1, + OUTPUT_WEEKDAY=0, + model_config_file=None, + description="this is the description", + ) + assert isinstance(config, Configuration) + + def test_config_can_be_instantiated_from_a_pcse_config_file(self): + config_file_path = phy_data_folder / "WOFOST_Leaf_Dynamics.conf" + config = Configuration.from_pcse_config_file(config_file_path) + assert isinstance(config, Configuration) + assert config.model_config_file == config_file_path.resolve() + + def test_output_variables_can_be_updated(self): + config = Configuration(CROP=WOFOST_Leaf_Dynamics) + assert not config.OUTPUT_VARS + config.update_output_variable_lists(output_vars=["DVS", "LAI"]) + assert config.OUTPUT_VARS == ["DVS", "LAI"] From 20b6789c71e9da5c39ecdbff1f39e8ee8b16c040 Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Fri, 12 Dec 2025 10:57:22 +0100 Subject: [PATCH 11/19] fix typo --- src/diffwofost/physical_models/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/diffwofost/physical_models/config.py b/src/diffwofost/physical_models/config.py index 75b9bf8..ac3610d 100644 --- a/src/diffwofost/physical_models/config.py +++ b/src/diffwofost/physical_models/config.py @@ -73,7 +73,7 @@ def from_pcse_config_file(cls, filename: str | Path) -> Self: description = desc if description[-1] != "\n": description += "\n" - config["descrition"] = description + config["description"] = description # Loop through the attributes in the configuration file for key, value in loc.items(): From 62926dee7e6b526ca8a1418d4f2652346ee19c2c Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Fri, 12 Dec 2025 10:58:12 +0100 Subject: [PATCH 12/19] add testing of description ; improve testing of update vars --- tests/physical_models/test_config.py | 12 +++++++++++- .../test_data/WOFOST_Leaf_Dynamics.conf | 3 +++ .../test_data/WOFOST_Root_Dynamics.conf | 3 +++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/physical_models/test_config.py b/tests/physical_models/test_config.py index 98f2f14..39fc5af 100644 --- a/tests/physical_models/test_config.py +++ b/tests/physical_models/test_config.py @@ -31,9 +31,19 @@ def test_config_can_be_instantiated_from_a_pcse_config_file(self): config = Configuration.from_pcse_config_file(config_file_path) assert isinstance(config, Configuration) assert config.model_config_file == config_file_path.resolve() + assert config.description is not None # Description is parsed from the module docstring def test_output_variables_can_be_updated(self): config = Configuration(CROP=WOFOST_Leaf_Dynamics) assert not config.OUTPUT_VARS - config.update_output_variable_lists(output_vars=["DVS", "LAI"]) + assert not config.SUMMARY_OUTPUT_VARS + assert not config.TERMINAL_OUTPUT_VARS + # Test all accepted data types + config.update_output_variable_lists( + output_vars=["DVS", "LAI"], # list + summary_vars="LAI", # str + terminal_vars={"DVS"}, # set + ) assert config.OUTPUT_VARS == ["DVS", "LAI"] + assert config.SUMMARY_OUTPUT_VARS == ["LAI"] + assert config.TERMINAL_OUTPUT_VARS == ["DVS"] diff --git a/tests/physical_models/test_data/WOFOST_Leaf_Dynamics.conf b/tests/physical_models/test_data/WOFOST_Leaf_Dynamics.conf index 23b8de6..78d6859 100644 --- a/tests/physical_models/test_data/WOFOST_Leaf_Dynamics.conf +++ b/tests/physical_models/test_data/WOFOST_Leaf_Dynamics.conf @@ -1,4 +1,7 @@ +"""PCSE configuration file for the Leaf Dynamics model +This configuration is suitable to run the model in isolation, for testing purposes. +""" from diffwofost.physical_models.crop.leaf_dynamics import WOFOST_Leaf_Dynamics from pcse.agromanager import AgroManager diff --git a/tests/physical_models/test_data/WOFOST_Root_Dynamics.conf b/tests/physical_models/test_data/WOFOST_Root_Dynamics.conf index fc19795..27c849c 100644 --- a/tests/physical_models/test_data/WOFOST_Root_Dynamics.conf +++ b/tests/physical_models/test_data/WOFOST_Root_Dynamics.conf @@ -1,4 +1,7 @@ +"""PCSE configuration file for the Root Dynamics model +This configuration is suitable to run the model in isolation, for testing purposes. +""" from diffwofost.physical_models.crop.root_dynamics import WOFOST_Root_Dynamics from pcse.agromanager import AgroManager From 91d9cb0770a59768796c6a7129b5c93256e4d44d Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Fri, 12 Dec 2025 11:47:22 +0100 Subject: [PATCH 13/19] fix parsing of default pcse config files and add test on it --- src/diffwofost/physical_models/config.py | 6 +++--- tests/physical_models/test_config.py | 9 ++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/diffwofost/physical_models/config.py b/src/diffwofost/physical_models/config.py index ac3610d..3a36c76 100644 --- a/src/diffwofost/physical_models/config.py +++ b/src/diffwofost/physical_models/config.py @@ -43,16 +43,16 @@ def from_pcse_config_file(cls, filename: str | Path) -> Self: config = {} path = Path(filename) - if path.is_file(): + if path.is_absolute() or path.is_file(): model_config_file = path else: - pcse_dir = Path(pcse.__path__) + pcse_dir = Path(pcse.__path__[0]) model_config_file = pcse_dir / "conf" / path model_config_file = model_config_file.resolve() # check that configuration file exists if not model_config_file.exists(): - msg = f"PCSE model configuration file does not exist: {model_config_file}" + msg = f"PCSE model configuration file does not exist: {model_config_file.name}" raise FileNotFoundError(msg) # store for later use config["model_config_file"] = model_config_file diff --git a/tests/physical_models/test_config.py b/tests/physical_models/test_config.py index 39fc5af..acb6e40 100644 --- a/tests/physical_models/test_config.py +++ b/tests/physical_models/test_config.py @@ -1,4 +1,5 @@ from pcse.agromanager import AgroManager +from pcse.crop.phenology import DVS_Phenology from pcse.soil.classic_waterbalance import WaterbalancePP from diffwofost.physical_models.config import Configuration from diffwofost.physical_models.crop.leaf_dynamics import WOFOST_Leaf_Dynamics @@ -26,7 +27,13 @@ def test_config_accept_other_optional_input_args(self): ) assert isinstance(config, Configuration) - def test_config_can_be_instantiated_from_a_pcse_config_file(self): + def test_config_can_be_instantiated_from_a_default_pcse_config_file(self): + config = Configuration.from_pcse_config_file("Wofost72_Pheno.conf") + assert config.SOIL is None + assert config.CROP == DVS_Phenology + assert config.AGROMANAGEMENT == AgroManager + + def test_config_can_be_instantiated_from_a_custom_pcse_config_file(self): config_file_path = phy_data_folder / "WOFOST_Leaf_Dynamics.conf" config = Configuration.from_pcse_config_file(config_file_path) assert isinstance(config, Configuration) From 2671741b6ecf7300150550ae7b8d17bf24c5633d Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Fri, 12 Dec 2025 14:56:39 +0100 Subject: [PATCH 14/19] fix tests so only one config file is loaded per module --- .../crop/test_leaf_dynamics.py | 33 +++++++---------- tests/physical_models/crop/test_phenology.py | 35 ++++++++----------- .../crop/test_root_dynamics.py | 24 +++++-------- 3 files changed, 36 insertions(+), 56 deletions(-) diff --git a/tests/physical_models/crop/test_leaf_dynamics.py b/tests/physical_models/crop/test_leaf_dynamics.py index 27dec8f..e2cc192 100644 --- a/tests/physical_models/crop/test_leaf_dynamics.py +++ b/tests/physical_models/crop/test_leaf_dynamics.py @@ -44,14 +44,14 @@ def __init__( crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + config, external_states, ): super().__init__() self.crop_model_params_provider = crop_model_params_provider self.weather_data_provider = weather_data_provider self.agro_management_inputs = agro_management_inputs - self.config_path = config_path + self.config = config self.external_states = external_states def forward(self, params_dict): @@ -63,7 +63,7 @@ def forward(self, params_dict): self.crop_model_params_provider, self.weather_data_provider, self.agro_management_inputs, - self.config_path, + self.config, self.external_states, ) engine.run_till_terminate() @@ -95,13 +95,12 @@ def test_leaf_dynamics_with_testengine(self, test_data_url): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params) - config_path = str(phy_data_folder / "WOFOST_Leaf_Dynamics.conf") engine = EngineTestHelper( crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + leaf_dynamics_config, external_states, ) engine.run_till_terminate() @@ -152,7 +151,6 @@ def test_leaf_dynamics_with_one_parameter_vector(self, param): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params, meteo_range_checks=False) - config_path = str(phy_data_folder / "WOFOST_Leaf_Dynamics.conf") # Setting a vector (with one value) for the selected parameter if param == "TEMP": @@ -175,7 +173,7 @@ def test_leaf_dynamics_with_one_parameter_vector(self, param): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + leaf_dynamics_config, external_states, ) engine.run_till_terminate() @@ -185,7 +183,7 @@ def test_leaf_dynamics_with_one_parameter_vector(self, param): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + leaf_dynamics_config, external_states, ) engine.run_till_terminate() @@ -226,7 +224,6 @@ def test_leaf_dynamics_with_different_parameter_values(self, param, delta): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params) - config_path = str(phy_data_folder / "WOFOST_Leaf_Dynamics.conf") # Setting a vector with multiple values for the selected parameter test_value = crop_model_params_provider[param] @@ -243,7 +240,7 @@ def test_leaf_dynamics_with_different_parameter_values(self, param, delta): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + leaf_dynamics_config, external_states, ) engine.run_till_terminate() @@ -273,7 +270,6 @@ def test_leaf_dynamics_with_multiple_parameter_vectors(self): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params) - config_path = str(phy_data_folder / "WOFOST_Leaf_Dynamics.conf") # Setting a vector (with one value) for the TDWI and SPAN parameters for param in ("TDWI", "SPAN", "RGRLAI", "TBASE", "PERDL", "KDIFTB", "SLATB"): @@ -288,7 +284,7 @@ def test_leaf_dynamics_with_multiple_parameter_vectors(self): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + leaf_dynamics_config, external_states, ) engine.run_till_terminate() @@ -317,7 +313,6 @@ def test_leaf_dynamics_with_multiple_parameter_arrays(self): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params, meteo_range_checks=False) - config_path = str(phy_data_folder / "WOFOST_Leaf_Dynamics.conf") # Setting an array with arbitrary shape (and one value) for param in ("RGRLAI", "TBASE", "PERDL", "KDIFTB", "SLATB"): @@ -335,7 +330,7 @@ def test_leaf_dynamics_with_multiple_parameter_arrays(self): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + leaf_dynamics_config, external_states, ) engine.run_till_terminate() @@ -367,7 +362,6 @@ def test_leaf_dynamics_with_incompatible_parameter_vectors(self): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params) - config_path = str(phy_data_folder / "WOFOST_Leaf_Dynamics.conf") # Setting a vector (with one value) for the TDWI and SPAN parameters, # but with different lengths @@ -383,7 +377,7 @@ def test_leaf_dynamics_with_incompatible_parameter_vectors(self): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + leaf_dynamics_config, external_states, ) @@ -398,7 +392,6 @@ def test_leaf_dynamics_with_incompatible_weather_parameter_vectors(self): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params, meteo_range_checks=False) - config_path = str(phy_data_folder / "WOFOST_Leaf_Dynamics.conf") # Setting vectors with incompatible shapes: TDWI and TEMP crop_model_params_provider.set_override( @@ -412,7 +405,7 @@ def test_leaf_dynamics_with_incompatible_weather_parameter_vectors(self): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + leaf_dynamics_config, external_states, ) @@ -460,13 +453,11 @@ def test_leaf_dynamics_with_sigmoid_approx(self, test_data_url): # Make SPAN a parameter requiring gradients crop_model_params_provider["SPAN"].requires_grad = True - config_path = str(phy_data_folder / "WOFOST_Leaf_Dynamics.conf") - engine = EngineTestHelper( crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + leaf_dynamics_config, external_states, ) engine.run_till_terminate() diff --git a/tests/physical_models/crop/test_phenology.py b/tests/physical_models/crop/test_phenology.py index ccdbb02..9ad85ae 100644 --- a/tests/physical_models/crop/test_phenology.py +++ b/tests/physical_models/crop/test_phenology.py @@ -5,6 +5,7 @@ import torch from pcse.engine import Engine from pcse.models import Wofost72_PP +from diffwofost.physical_models.config import Configuration from diffwofost.physical_models.crop.phenology import DVS_Phenology from diffwofost.physical_models.utils import EngineTestHelper from diffwofost.physical_models.utils import calculate_numerical_grad @@ -15,6 +16,8 @@ # Ignore deprecation warnings from pcse.base.simulationobject pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning:pcse.base.simulationobject") +phenology_config = Configuration.from_pcse_config_file(phy_data_folder / "WOFOST_Phenology.conf") + def assert_reference_match(reference, model, expected_precision): assert reference["DAY"] == model["day"] @@ -54,12 +57,11 @@ def get_test_diff_phenology_model(): (crop_model_params_provider, weather_data_provider, agro_management_inputs, external_states) = ( prepare_engine_input(test_data, crop_model_params) ) - config_path = str(phy_data_folder / "WOFOST_Phenology.conf") return DiffPhenologyDynamics( copy.deepcopy(crop_model_params_provider), weather_data_provider, agro_management_inputs, - config_path, + phenology_config, copy.deepcopy(external_states), ) @@ -70,14 +72,14 @@ def __init__( crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + config, external_states, ): super().__init__() self.crop_model_params_provider = crop_model_params_provider self.weather_data_provider = weather_data_provider self.agro_management_inputs = agro_management_inputs - self.config_path = config_path + self.config = config self.external_states = external_states def forward(self, params_dict): @@ -89,7 +91,7 @@ def forward(self, params_dict): self.crop_model_params_provider, self.weather_data_provider, self.agro_management_inputs, - self.config_path, + self.config, self.external_states, ) engine.run_till_terminate() @@ -134,13 +136,12 @@ def test_phenology_with_testengine(self, test_data_url): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params) - config_path = str(phy_data_folder / "WOFOST_Phenology.conf") engine = EngineTestHelper( crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + phenology_config, external_states, ) engine.run_till_terminate() @@ -229,7 +230,6 @@ def test_phenology_with_one_parameter_vector(self, param): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params, meteo_range_checks=False) - config_path = str(phy_data_folder / "WOFOST_Phenology.conf") if param == "TEMP": for (_, _), wdc in weather_data_provider.store.items(): @@ -247,7 +247,7 @@ def test_phenology_with_one_parameter_vector(self, param): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + phenology_config, external_states, ) engine.run_till_terminate() @@ -257,7 +257,7 @@ def test_phenology_with_one_parameter_vector(self, param): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + phenology_config, external_states, ) engine.run_till_terminate() @@ -311,7 +311,6 @@ def test_phenology_with_different_parameter_values(self, param, delta): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params) - config_path = str(phy_data_folder / "WOFOST_Phenology.conf") test_value = crop_model_params_provider[param] if param == "DTSMTB": @@ -327,7 +326,7 @@ def test_phenology_with_different_parameter_values(self, param, delta): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + phenology_config, external_states, ) engine.run_till_terminate() @@ -373,7 +372,6 @@ def test_phenology_with_multiple_parameter_vectors(self): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params) - config_path = str(phy_data_folder / "WOFOST_Phenology.conf") for param in crop_model_params: if param == "DTSMTB": @@ -386,7 +384,7 @@ def test_phenology_with_multiple_parameter_vectors(self): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + phenology_config, external_states, ) engine.run_till_terminate() @@ -422,7 +420,6 @@ def test_phenology_with_multiple_parameter_arrays(self): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params, meteo_range_checks=False) - config_path = str(phy_data_folder / "WOFOST_Phenology.conf") for param in ( "TSUMEM", @@ -453,7 +450,7 @@ def test_phenology_with_multiple_parameter_arrays(self): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + phenology_config, external_states, ) engine.run_till_terminate() @@ -494,7 +491,6 @@ def test_phenology_with_incompatible_parameter_vectors(self): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params) - config_path = str(phy_data_folder / "WOFOST_Phenology.conf") crop_model_params_provider.set_override( "TSUM1", crop_model_params_provider["TSUM1"].repeat(10), check=False @@ -508,7 +504,7 @@ def test_phenology_with_incompatible_parameter_vectors(self): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + phenology_config, external_states, ) @@ -537,7 +533,6 @@ def test_phenology_with_incompatible_weather_parameter_vectors(self): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params, meteo_range_checks=False) - config_path = str(phy_data_folder / "WOFOST_Phenology.conf") crop_model_params_provider.set_override( "TSUM1", crop_model_params_provider["TSUM1"].repeat(10), check=False @@ -550,7 +545,7 @@ def test_phenology_with_incompatible_weather_parameter_vectors(self): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + phenology_config, external_states, ) diff --git a/tests/physical_models/crop/test_root_dynamics.py b/tests/physical_models/crop/test_root_dynamics.py index c547a97..3c36f8f 100644 --- a/tests/physical_models/crop/test_root_dynamics.py +++ b/tests/physical_models/crop/test_root_dynamics.py @@ -44,14 +44,14 @@ def __init__( crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + config, external_states, ): super().__init__() self.crop_model_params_provider = crop_model_params_provider self.weather_data_provider = weather_data_provider self.agro_management_inputs = agro_management_inputs - self.config_path = config_path + self.config = config self.external_states = external_states def forward(self, params_dict): @@ -63,7 +63,7 @@ def forward(self, params_dict): self.crop_model_params_provider, self.weather_data_provider, self.agro_management_inputs, - self.config_path, + self.config, self.external_states, ) engine.run_till_terminate() @@ -95,13 +95,12 @@ def test_root_dynamics_with_testengine(self, test_data_url): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params) - config_path = str(phy_data_folder / "WOFOST_Root_Dynamics.conf") engine = EngineTestHelper( crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + root_dynamics_config, external_states, ) engine.run_till_terminate() @@ -151,7 +150,6 @@ def test_root_dynamics_with_one_parameter_vector(self, param): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params) - config_path = str(phy_data_folder / "WOFOST_Root_Dynamics.conf") # Setting a vector (with one value) for the selected parameter # If the parameter is an Afgen table (like RDRRTB), the repeat will create a @@ -166,7 +164,7 @@ def test_root_dynamics_with_one_parameter_vector(self, param): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + root_dynamics_config, external_states, ) engine.run_till_terminate() @@ -207,7 +205,6 @@ def test_root_dynamics_with_different_parameter_values(self, param, delta): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params) - config_path = str(phy_data_folder / "WOFOST_Root_Dynamics.conf") # Setting a vector with multiple values for the selected parameter test_value = crop_model_params_provider[param] @@ -224,7 +221,7 @@ def test_root_dynamics_with_different_parameter_values(self, param, delta): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + root_dynamics_config, external_states, ) engine.run_till_terminate() @@ -254,7 +251,6 @@ def test_root_dynamics_with_multiple_parameter_vectors(self): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params) - config_path = str(phy_data_folder / "WOFOST_Root_Dynamics.conf") # Setting a vector (with one value) for the RDI and RRI parameters for param in ("RDI", "RRI", "RDMCR", "RDMSOL", "TDWI", "IAIRDU", "RDRRTB"): @@ -270,7 +266,7 @@ def test_root_dynamics_with_multiple_parameter_vectors(self): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + root_dynamics_config, external_states, ) engine.run_till_terminate() @@ -299,7 +295,6 @@ def test_root_dynamics_with_multiple_parameter_arrays(self): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params) - config_path = str(phy_data_folder / "WOFOST_Root_Dynamics.conf") # Setting an array with arbitrary shape (and one value) for param in ("RDI", "RRI", "RDMCR", "RDMSOL", "TDWI", "IAIRDU", "RDRRTB"): @@ -313,7 +308,7 @@ def test_root_dynamics_with_multiple_parameter_arrays(self): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + root_dynamics_config, external_states, ) engine.run_till_terminate() @@ -345,7 +340,6 @@ def test_root_dynamics_with_incompatible_parameter_vectors(self): agro_management_inputs, external_states, ) = prepare_engine_input(test_data, crop_model_params) - config_path = str(phy_data_folder / "WOFOST_Root_Dynamics.conf") # Setting a vector (with one value) for the RDI and RRI parameters, # but with different lengths @@ -361,7 +355,7 @@ def test_root_dynamics_with_incompatible_parameter_vectors(self): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + root_dynamics_config, external_states, ) From af6bb77c2709d72bd785cb70e4ea6934266b68ed Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Fri, 12 Dec 2025 14:57:08 +0100 Subject: [PATCH 15/19] add test on engine --- tests/physical_models/test_engine.py | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/physical_models/test_engine.py diff --git a/tests/physical_models/test_engine.py b/tests/physical_models/test_engine.py new file mode 100644 index 0000000..680f860 --- /dev/null +++ b/tests/physical_models/test_engine.py @@ -0,0 +1,36 @@ +from diffwofost.physical_models.engine import Engine +from diffwofost.physical_models.utils import get_test_data +from diffwofost.physical_models.utils import prepare_engine_input +from . import phy_data_folder + + +class TestEngine: + def test_engine_can_be_instantiated_from_default_pcse_config(self): + test_data_url = f"{phy_data_folder}/test_phenology_wofost72_01.yaml" + test_data = get_test_data(test_data_url) + crop_model_params = [ + "TSUMEM", + "TBASEM", + "TEFFMX", + "TSUM1", + "TSUM2", + "IDSL", + "DLO", + "DLC", + "DVSI", + "DVSEND", + "DTSMTB", + "VERNSAT", + "VERNBASE", + "VERNDVS", + ] + (crop_model_params_provider, weather_data_provider, agro_management_inputs, _) = ( + prepare_engine_input(test_data, crop_model_params) + ) + engine = Engine( + parameterprovider=crop_model_params_provider, + weatherdataprovider=weather_data_provider, + agromanagement=agro_management_inputs, + config="Wofost72_Pheno.conf", + ) + assert isinstance(engine, Engine) From e50c115c9a8966d174dcb3e5e9947863ee63831d Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Mon, 15 Dec 2025 10:27:25 +0100 Subject: [PATCH 16/19] define config in memory for tests --- tests/physical_models/crop/test_leaf_dynamics.py | 5 +++-- tests/physical_models/crop/test_phenology.py | 5 ++++- tests/physical_models/crop/test_root_dynamics.py | 5 +++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/physical_models/crop/test_leaf_dynamics.py b/tests/physical_models/crop/test_leaf_dynamics.py index e2cc192..4fdc69c 100644 --- a/tests/physical_models/crop/test_leaf_dynamics.py +++ b/tests/physical_models/crop/test_leaf_dynamics.py @@ -17,8 +17,9 @@ # Ignore deprecation warnings from pcse.base.simulationobject pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning:pcse.base.simulationobject") -leaf_dynamics_config = Configuration.from_pcse_config_file( - phy_data_folder / "WOFOST_Leaf_Dynamics.conf" +leaf_dynamics_config = Configuration( + CROP=WOFOST_Leaf_Dynamics, + OUTPUT_VARS=["LAI", "TWLV"], ) diff --git a/tests/physical_models/crop/test_phenology.py b/tests/physical_models/crop/test_phenology.py index 9ad85ae..ceba520 100644 --- a/tests/physical_models/crop/test_phenology.py +++ b/tests/physical_models/crop/test_phenology.py @@ -16,7 +16,10 @@ # Ignore deprecation warnings from pcse.base.simulationobject pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning:pcse.base.simulationobject") -phenology_config = Configuration.from_pcse_config_file(phy_data_folder / "WOFOST_Phenology.conf") +phenology_config = Configuration( + CROP=DVS_Phenology, + OUTPUT_VARS=["DVR", "DVS", "TSUM", "TSUME", "VERN"], +) def assert_reference_match(reference, model, expected_precision): diff --git a/tests/physical_models/crop/test_root_dynamics.py b/tests/physical_models/crop/test_root_dynamics.py index 3c36f8f..6cee94b 100644 --- a/tests/physical_models/crop/test_root_dynamics.py +++ b/tests/physical_models/crop/test_root_dynamics.py @@ -17,8 +17,9 @@ # Ignore deprecation warnings from pcse.base.simulationobject pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning:pcse.base.simulationobject") -root_dynamics_config = Configuration.from_pcse_config_file( - phy_data_folder / "WOFOST_Root_Dynamics.conf" +root_dynamics_config = Configuration( + CROP=WOFOST_Root_Dynamics, + OUTPUT_VARS=["RD", "TWRT"], ) From aef7d03ea8ed27d7717d0bc0597e7189c0cd372c Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Mon, 15 Dec 2025 10:28:08 +0100 Subject: [PATCH 17/19] add API reference entries to docs --- docs/api_reference.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/api_reference.md b/docs/api_reference.md index b7256e4..6ee0077 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -14,4 +14,8 @@ hide: ## **Utility (under development)** +::: diffwofost.physical_models.config.Configuration + +::: diffwofost.physical_models.engine.Engine + ::: diffwofost.physical_models.utils.EngineTestHelper From 6f24b2f5db21ebea23c1927cdbd4e8c1108d7abc Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Mon, 15 Dec 2025 10:49:21 +0100 Subject: [PATCH 18/19] remove all config files but one --- .../test_data/WOFOST_Leaf_Dynamics.conf | 35 ------------------- .../test_data/WOFOST_Root_Dynamics.conf | 35 ------------------- ...henology.conf => Wofost72_Pheno_test.conf} | 3 ++ 3 files changed, 3 insertions(+), 70 deletions(-) delete mode 100644 tests/physical_models/test_data/WOFOST_Leaf_Dynamics.conf delete mode 100644 tests/physical_models/test_data/WOFOST_Root_Dynamics.conf rename tests/physical_models/test_data/{WOFOST_Phenology.conf => Wofost72_Pheno_test.conf} (89%) diff --git a/tests/physical_models/test_data/WOFOST_Leaf_Dynamics.conf b/tests/physical_models/test_data/WOFOST_Leaf_Dynamics.conf deleted file mode 100644 index 78d6859..0000000 --- a/tests/physical_models/test_data/WOFOST_Leaf_Dynamics.conf +++ /dev/null @@ -1,35 +0,0 @@ -"""PCSE configuration file for the Leaf Dynamics model - -This configuration is suitable to run the model in isolation, for testing purposes. -""" -from diffwofost.physical_models.crop.leaf_dynamics import WOFOST_Leaf_Dynamics -from pcse.agromanager import AgroManager - -# Module to be used for water balance -SOIL = None - -# Module to be used for the crop simulation itself -CROP = WOFOST_Leaf_Dynamics - -# Module to use for AgroManagement actions -AGROMANAGEMENT = AgroManager - -# variables to save at OUTPUT signals -# Set to an empty list if you do not want any OUTPUT -OUTPUT_VARS = ["LAI", "TWLV"] -# interval for OUTPUT signals, either "daily"|"dekadal"|"monthly"|"weekly" -# For daily output you change the number of days between successive -# outputs using OUTPUT_INTERVAL_DAYS. For dekadal and monthly -# output this is ignored. -OUTPUT_INTERVAL = "daily" -OUTPUT_INTERVAL_DAYS = 1 -# Weekday: Monday is 0 and Sunday is 6 -OUTPUT_WEEKDAY = 0 - -# Summary variables to save at CROP_FINISH signals -# Set to an empty list if you do not want any SUMMARY_OUTPUT -SUMMARY_OUTPUT_VARS = [] - -# Summary variables to save at TERMINATE signals -# Set to an empty list if you do not want any TERMINAL_OUTPUT -TERMINAL_OUTPUT_VARS = [] diff --git a/tests/physical_models/test_data/WOFOST_Root_Dynamics.conf b/tests/physical_models/test_data/WOFOST_Root_Dynamics.conf deleted file mode 100644 index 27c849c..0000000 --- a/tests/physical_models/test_data/WOFOST_Root_Dynamics.conf +++ /dev/null @@ -1,35 +0,0 @@ -"""PCSE configuration file for the Root Dynamics model - -This configuration is suitable to run the model in isolation, for testing purposes. -""" -from diffwofost.physical_models.crop.root_dynamics import WOFOST_Root_Dynamics -from pcse.agromanager import AgroManager - -# Module to be used for water balance -SOIL = None - -# Module to be used for the crop simulation itself -CROP = WOFOST_Root_Dynamics - -# Module to use for AgroManagement actions -AGROMANAGEMENT = AgroManager - -# variables to save at OUTPUT signals -# Set to an empty list if you do not want any OUTPUT -OUTPUT_VARS = ["RD", "TWRT"] -# interval for OUTPUT signals, either "daily"|"dekadal"|"monthly"|"weekly" -# For daily output you change the number of days between successive -# outputs using OUTPUT_INTERVAL_DAYS. For dekadal and monthly -# output this is ignored. -OUTPUT_INTERVAL = "daily" -OUTPUT_INTERVAL_DAYS = 1 -# Weekday: Monday is 0 and Sunday is 6 -OUTPUT_WEEKDAY = 0 - -# Summary variables to save at CROP_FINISH signals -# Set to an empty list if you do not want any SUMMARY_OUTPUT -SUMMARY_OUTPUT_VARS = [] - -# Summary variables to save at TERMINATE signals -# Set to an empty list if you do not want any TERMINAL_OUTPUT -TERMINAL_OUTPUT_VARS = [] diff --git a/tests/physical_models/test_data/WOFOST_Phenology.conf b/tests/physical_models/test_data/Wofost72_Pheno_test.conf similarity index 89% rename from tests/physical_models/test_data/WOFOST_Phenology.conf rename to tests/physical_models/test_data/Wofost72_Pheno_test.conf index c47fead..8156e52 100644 --- a/tests/physical_models/test_data/WOFOST_Phenology.conf +++ b/tests/physical_models/test_data/Wofost72_Pheno_test.conf @@ -1,4 +1,7 @@ +"""PCSE configuration file for the Phenology model. +This configuration is suitable to run the Phenology model in isolation. +""" from diffwofost.physical_models.crop.phenology import DVS_Phenology from pcse.agromanager import AgroManager From b88f596682928d0f613ddbebf771ba47088b3aae Mon Sep 17 00:00:00 2001 From: Francesco Nattino Date: Mon, 15 Dec 2025 10:50:02 +0100 Subject: [PATCH 19/19] update tests --- .../crop/test_leaf_dynamics.py | 21 ------------ tests/physical_models/crop/test_phenology.py | 32 ------------------- .../crop/test_root_dynamics.py | 21 ------------ tests/physical_models/test_config.py | 2 +- 4 files changed, 1 insertion(+), 75 deletions(-) diff --git a/tests/physical_models/crop/test_leaf_dynamics.py b/tests/physical_models/crop/test_leaf_dynamics.py index 4fdc69c..d1dd78b 100644 --- a/tests/physical_models/crop/test_leaf_dynamics.py +++ b/tests/physical_models/crop/test_leaf_dynamics.py @@ -4,7 +4,6 @@ import pytest import torch from numpy.testing import assert_array_almost_equal -from pcse.engine import Engine from pcse.models import Wofost72_PP from diffwofost.physical_models.config import Configuration from diffwofost.physical_models.crop.leaf_dynamics import WOFOST_Leaf_Dynamics @@ -118,26 +117,6 @@ def test_leaf_dynamics_with_testengine(self, test_data_url): for var, precision in expected_precision.items() ) - def test_leaf_dynamics_with_engine(self): - # prepare model input - test_data_url = f"{phy_data_folder}/test_leafdynamics_wofost72_01.yaml" - test_data = get_test_data(test_data_url) - crop_model_params = ["SPAN", "TDWI", "TBASE", "PERDL", "RGRLAI"] - (crop_model_params_provider, weather_data_provider, agro_management_inputs, _) = ( - prepare_engine_input(test_data, crop_model_params) - ) - - config_path = str(phy_data_folder / "WOFOST_Leaf_Dynamics.conf") - - # Engine does not allows to specify `external_states` - with pytest.raises(ValueError): - Engine( - crop_model_params_provider, - weather_data_provider, - agro_management_inputs, - config_path, - ) - @pytest.mark.parametrize( "param", ["TDWI", "SPAN", "RGRLAI", "TBASE", "PERDL", "KDIFTB", "SLATB", "TEMP"] ) diff --git a/tests/physical_models/crop/test_phenology.py b/tests/physical_models/crop/test_phenology.py index ceba520..85e616c 100644 --- a/tests/physical_models/crop/test_phenology.py +++ b/tests/physical_models/crop/test_phenology.py @@ -3,7 +3,6 @@ from unittest.mock import patch import pytest import torch -from pcse.engine import Engine from pcse.models import Wofost72_PP from diffwofost.physical_models.config import Configuration from diffwofost.physical_models.crop.phenology import DVS_Phenology @@ -156,37 +155,6 @@ def test_phenology_with_testengine(self, test_data_url): for reference, model in zip(expected_results, actual_results, strict=False): assert_reference_match(reference, model, expected_precision) - def test_phenology_with_engine(self): - test_data_url = f"{phy_data_folder}/test_phenology_wofost72_01.yaml" - test_data = get_test_data(test_data_url) - crop_model_params = [ - "TSUMEM", - "TBASEM", - "TEFFMX", - "TSUM1", - "TSUM2", - "IDSL", - "DLO", - "DLC", - "DVSI", - "DVSEND", - "DTSMTB", - "VERNSAT", - "VERNBASE", - "VERNDVS", - ] - (crop_model_params_provider, weather_data_provider, agro_management_inputs, _) = ( - prepare_engine_input(test_data, crop_model_params) - ) - config_path = str(phy_data_folder / "WOFOST_Phenology.conf") - - Engine( - crop_model_params_provider, - weather_data_provider, - agro_management_inputs, - config_path, - ) - @pytest.mark.parametrize( "param", [ diff --git a/tests/physical_models/crop/test_root_dynamics.py b/tests/physical_models/crop/test_root_dynamics.py index 6cee94b..b262b3a 100644 --- a/tests/physical_models/crop/test_root_dynamics.py +++ b/tests/physical_models/crop/test_root_dynamics.py @@ -4,7 +4,6 @@ import pytest import torch from numpy.testing import assert_array_almost_equal -from pcse.engine import Engine from pcse.models import Wofost72_PP from diffwofost.physical_models.config import Configuration from diffwofost.physical_models.crop.root_dynamics import WOFOST_Root_Dynamics @@ -119,26 +118,6 @@ def test_root_dynamics_with_testengine(self, test_data_url): for var, precision in expected_precision.items() ) - def test_root_dynamics_with_engine(self): - # prepare model input - test_data_url = f"{phy_data_folder}/test_rootdynamics_wofost72_01.yaml" - test_data = get_test_data(test_data_url) - crop_model_params = ["RDI", "RRI", "RDMCR", "RDMSOL", "TDWI", "IAIRDU", "RDRRTB"] - (crop_model_params_provider, weather_data_provider, agro_management_inputs, _) = ( - prepare_engine_input(test_data, crop_model_params) - ) - - config_path = str(phy_data_folder / "WOFOST_Root_Dynamics.conf") - - # Engine does not allows to specify `external_states` - with pytest.raises(KeyError): - Engine( - crop_model_params_provider, - weather_data_provider, - agro_management_inputs, - config_path, - ) - @pytest.mark.parametrize("param", ["RDI", "RRI", "RDMCR", "RDMSOL", "TDWI", "IAIRDU", "RDRRTB"]) def test_root_dynamics_with_one_parameter_vector(self, param): # prepare model input diff --git a/tests/physical_models/test_config.py b/tests/physical_models/test_config.py index acb6e40..b7efe2d 100644 --- a/tests/physical_models/test_config.py +++ b/tests/physical_models/test_config.py @@ -34,7 +34,7 @@ def test_config_can_be_instantiated_from_a_default_pcse_config_file(self): assert config.AGROMANAGEMENT == AgroManager def test_config_can_be_instantiated_from_a_custom_pcse_config_file(self): - config_file_path = phy_data_folder / "WOFOST_Leaf_Dynamics.conf" + config_file_path = phy_data_folder / "Wofost72_Pheno_test.conf" config = Configuration.from_pcse_config_file(config_file_path) assert isinstance(config, Configuration) assert config.model_config_file == config_file_path.resolve()