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/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 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] diff --git a/src/diffwofost/physical_models/config.py b/src/diffwofost/physical_models/config.py new file mode 100644 index 0000000..3a36c76 --- /dev/null +++ b/src/diffwofost/physical_models/config.py @@ -0,0 +1,129 @@ +from dataclasses import dataclass +from dataclasses import field +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 + + +@dataclass(frozen=True) +class Configuration: + """Class to store model configuration from a PCSE configuration files.""" + + CROP: type[SimulationObject] + 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) + 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 = {} + + path = Path(filename) + if path.is_absolute() or path.is_file(): + model_config_file = path + else: + 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.name}" + 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["description"] = description + + # Loop through the attributes in the configuration file + for key, value in 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..4b6920e --- /dev/null +++ b/src/diffwofost/physical_models/engine.py @@ -0,0 +1,67 @@ +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 = [] + self._saved_summary_output = [] + self._saved_terminal_output = {} + + # 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 5195526..ecd6157 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,42 +11,34 @@ """ import logging -import os 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 Enum from pcse.traitlets import TraitType +from .config import Configuration +from .engine import Engine DTYPE = torch.float64 # Default data type for tensors in this module 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.""" 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: @@ -98,21 +88,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.""" @@ -121,13 +96,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 diff --git a/tests/physical_models/crop/test_leaf_dynamics.py b/tests/physical_models/crop/test_leaf_dynamics.py index 3eab5a5..d1dd78b 100644 --- a/tests/physical_models/crop/test_leaf_dynamics.py +++ b/tests/physical_models/crop/test_leaf_dynamics.py @@ -4,8 +4,8 @@ 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 from diffwofost.physical_models.utils import EngineTestHelper from diffwofost.physical_models.utils import calculate_numerical_grad @@ -16,6 +16,11 @@ # Ignore deprecation warnings from pcse.base.simulationobject pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning:pcse.base.simulationobject") +leaf_dynamics_config = Configuration( + CROP=WOFOST_Leaf_Dynamics, + OUTPUT_VARS=["LAI", "TWLV"], +) + def get_test_diff_leaf_model(): test_data_url = f"{phy_data_folder}/test_leafdynamics_wofost72_01.yaml" @@ -24,12 +29,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), ) @@ -40,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): @@ -59,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() @@ -91,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() @@ -114,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"] ) @@ -148,7 +131,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": @@ -171,7 +153,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() @@ -181,7 +163,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() @@ -222,7 +204,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] @@ -239,7 +220,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() @@ -269,7 +250,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"): @@ -284,7 +264,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() @@ -313,7 +293,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"): @@ -331,7 +310,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() @@ -363,7 +342,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 @@ -379,7 +357,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, ) @@ -394,7 +372,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( @@ -408,7 +385,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, ) @@ -456,13 +433,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 04624fc..6e16cf0 100644 --- a/tests/physical_models/crop/test_phenology.py +++ b/tests/physical_models/crop/test_phenology.py @@ -3,8 +3,8 @@ 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 from diffwofost.physical_models.utils import EngineTestHelper from diffwofost.physical_models.utils import calculate_numerical_grad @@ -15,6 +15,11 @@ # Ignore deprecation warnings from pcse.base.simulationobject pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning:pcse.base.simulationobject") +phenology_config = Configuration( + CROP=DVS_Phenology, + OUTPUT_VARS=["DVR", "DVS", "TSUM", "TSUME", "VERN"], +) + def assert_reference_match(reference, model, expected_precision): assert reference["DAY"] == model["day"] @@ -54,12 +59,11 @@ def get_test_diff_phenology_model(): (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") return DiffPhenologyDynamics( copy.deepcopy(crop_model_params_provider), weather_data_provider, agro_management_inputs, - config_path, + phenology_config, ) @@ -69,13 +73,13 @@ def __init__( crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + config, ): 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 def forward(self, params_dict): # pass new value of parameters to the model @@ -86,7 +90,7 @@ def forward(self, params_dict): self.crop_model_params_provider, self.weather_data_provider, self.agro_management_inputs, - self.config_path, + self.config, ) engine.run_till_terminate() results = engine.get_output() @@ -129,13 +133,12 @@ def test_phenology_with_testengine(self, test_data_url): agro_management_inputs, _, ) = 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, ) engine.run_till_terminate() actual_results = engine.get_output() @@ -146,37 +149,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", [ @@ -223,7 +195,6 @@ def test_phenology_with_one_parameter_vector(self, param): agro_management_inputs, _, ) = 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(): @@ -241,7 +212,7 @@ def test_phenology_with_one_parameter_vector(self, param): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + phenology_config, ) engine.run_till_terminate() _ = engine.get_output() @@ -250,7 +221,7 @@ def test_phenology_with_one_parameter_vector(self, param): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + phenology_config, ) engine.run_till_terminate() actual_results = engine.get_output() @@ -303,7 +274,6 @@ def test_phenology_with_different_parameter_values(self, param, delta): agro_management_inputs, _, ) = 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": @@ -319,7 +289,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, ) engine.run_till_terminate() actual_results = engine.get_output() @@ -364,7 +334,6 @@ def test_phenology_with_multiple_parameter_vectors(self): agro_management_inputs, _, ) = 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": @@ -377,7 +346,7 @@ def test_phenology_with_multiple_parameter_vectors(self): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + phenology_config, ) engine.run_till_terminate() actual_results = engine.get_output() @@ -412,7 +381,6 @@ def test_phenology_with_multiple_parameter_arrays(self): agro_management_inputs, _, ) = 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", @@ -443,7 +411,7 @@ def test_phenology_with_multiple_parameter_arrays(self): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + phenology_config, ) engine.run_till_terminate() actual_results = engine.get_output() @@ -483,7 +451,6 @@ def test_phenology_with_incompatible_parameter_vectors(self): agro_management_inputs, _, ) = 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 @@ -497,7 +464,7 @@ def test_phenology_with_incompatible_parameter_vectors(self): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + phenology_config, ) def test_phenology_with_incompatible_weather_parameter_vectors(self): @@ -525,7 +492,6 @@ def test_phenology_with_incompatible_weather_parameter_vectors(self): agro_management_inputs, _, ) = 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 @@ -538,7 +504,7 @@ def test_phenology_with_incompatible_weather_parameter_vectors(self): crop_model_params_provider, weather_data_provider, agro_management_inputs, - config_path, + phenology_config, ) @pytest.mark.parametrize("test_data_url", wofost72_data_urls) diff --git a/tests/physical_models/crop/test_root_dynamics.py b/tests/physical_models/crop/test_root_dynamics.py index 9e2423e..b262b3a 100644 --- a/tests/physical_models/crop/test_root_dynamics.py +++ b/tests/physical_models/crop/test_root_dynamics.py @@ -4,8 +4,8 @@ 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 from diffwofost.physical_models.utils import EngineTestHelper from diffwofost.physical_models.utils import calculate_numerical_grad @@ -16,6 +16,11 @@ # Ignore deprecation warnings from pcse.base.simulationobject pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning:pcse.base.simulationobject") +root_dynamics_config = Configuration( + CROP=WOFOST_Root_Dynamics, + OUTPUT_VARS=["RD", "TWRT"], +) + def get_test_diff_root_model(): test_data_url = f"{phy_data_folder}/test_rootdynamics_wofost72_01.yaml" @@ -24,12 +29,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), ) @@ -40,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): @@ -59,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() @@ -91,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() @@ -115,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 @@ -147,7 +130,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 @@ -162,7 +144,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() @@ -203,7 +185,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] @@ -220,7 +201,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() @@ -250,7 +231,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"): @@ -266,7 +246,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() @@ -295,7 +275,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"): @@ -309,7 +288,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() @@ -341,7 +320,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 @@ -357,7 +335,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, ) diff --git a/tests/physical_models/test_config.py b/tests/physical_models/test_config.py new file mode 100644 index 0000000..b7efe2d --- /dev/null +++ b/tests/physical_models/test_config.py @@ -0,0 +1,56 @@ +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 +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_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 / "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() + 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 + 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 deleted file mode 100644 index 23b8de6..0000000 --- a/tests/physical_models/test_data/WOFOST_Leaf_Dynamics.conf +++ /dev/null @@ -1,32 +0,0 @@ - -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 fc19795..0000000 --- a/tests/physical_models/test_data/WOFOST_Root_Dynamics.conf +++ /dev/null @@ -1,32 +0,0 @@ - -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 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)