diff --git a/docs/source/api/abiotic_simple/simple_regression.md b/docs/source/api/abiotic_simple/microclimate.md similarity index 81% rename from docs/source/api/abiotic_simple/simple_regression.md rename to docs/source/api/abiotic_simple/microclimate.md index f820fbbe4..f69d3b7a7 100644 --- a/docs/source/api/abiotic_simple/simple_regression.md +++ b/docs/source/api/abiotic_simple/microclimate.md @@ -14,10 +14,10 @@ kernelspec: name: vr_python3 --- -#  API for the {mod}`~virtual_rainforest.models.abiotic_simple.simple_regression` module +#  API for the {mod}`~virtual_rainforest.models.abiotic_simple.microclimate` module ```{eval-rst} -.. automodule:: virtual_rainforest.models.abiotic_simple.simple_regression +.. automodule:: virtual_rainforest.models.abiotic_simple.microclimate :autosummary: :members: :special-members: __init__ diff --git a/docs/source/api/hydrology.md b/docs/source/api/hydrology.md new file mode 100644 index 000000000..6262842d7 --- /dev/null +++ b/docs/source/api/hydrology.md @@ -0,0 +1,21 @@ +--- +jupytext: + cell_metadata_filter: -all + formats: md:myst + main_language: python + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.13.8 +kernelspec: + display_name: vr_python3 + language: python + name: vr_python3 +--- + +# API reference for `hydrology` modules + +```{eval-rst} +.. automodule:: virtual_rainforest.models.hydrology +``` diff --git a/docs/source/api/hydrology/hydrology_constants.md b/docs/source/api/hydrology/hydrology_constants.md new file mode 100644 index 000000000..979724580 --- /dev/null +++ b/docs/source/api/hydrology/hydrology_constants.md @@ -0,0 +1,24 @@ +--- +jupytext: + cell_metadata_filter: -all + formats: md:myst + main_language: python + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.13.8 +kernelspec: + display_name: vr_python3 + language: python + name: vr_python3 +--- + +#  API for the {mod}`~virtual_rainforest.models.hydrology.hydrology_constants` module + +```{eval-rst} +.. automodule:: virtual_rainforest.models.hydrology.hydrology_constants + :autosummary: + :members: + :special-members: __init__ +``` diff --git a/docs/source/api/hydrology/hydrology_model.md b/docs/source/api/hydrology/hydrology_model.md new file mode 100644 index 000000000..092fed006 --- /dev/null +++ b/docs/source/api/hydrology/hydrology_model.md @@ -0,0 +1,25 @@ +--- +jupytext: + cell_metadata_filter: -all + formats: md:myst + main_language: python + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.13.8 +kernelspec: + display_name: vr_python3 + language: python + name: vr_python3 +--- + + +# API documentation for the {mod}`~virtual_rainforest.models.hydrology.hydrology_model` module + +```{eval-rst} +.. automodule:: virtual_rainforest.models.hydrology.hydrology_model + :autosummary: + :members: + :exclude-members: model_name +``` diff --git a/docs/source/index.md b/docs/source/index.md index 976021630..9b026cca0 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -95,7 +95,10 @@ team. Soil Constants Abiotic Simple Overview Abiotic Simple Model - Abiotic Simple Regression + Abiotic Simple Microclimate + Abiotic Hydrology Overview + Abiotic Hydrology Model + Abiotic Hydrology Constants Abiotic Overview Abiotic Model Abiotic Tools diff --git a/docs/source/virtual_rainforest/module_overview.md b/docs/source/virtual_rainforest/module_overview.md index a8adf29de..0146d57f8 100644 --- a/docs/source/virtual_rainforest/module_overview.md +++ b/docs/source/virtual_rainforest/module_overview.md @@ -119,8 +119,8 @@ the Virtual Rainforest in high temporal resolution or for representative days pe ### Simple Abiotic Model The Simple Abiotic Model is a one-column model that operates on a grid cell basis and -does not consider horizontal exchange of energy, water, and momentum. The model uses -linear regressions from {cite}`hardwick_relationship_2015` and +does not consider horizontal exchange of energy, atmospheric water, and momentum. +The model uses linear regressions from {cite}`hardwick_relationship_2015` and {cite}`jucker_canopy_2018` to predict atmospheric temperature, relative humidity, and vapour pressure deficit at ground level (2m) given the above canopy conditions and leaf area index of @@ -130,6 +130,15 @@ level prediction. Soil temperature is interpolated between the surface layer and temperature at 1 m depth which equals the mean annual temperature. The model also provides a constant vertical profile of atmospheric pressure and atmospheric $\ce{CO_{2}}$. + +### Simple Hydrology Model + +The simple version of the Hydrology Model is a one-column model that operates on a grid +cell basis and does not consider horizontal exchange of water. We placed this +functionality in a separate model in order to allow easy replacement with a different +hydrology model, for example a process-based model that runs on a daily time step and +returns monthly statistics to the data object for other modules to use. + Soil moisture and surface runoff are calculated for each grid cell with a simple bucket model based on {cite}`davis_simple_2017`; vertical flow and horizontal flow (above and below ground) between grid cells are currently not implemented. diff --git a/tests/models/abiotic_simple/test_abiotic_simple_model.py b/tests/models/abiotic_simple/test_abiotic_simple_model.py index d21819d0b..32dbbf1e4 100644 --- a/tests/models/abiotic_simple/test_abiotic_simple_model.py +++ b/tests/models/abiotic_simple/test_abiotic_simple_model.py @@ -1,7 +1,7 @@ """Test module for abiotic_simple.abiotic_simple_model.py.""" from contextlib import nullcontext as does_not_raise -from logging import DEBUG, ERROR, INFO +from logging import DEBUG, INFO import numpy as np import pint @@ -10,17 +10,15 @@ from xarray import DataArray from tests.conftest import log_check -from virtual_rainforest.core.exceptions import InitialisationError from virtual_rainforest.models.abiotic_simple.abiotic_simple_model import ( AbioticSimpleModel, ) @pytest.mark.parametrize( - "ini_soil_moisture,raises,expected_log_entries", + "raises,expected_log_entries", [ ( - 0.5, does_not_raise(), ( ( @@ -37,10 +35,6 @@ "abiotic_simple model: required var 'atmospheric_pressure_ref' " "checked", ), - ( - DEBUG, - "abiotic_simple model: required var 'precipitation' checked", - ), ( DEBUG, "abiotic_simple model: required var 'atmospheric_co2_ref' checked", @@ -60,32 +54,11 @@ ), ), ), - ( - -0.5, - pytest.raises(InitialisationError), - ( - ( - ERROR, - "The initial soil moisture has to be between 0 and 1!", - ), - ), - ), - ( - DataArray([50, 30, 20]), - pytest.raises(InitialisationError), - ( - ( - ERROR, - "The initial soil moisture must be a float!", - ), - ), - ), ], ) def test_abiotic_simple_model_initialization( caplog, dummy_climate_data, - ini_soil_moisture, raises, expected_log_entries, layer_roles_fixture, @@ -101,7 +74,6 @@ def test_abiotic_simple_model_initialization( pint.Quantity("1 week"), soil_layers, canopy_layers, - ini_soil_moisture, ) # In cases where it passes then checks that the object has the right properties @@ -116,7 +88,6 @@ def test_abiotic_simple_model_initialization( assert model.model_name == "abiotic_simple" assert repr(model) == "AbioticSimpleModel(update_interval = 1 week)" assert model.layer_roles == layer_roles_fixture - assert model.initial_soil_moisture == ini_soil_moisture # Final check that expected logging entries are produced log_check(caplog, expected_log_entries) @@ -143,9 +114,6 @@ def test_abiotic_simple_model_initialization( "canopy_layers": 10, }, }, - "abiotic_simple": { - "initial_soil_moisture": 0.5, - }, }, pint.Quantity("1 week"), does_not_raise(), @@ -169,10 +137,6 @@ def test_abiotic_simple_model_initialization( "abiotic_simple model: required var 'atmospheric_pressure_ref' " "checked", ), - ( - DEBUG, - "abiotic_simple model: required var 'precipitation' checked", - ), ( DEBUG, "abiotic_simple model: required var 'atmospheric_co2_ref' checked", @@ -234,9 +198,6 @@ def test_generate_abiotic_simple_model( "canopy_layers": 10, }, }, - "abiotic_simple": { - "initial_soil_moisture": 0.5, - }, }, pint.Quantity("1 week"), ) @@ -261,24 +222,23 @@ def test_setup( model.setup() - soil_moisture_values = np.repeat(a=[np.nan, 0.5], repeats=[13, 2]) - xr.testing.assert_allclose( - dummy_climate_data["soil_moisture"], + model.data["soil_temperature"], DataArray( - np.broadcast_to(soil_moisture_values, (3, 15)).T, + np.full((15, 3), np.nan), dims=["layers", "cell_id"], coords={ - "layers": np.arange(15), - "layer_roles": ("layers", layer_roles_fixture), + "layers": np.arange(0, 15), + "layer_roles": ( + "layers", + model.layer_roles, + ), "cell_id": [0, 1, 2], }, - name="soil_moisture", ), ) - xr.testing.assert_allclose( - dummy_climate_data["vapour_pressure_deficit_ref"], + model.data["vapour_pressure_deficit_ref"], DataArray( np.full((3, 3), 0.141727), dims=["cell_id", "time_index"], diff --git a/tests/models/abiotic_simple/test_simple_regression.py b/tests/models/abiotic_simple/test_microclimate.py similarity index 78% rename from tests/models/abiotic_simple/test_simple_regression.py rename to tests/models/abiotic_simple/test_microclimate.py index c0e404799..d71bd2cd2 100644 --- a/tests/models/abiotic_simple/test_simple_regression.py +++ b/tests/models/abiotic_simple/test_microclimate.py @@ -1,4 +1,4 @@ -"""Test module for abiotic_simple.simple_regression.py.""" +"""Test module for abiotic_simple.microclimate.py.""" import numpy as np import xarray as xr @@ -8,9 +8,7 @@ def test_log_interpolation(dummy_climate_data, layer_roles_fixture): """Test interpolation for temperature and humidity non-negative.""" - from virtual_rainforest.models.abiotic_simple.simple_regression import ( - log_interpolation, - ) + from virtual_rainforest.models.abiotic_simple.microclimate import log_interpolation data = dummy_climate_data @@ -103,9 +101,9 @@ def test_log_interpolation(dummy_climate_data, layer_roles_fixture): def test_calculate_saturation_vapour_pressure(dummy_climate_data): - """Test.""" + """Test calculation of saturation vapour pressure.""" - from virtual_rainforest.models.abiotic_simple.simple_regression import ( + from virtual_rainforest.models.abiotic_simple.microclimate import ( calculate_saturation_vapour_pressure, ) @@ -124,9 +122,9 @@ def test_calculate_saturation_vapour_pressure(dummy_climate_data): def test_calculate_vapour_pressure_deficit(): - """Test.""" + """Test calculation of VPD.""" - from virtual_rainforest.models.abiotic_simple.simple_regression import ( + from virtual_rainforest.models.abiotic_simple.microclimate import ( calculate_vapour_pressure_deficit, ) @@ -204,12 +202,10 @@ def test_calculate_vapour_pressure_deficit(): xr.testing.assert_allclose(result, exp_output) -def test_run_simple_regression(dummy_climate_data, layer_roles_fixture): - """Test interpolation.""" +def test_run_microclimate(dummy_climate_data, layer_roles_fixture): + """Test interpolation of all variables.""" - from virtual_rainforest.models.abiotic_simple.simple_regression import ( - run_simple_regression, - ) + from virtual_rainforest.models.abiotic_simple.microclimate import run_microclimate data = dummy_climate_data @@ -222,11 +218,10 @@ def test_run_simple_regression(dummy_climate_data, layer_roles_fixture): data["atmospheric_co2"] = ( data["atmospheric_pressure"].copy().rename("atmospheric_co2") ) - result = run_simple_regression( + result = run_microclimate( data=data, layer_roles=layer_roles_fixture, time_index=0, - soil_moisture_capacity=DataArray([30, 60, 90], dims="cell_id"), ) exp_air_temperature = xr.concat( @@ -266,25 +261,11 @@ def test_run_simple_regression(dummy_climate_data, layer_roles_fixture): ).assign_coords(data["layer_heights"].coords) xr.testing.assert_allclose(result["atmospheric_pressure"], exp_atmospheric_pressure) - exp_soil_moisture = xr.concat( - [ - DataArray( - np.full((13, 3), np.nan), - dims=["layers", "cell_id"], - ), - DataArray( - [[30.0, 41.0, 90.0], [30.0, 41.0, 90.0]], dims=["layers", "cell_id"] - ), - ], - dim="layers", - ).assign_coords(data["layer_heights"].coords) - xr.testing.assert_allclose(result["soil_moisture"], exp_soil_moisture) - def test_interpolate_soil_temperature(dummy_climate_data): - """Test.""" + """Test soil temperature interpolation.""" - from virtual_rainforest.models.abiotic_simple.simple_regression import ( + from virtual_rainforest.models.abiotic_simple.microclimate import ( interpolate_soil_temperature, ) @@ -314,42 +295,3 @@ def test_interpolate_soil_temperature(dummy_climate_data): ) xr.testing.assert_allclose(result, exp_output) - - -def test_calculate_soil_moisture(dummy_climate_data, layer_roles_fixture): - """Test.""" - - from virtual_rainforest.models.abiotic_simple.simple_regression import ( - calculate_soil_moisture, - ) - - data = dummy_climate_data - precipitation_surface = data["precipitation"].isel(time_index=0) * ( - 1 - 0.1 * data["leaf_area_index"].sum(dim="layers") - ) - - exp_soil_moisture = DataArray( - [[30.0, 41.0, 90.0], [30.0, 41.0, 90.0]], - dims=["layers", "cell_id"], - coords={ - "cell_id": [0, 1, 2], - "layers": [13, 14], - "layer_roles": ("layers", ["soil", "soil"]), - }, - ) - - exp_runoff = DataArray( - [4, 0, 70], - dims=["cell_id"], - coords={"cell_id": [0, 1, 2]}, - ) - - result = calculate_soil_moisture( - layer_roles=layer_roles_fixture, - precipitation_surface=precipitation_surface, - current_soil_moisture=data["soil_moisture"], - soil_moisture_capacity=DataArray([30, 60, 90], dims=["cell_id"]), - ) - - xr.testing.assert_allclose(result[0], exp_soil_moisture) - xr.testing.assert_allclose(result[1], exp_runoff) diff --git a/tests/models/hydrology/test_hydrology_model.py b/tests/models/hydrology/test_hydrology_model.py new file mode 100644 index 000000000..7eacd8463 --- /dev/null +++ b/tests/models/hydrology/test_hydrology_model.py @@ -0,0 +1,282 @@ +"""Test module for hydrology.hydrology_model.py.""" + +from contextlib import nullcontext as does_not_raise +from logging import DEBUG, ERROR, INFO + +import numpy as np +import pint +import pytest +import xarray as xr +from xarray import DataArray + +from tests.conftest import log_check +from virtual_rainforest.core.exceptions import InitialisationError +from virtual_rainforest.models.hydrology.hydrology_model import HydrologyModel + + +@pytest.mark.parametrize( + "ini_soil_moisture,raises,expected_log_entries", + [ + ( + 0.5, + does_not_raise(), + ( + ( + DEBUG, + "hydrology model: required var 'precipitation' checked", + ), + ( + DEBUG, + "hydrology model: required var 'leaf_area_index' checked", + ), + ), + ), + ( + -0.5, + pytest.raises(InitialisationError), + ( + ( + ERROR, + "The initial soil moisture has to be between 0 and 1!", + ), + ), + ), + ( + DataArray([50, 30, 20]), + pytest.raises(InitialisationError), + ( + ( + ERROR, + "The initial soil moisture must be a float!", + ), + ), + ), + ], +) +def test_hydrology_model_initialization( + caplog, + dummy_climate_data, + ini_soil_moisture, + raises, + expected_log_entries, + layer_roles_fixture, + soil_layers=2, + canopy_layers=10, +): + """Test `HydrologyModel` initialization.""" + + with raises: + # Initialize model + model = HydrologyModel( + dummy_climate_data, + pint.Quantity("1 week"), + soil_layers, + canopy_layers, + ini_soil_moisture, + ) + + # In cases where it passes then checks that the object has the right properties + assert set( + [ + "setup", + "spinup", + "update", + "cleanup", + ] + ).issubset(dir(model)) + assert model.model_name == "hydrology" + assert repr(model) == "HydrologyModel(update_interval = 1 week)" + assert model.layer_roles == layer_roles_fixture + assert model.initial_soil_moisture == ini_soil_moisture + + # Final check that expected logging entries are produced + log_check(caplog, expected_log_entries) + + +@pytest.mark.parametrize( + "config,time_interval,raises,expected_log_entries", + [ + ( + {}, + None, + pytest.raises(KeyError), + (), # This error isn't handled so doesn't generate logging + ), + ( + { + "core": { + "timing": { + "start_date": "2020-01-01", + "update_interval": "1 week", + }, + "layers": { + "soil_layers": 2, + "canopy_layers": 10, + }, + }, + "hydrology": { + "initial_soil_moisture": 0.5, + }, + }, + pint.Quantity("1 week"), + does_not_raise(), + ( + ( + INFO, + "Information required to initialise the hydrology model " + "successfully extracted.", + ), + ( + DEBUG, + "hydrology model: required var 'precipitation' checked", + ), + ( + DEBUG, + "hydrology model: required var 'leaf_area_index' checked", + ), + ), + ), + ], +) +def test_generate_hydrology_model( + caplog, + dummy_climate_data, + config, + time_interval, + raises, + expected_log_entries, + layer_roles_fixture, +): + """Test that the initialisation of the hydrology model works as expected.""" + + # Check whether model is initialised (or not) as expected + with raises: + model = HydrologyModel.from_config( + dummy_climate_data, + config, + pint.Quantity(config["core"]["timing"]["update_interval"]), + ) + assert model.layer_roles == layer_roles_fixture + assert model.update_interval == time_interval + + # Final check that expected logging entries are produced + log_check(caplog, expected_log_entries) + + +@pytest.mark.parametrize( + "config,time_interval", + [ + ( + { + "core": { + "timing": { + "start_date": "2020-01-01", + "update_interval": "1 week", + }, + "layers": { + "soil_layers": 2, + "canopy_layers": 10, + }, + }, + "hydrology": { + "initial_soil_moisture": 0.5, + }, + }, + pint.Quantity("1 week"), + ) + ], +) +def test_setup( + dummy_climate_data, + config, + layer_roles_fixture, + time_interval, +): + """Test set up and update.""" + + # initialise model + model = HydrologyModel.from_config( + dummy_climate_data, + config, + pint.Quantity(config["core"]["timing"]["update_interval"]), + ) + assert model.layer_roles == layer_roles_fixture + assert model.update_interval == time_interval + + model.setup() + + soil_moisture_values = np.repeat(a=[np.nan, 0.5], repeats=[13, 2]) + + xr.testing.assert_allclose( + dummy_climate_data["soil_moisture"], + DataArray( + np.broadcast_to(soil_moisture_values, (3, 15)).T, + dims=["layers", "cell_id"], + coords={ + "layers": np.arange(15), + "layer_roles": ("layers", layer_roles_fixture), + "cell_id": [0, 1, 2], + }, + name="soil_moisture", + ), + ) + + # Run the update step + model.update() + + exp_soil_moisture = xr.concat( + [ + DataArray( + np.full((13, 3), np.nan), + dims=["layers", "cell_id"], + ), + DataArray( + [[0.514, 0.521, 0.64], [0.514, 0.521, 0.64]], + dims=["layers", "cell_id"], + ), + ], + dim="layers", + ).assign_coords(model.data["layer_heights"].coords) + + xr.testing.assert_allclose(model.data["soil_moisture"], exp_soil_moisture) + # test that time_index was updated + assert model.time_index == 1 + + +def test_calculate_soil_moisture(dummy_climate_data, layer_roles_fixture): + """Test that soil moisture and runoff are calculated correctly.""" + + from virtual_rainforest.models.hydrology.hydrology_model import ( + calculate_soil_moisture, + ) + + data = dummy_climate_data + precipitation_surface = data["precipitation"].isel(time_index=0) * ( + 1 - 0.1 * data["leaf_area_index"].sum(dim="layers") + ) + + exp_soil_moisture = DataArray( + [[0.3, 0.6, 0.9], [0.3, 0.6, 0.9]], + dims=["layers", "cell_id"], + coords={ + "cell_id": [0, 1, 2], + "layers": [13, 14], + "layer_roles": ("layers", ["soil", "soil"]), + }, + ) + + exp_runoff = DataArray( + [33.7, 40.4, 159.1], + dims=["cell_id"], + coords={"cell_id": [0, 1, 2]}, + ) + + result = calculate_soil_moisture( + layer_roles=layer_roles_fixture, + precipitation_surface=precipitation_surface, + current_soil_moisture=data["soil_moisture"], + soil_moisture_capacity=DataArray([0.3, 0.6, 0.9], dims=["cell_id"]), + ) + + xr.testing.assert_allclose(result[0], exp_soil_moisture) + xr.testing.assert_allclose(result[1], exp_runoff) diff --git a/virtual_rainforest/models/abiotic_simple/__init__.py b/virtual_rainforest/models/abiotic_simple/__init__.py index 4b027a39c..07897a810 100644 --- a/virtual_rainforest/models/abiotic_simple/__init__.py +++ b/virtual_rainforest/models/abiotic_simple/__init__.py @@ -1,6 +1,6 @@ r"""The :mod:`~virtual_rainforest.models.abiotic_simple` module is one of the component models of the Virtual Rainforest. It is comprised of several submodules that calculate -the microclimate and hydrology for the Virtual Rainforest. +the microclimate for the Virtual Rainforest. Each of the abiotic sub-modules has its own API reference page: @@ -9,10 +9,10 @@ abiotic simple module into a single class, which the high level functions of the Virtual Rainforest can then use. -* The :mod:`~virtual_rainforest.models.abiotic_simple.simple_regression` submodule +* The :mod:`~virtual_rainforest.models.abiotic_simple.microclimate` submodule contains a set functions and parameters that are used to calculate atmospheric temperature, humidity, :math:`\ce{CO2}`, and atmospheric pressure profiles as well as - soil temperature and soil moisture profiles. + soil temperature profiles. """ # noqa: D205, D415 diff --git a/virtual_rainforest/models/abiotic_simple/abiotic_simple_model.py b/virtual_rainforest/models/abiotic_simple/abiotic_simple_model.py index bf9181f65..8f89f69a9 100644 --- a/virtual_rainforest/models/abiotic_simple/abiotic_simple_model.py +++ b/virtual_rainforest/models/abiotic_simple/abiotic_simple_model.py @@ -26,10 +26,9 @@ class as a child of the :class:`~virtual_rainforest.core.base_model.BaseModel` c from virtual_rainforest.core.base_model import BaseModel from virtual_rainforest.core.data import Data -from virtual_rainforest.core.exceptions import InitialisationError from virtual_rainforest.core.logger import LOGGER from virtual_rainforest.core.utils import set_layer_roles -from virtual_rainforest.models.abiotic_simple import simple_regression +from virtual_rainforest.models.abiotic_simple import microclimate class AbioticSimpleModel(BaseModel): @@ -40,8 +39,6 @@ class AbioticSimpleModel(BaseModel): update_interval: Time to wait between updates of the model state. soil_layers: The number of soil layers to be modelled. canopy_layers: The initial number of canopy layers to be modelled. - initial_soil_moisture: The initial soil moisture for all layers, - [relative water content] (between 0.0 and 1.0). """ model_name = "abiotic_simple" @@ -54,7 +51,6 @@ class AbioticSimpleModel(BaseModel): ("air_temperature_ref", ("spatial",)), ("relative_humidity_ref", ("spatial",)), ("atmospheric_pressure_ref", ("spatial",)), - ("precipitation", ("spatial",)), ("atmospheric_co2_ref", ("spatial",)), ("mean_annual_temperature", ("spatial",)), ("leaf_area_index", ("spatial",)), @@ -68,22 +64,8 @@ def __init__( update_interval: Quantity, soil_layers: int, canopy_layers: int, - initial_soil_moisture: float, **kwargs: Any, ): - # sanity checks for initial soil moisture - if type(initial_soil_moisture) is not float: - to_raise = InitialisationError("The initial soil moisture must be a float!") - LOGGER.error(to_raise) - raise to_raise - - if initial_soil_moisture < 0 or initial_soil_moisture > 1: - to_raise = InitialisationError( - "The initial soil moisture has to be between 0 and 1!" - ) - LOGGER.error(to_raise) - raise to_raise - super().__init__(data, update_interval, **kwargs) # create a list of layer roles @@ -95,8 +77,6 @@ def __init__( """A list of vertical layer roles.""" self.update_interval """The time interval between model updates.""" - self.initial_soil_moisture = initial_soil_moisture - """Initial soil moisture for all layers and grill cells identical.""" self.time_index = 0 """Start counter for extracting correct input data.""" @@ -119,48 +99,21 @@ def from_config( # Find number of soil and canopy layers soil_layers = config["core"]["layers"]["soil_layers"] canopy_layers = config["core"]["layers"]["canopy_layers"] - initial_soil_moisture = config["abiotic_simple"]["initial_soil_moisture"] LOGGER.info( "Information required to initialise the abiotic simple model successfully " "extracted." ) - return cls( - data, update_interval, soil_layers, canopy_layers, initial_soil_moisture - ) + return cls(data, update_interval, soil_layers, canopy_layers) def setup(self) -> None: """Function to set up the abiotic simple model. - At the moment, this function only initializes soil moisture homogenously for all + At the moment, this function only initializes soil temperature for all soil layers and calculates the reference vapour pressure deficit for all time steps. Both variables are added directly to the self.data object. """ - # Create 1-dimensional numpy array filled with initial soil moisture values for - # all soil layers and np.nan for atmosphere layers - soil_moisture_values = np.repeat( - a=[np.nan, self.initial_soil_moisture], - repeats=[ - len(self.layer_roles) - self.layer_roles.count("soil"), - self.layer_roles.count("soil"), - ], - ) - # Broadcast 1-dimensional array to grid and assign dimensions and coordinates - self.data["soil_moisture"] = DataArray( - np.broadcast_to( - soil_moisture_values, - (len(self.data.grid.cell_id), len(self.layer_roles)), - ).T, - dims=["layers", "cell_id"], - coords={ - "layers": np.arange(15), - "layer_roles": ("layers", self.layer_roles), - "cell_id": self.data.grid.cell_id, - }, - name="soil_moisture", - ) - # create soil temperature array self.data["soil_temperature"] = DataArray( np.full((len(self.layer_roles), len(self.data.grid.cell_id)), np.nan), @@ -176,7 +129,7 @@ def setup(self) -> None: # calculate vapour pressure deficit at reference height for all time steps self.data[ "vapour_pressure_deficit_ref" - ] = simple_regression.calculate_vapour_pressure_deficit( + ] = microclimate.calculate_vapour_pressure_deficit( temperature=self.data["air_temperature_ref"], relative_humidity=self.data["relative_humidity_ref"], ).rename( @@ -192,7 +145,7 @@ def update(self) -> None: # This section perfomes a series of calculations to update the variables in the # abiotic model. This could be moved to here and written directly to the data # object. For now, we leave it as a separate routine. - output_variables = simple_regression.run_simple_regression( + output_variables = microclimate.run_microclimate( data=self.data, layer_roles=self.layer_roles, time_index=self.time_index, diff --git a/virtual_rainforest/models/abiotic_simple/abiotic_simple_schema.json b/virtual_rainforest/models/abiotic_simple/abiotic_simple_schema.json index 42b063c26..f7ac3cfb9 100644 --- a/virtual_rainforest/models/abiotic_simple/abiotic_simple_schema.json +++ b/virtual_rainforest/models/abiotic_simple/abiotic_simple_schema.json @@ -2,21 +2,11 @@ "type": "object", "properties": { "abiotic_simple": { - "description": "Configuration settings for the simple abiotic module", + "description": "Configuration settings for the abiotic_simple module", "type": "object", - "properties": { - "initial_soil_moisture": { - "description": "Initial soil moisture for all layers", - "type": "number", - "exclusiveMinimum": 0, - "default": 0.5, - "units": "Relative water content (between 0.0 and 1.0)" - } - }, + "properties": {}, "default": {}, - "required": [ - "initial_soil_moisture" - ] + "required": [] } }, "required": [ diff --git a/virtual_rainforest/models/abiotic_simple/simple_regression.py b/virtual_rainforest/models/abiotic_simple/microclimate.py similarity index 70% rename from virtual_rainforest/models/abiotic_simple/simple_regression.py rename to virtual_rainforest/models/abiotic_simple/microclimate.py index ade56c62b..4ab05718b 100644 --- a/virtual_rainforest/models/abiotic_simple/simple_regression.py +++ b/virtual_rainforest/models/abiotic_simple/microclimate.py @@ -1,18 +1,16 @@ -r"""The ``models.abiotic_simple.simple_regression`` module uses linear regressions from +r"""The ``models.abiotic_simple.microclimate`` module uses linear regressions from :cite:t:`hardwick_relationship_2015` and :cite:t:`jucker_canopy_2018` to predict atmospheric temperature and relative humidity at ground level (2m) given the above canopy conditions and leaf area index of intervening canopy. A within canopy profile is then interpolated using a logarithmic curve between the above canopy observation and ground level prediction. -Soil temperature is interpolated between the surface layer and the air temperature at +Soil temperature is interpolated between the surface layer and the soil temperature at 1 m depth which equals the mean annual temperature. The module also provides a constant vertical profile of atmospheric pressure and :math:`\ce{CO2}`. -Soil moisture and surface runoff are calculated with a simple bucket model based on -:cite:t:`davis_simple_2017`. """ # noqa: D205, D415 -from typing import Dict, List, Tuple, Union +from typing import Dict, List import numpy as np import xarray as xr @@ -31,8 +29,6 @@ """ MicroclimateParameters: Dict[str, float] = { - "soil_moisture_capacity": 0.9, - "water_interception_factor": 0.1, "saturation_vapour_pressure_factor1": 0.61078, "saturation_vapour_pressure_factor2": 7.5, "saturation_vapour_pressure_factor3": 237.3, @@ -46,8 +42,6 @@ "relative_humidity_max": 100, "vapour_pressure_deficit_min": 0, "vapour_pressure_deficit_max": 10, - "soil_moisture_min": 0, - "soil_moisture_max": 100, "soil_temperature_min": -10, "soil_temperature_max": 50, } @@ -59,18 +53,12 @@ # to conserve energy and matter -def run_simple_regression( +def run_microclimate( data: Data, layer_roles: List[str], time_index: int, # could be datetime? MicroclimateGradients: Dict[str, float] = MicroclimateGradients, Bounds: Dict[str, float] = Bounds, - water_interception_factor: Union[DataArray, float] = MicroclimateParameters[ - "water_interception_factor" - ], - soil_moisture_capacity: Union[DataArray, float] = MicroclimateParameters[ - "soil_moisture_capacity" - ], ) -> Dict[str, DataArray]: r"""Calculate simple microclimate. @@ -83,7 +71,7 @@ def run_simple_regression( :math:`y = m * LAI + c` where :math:`y` is the variable of interest, math:`m` is the gradient - (:data:`~virtual_rainforest.models.abiotic_simple.simple_regression.MicroclimateGradients`) + (:data:`~virtual_rainforest.models.abiotic_simple.microclimate.MicroclimateGradients`) and :math:`c` is the intersect which we set to the external data values. We assume that the gradient remains constant. @@ -94,14 +82,6 @@ def run_simple_regression( The function also provides constant atmospheric pressure and :math:`\ce{CO2}` for all atmospheric levels. - Soil moisture and surface runoff are calculated with a simple bucket model based - on :cite:t:`davis_simple_2017`: if - precipitation exceeds soil moisture capacity (see - :data:`~virtual_rainforest.models.abiotic_simple.simple_regression.MicroclimateParameters`) - , the excess water is added to runoff and soil moisture is set to soil moisture - capacity value; if the soil is not saturated, precipitation is added to the current - soil moisture level and runoff is set to zero. - The `layer_roles` list is composed of the following layers (index 0 above canopy): * above canopy (canopy height + reference measurement height, typically 2m) @@ -119,8 +99,6 @@ def run_simple_regression( * atmospheric_co2_ref * leaf_area_index * layer_heights - * precipitation - * soil_moisture Args: data: Data object @@ -131,16 +109,11 @@ def run_simple_regression( :cite:p:`hardwick_relationship_2015` Bounds: upper and lower allowed values for vertical profiles, used to constrain log interpolation. Note that currently no conservation of water and energy! - water_interception_factor: Factor that determines how much rainfall is - intercepted by stem and canopy - soil_moisture_capacity: soil moisture capacity for water, relative water content - (between 0.0 and 1.0) Returns: Dict of DataArrays for air temperature [C], relative humidity [-], vapour - pressure deficit [kPa], soil temperature [C], atmospheric pressure [kPa], - atmospheric :math:`\ce{CO2}` [ppm], soil moisture [relative water content], and - surface runoff [mm] + pressure deficit [kPa], soil temperature [C], atmospheric pressure [kPa], and + atmospheric :math:`\ce{CO2}` [ppm] """ # TODO correct gap between 1.5 m and 2m reference height for LAI = 0 @@ -203,29 +176,6 @@ def run_simple_regression( dim="layers", ) - # Precipitation at the surface is reduced as a function of leaf area index - precipitation_surface = data["precipitation"].isel(time_index=time_index) * ( - 1 - water_interception_factor * data["leaf_area_index"].sum(dim="layers") - ) - - # Mean soil moisture profile, [] and Runoff, [mm] - soil_moisture_only, surface_run_off = calculate_soil_moisture( - layer_roles=layer_roles, - precipitation_surface=precipitation_surface, - current_soil_moisture=data["soil_moisture"], - soil_moisture_capacity=soil_moisture_capacity, - ) - output["soil_moisture"] = xr.concat( - [ - data["soil_moisture"].isel( - layers=np.arange(0, len(layer_roles) - layer_roles.count("soil")) - ), - soil_moisture_only, - ], - dim="layers", - ) - output["surface_run_off"] = surface_run_off - return output @@ -387,75 +337,3 @@ def interpolate_soil_temperature( np.clip(layer_values, lower_bound, upper_bound), coords=interpolation_heights.coords, ).drop_isel(layers=0) - - -# TODO HYDROLOGY grid based in simple model? -# Stream flow could be estimated as P-ET - - -def calculate_soil_moisture( - layer_roles: List, - precipitation_surface: DataArray, - current_soil_moisture: DataArray, - soil_moisture_capacity: Union[DataArray, float] = MicroclimateParameters[ - "soil_moisture_capacity" - ], -) -> Tuple[DataArray, DataArray]: - """Calculate surface runoff and update soil moisture. - - Soil moisture and surface runoff are calculated with a simple bucket model: if - precipitation exceeds soil moisture capacity (see MicroclimateParameters), the - excess water is added to runoff and soil moisture is set to soil moisture capacity - value; if the soil is not saturated, precipitation is added to the current soil - moisture level and runoff is set to zero. - - Args: - layer_roles: list of layer roles (from top to bottom: above, canopy, subcanopy, - surface, soil) - precipitation_surface: precipitation that reaches surface, [mm], - current_soil_moisture: current soil moisture at upper layer, [mm], - soil_moisture_capacity: soil moisture capacity (optional), [relative water - content] - - Returns: - current soil moisture for one layer, [relative water content], surface runoff, - [mm] - """ - # calculate how much water can be added to soil before capacity is reached - available_capacity = soil_moisture_capacity - current_soil_moisture.mean( - dim="layers" - ) - - # calculate where precipitation exceeds available capacity - surface_runoff_cells = precipitation_surface.where( - precipitation_surface > available_capacity - ) - # calculate runoff - surface_runoff = ( - DataArray(surface_runoff_cells.data - available_capacity.data) - .fillna(0) - .rename("surface_runoff") - .rename({"dim_0": "cell_id"}) - .assign_coords({"cell_id": current_soil_moisture.cell_id}) - ) - - # calculate total water in each grid cell - total_water = current_soil_moisture.mean(dim="layers") + precipitation_surface - - # calculate soil moisture for one layer and copy to all layers - soil_moisture = ( - DataArray(np.clip(total_water, 0, soil_moisture_capacity)) - .expand_dims(dim={"layers": np.arange(layer_roles.count("soil"))}, axis=0) - .assign_coords( - { - "cell_id": current_soil_moisture.cell_id, - "layers": [ - len(layer_roles) - layer_roles.count("soil"), - len(layer_roles) - 1, - ], - "layer_roles": ("layers", layer_roles.count("soil") * ["soil"]), - } - ) - ) - - return soil_moisture, surface_runoff diff --git a/virtual_rainforest/models/hydrology/__init__.py b/virtual_rainforest/models/hydrology/__init__.py new file mode 100644 index 000000000..1cc5caac1 --- /dev/null +++ b/virtual_rainforest/models/hydrology/__init__.py @@ -0,0 +1,29 @@ +r"""The :mod:`~virtual_rainforest.models.hydrology` model is one of the component +models of the Virtual Rainforest. It is comprised of several submodules that calculate +the hydrology for the Virtual Rainforest. + +Each of the hydrology sub-modules has its own API reference page: + +* The :mod:`~virtual_rainforest.models.hydrology.hydrology_model` submodule + instantiates the HydrologyModel class which consolidates the functionality of the + hydrology module into a single class, which the high level functions of the + Virtual Rainforest can then use. At the momemnt, the model calculates the hydrology + loosely based on the SPLASH model :cite:p:`davis_simple_2017`. In the future, this + simple bucket-model will be replaced by a process-based model that calculates a within + grid cell water balance as well as the catchment water balance on a daily time step. + +* The :mod:`~virtual_rainforest.models.hydrology.hydrology_constants` submodule contains + parameters and constants for the hydrology model. This is a temporary solution. +""" # noqa: D205, D415 + +from importlib import resources + +from virtual_rainforest.core.config import register_schema +from virtual_rainforest.models.hydrology.hydrology_model import HydrologyModel + +with resources.path( + "virtual_rainforest.models.hydrology", "hydrology_schema.json" +) as schema_file_path: + register_schema( + module_name=HydrologyModel.model_name, schema_file_path=schema_file_path + ) diff --git a/virtual_rainforest/models/hydrology/hydrology_constants.py b/virtual_rainforest/models/hydrology/hydrology_constants.py new file mode 100644 index 000000000..23bb0ffed --- /dev/null +++ b/virtual_rainforest/models/hydrology/hydrology_constants.py @@ -0,0 +1,14 @@ +"""The :mod:`~virtual_rainforest.models.hydrology.hydrology_constants` module contains +constants and parameters for the hydrology model. This is a temporary solution. +""" # noqa: D205, D415 + +from typing import Dict + +HydrologyParameters: Dict[str, float] = { + "soil_moisture_capacity": 0.9, + "water_interception_factor": 0.1, +} +"""Parameters for hydrology model.""" + +# TODO move bounds to core.bound_checking once that is implemented and introduce method +# to conserve energy and matter diff --git a/virtual_rainforest/models/hydrology/hydrology_model.py b/virtual_rainforest/models/hydrology/hydrology_model.py new file mode 100644 index 000000000..08a9587aa --- /dev/null +++ b/virtual_rainforest/models/hydrology/hydrology_model.py @@ -0,0 +1,282 @@ +"""The :mod:`~virtual_rainforest.models.hydrology.hydrology_model` module +creates a +:class:`~virtual_rainforest.models.hydrology.hydrology_model.HydrologyModel` +class as a child of the :class:`~virtual_rainforest.core.base_model.BaseModel` class. +At present a lot of the abstract methods of the parent class (e.g. +:func:`~virtual_rainforest.core.base_model.BaseModel.spinup`) are overwritten using +placeholder functions that don't do anything. This will change as the Virtual Rainforest +model develops. The factory method +:func:`~virtual_rainforest.models.hydrology.hydrology_model.HydrologyModel.from_config` +exists in a +more complete state, and unpacks a small number of parameters from our currently pretty +minimal configuration dictionary. These parameters are then used to generate a class +instance. If errors crop here when converting the information from the config dictionary +to the required types they are caught and then logged, and at the end of the unpacking +an error is thrown. This error should be caught and handled by downstream functions so +that all model configuration failures can be reported as one. +""" # noqa: D205, D415 + +from __future__ import annotations + +from typing import Any, List, Tuple, Union + +import numpy as np +import xarray as xr +from pint import Quantity +from xarray import DataArray + +from virtual_rainforest.core.base_model import BaseModel +from virtual_rainforest.core.data import Data +from virtual_rainforest.core.exceptions import InitialisationError +from virtual_rainforest.core.logger import LOGGER +from virtual_rainforest.core.utils import set_layer_roles +from virtual_rainforest.models.hydrology.hydrology_constants import HydrologyParameters + + +class HydrologyModel(BaseModel): + """A class describing the hydrology model. + + Args: + data: The data object to be used in the model. + update_interval: Time to wait between updates of the model state. + soil_layers: The number of soil layers to be modelled. + canopy_layers: The initial number of canopy layers to be modelled. + initial_soil_moisture: The initial soil moisture for all layers. + + Raises: + InitialisationError: when initial soil moisture is out of bounds. + """ + + model_name = "hydrology" + """The model name for use in registering the model and logging.""" + lower_bound_on_time_scale = "1 day" + """Shortest time scale that hydrology model can sensibly capture.""" + upper_bound_on_time_scale = "1 month" + """Longest time scale that hydrology model can sensibly capture.""" + required_init_vars = ( + ("precipitation", ("spatial",)), + ("leaf_area_index", ("spatial",)), + ) # TODO add time + """The required variables and axes for the hydrology model""" + + def __init__( + self, + data: Data, + update_interval: Quantity, + soil_layers: int, + canopy_layers: int, + initial_soil_moisture: float, + **kwargs: Any, + ): + # sanity checks for initial soil moisture + if type(initial_soil_moisture) is not float: + to_raise = InitialisationError("The initial soil moisture must be a float!") + LOGGER.error(to_raise) + raise to_raise + + if initial_soil_moisture < 0 or initial_soil_moisture > 1: + to_raise = InitialisationError( + "The initial soil moisture has to be between 0 and 1!" + ) + LOGGER.error(to_raise) + raise to_raise + + super().__init__(data, update_interval, **kwargs) + + # create a list of layer roles + layer_roles = set_layer_roles(canopy_layers, soil_layers) + + self.data + """A Data instance providing access to the shared simulation data.""" + self.layer_roles = layer_roles + """A list of vertical layer roles.""" + self.update_interval + """The time interval between model updates.""" + self.initial_soil_moisture = initial_soil_moisture + """Initial soil moisture for all layers and grill cells identical.""" + self.time_index = 0 + """Start counter for extracting correct input data.""" + + @classmethod + def from_config( + cls, data: Data, config: dict[str, Any], update_interval: Quantity + ) -> HydrologyModel: + """Factory function to initialise the hydrology model from configuration. + + This function unpacks the relevant information from the configuration file, and + then uses it to initialise the model. If any information from the config is + invalid rather than returning an initialised model instance an error is raised. + + Args: + data: A :class:`~virtual_rainforest.core.data.Data` instance. + config: The complete (and validated) Virtual Rainforest configuration. + update_interval: Frequency with which all models are updated. + """ + + # Find number of soil and canopy layers + soil_layers = config["core"]["layers"]["soil_layers"] + canopy_layers = config["core"]["layers"]["canopy_layers"] + initial_soil_moisture = config["hydrology"]["initial_soil_moisture"] + + LOGGER.info( + "Information required to initialise the hydrology model successfully " + "extracted." + ) + return cls( + data, update_interval, soil_layers, canopy_layers, initial_soil_moisture + ) + + def setup(self) -> None: + """Function to set up the hydrology model. + + At the moment, this function only initializes soil moisture homogenously for all + soil layers. + """ + + # Create 1-dimenaional numpy array filled with initial soil moisture values for + # all soil layers and np.nan for atmosphere layers + soil_moisture_values = np.repeat( + a=[np.nan, self.initial_soil_moisture], + repeats=[ + len(self.layer_roles) - self.layer_roles.count("soil"), + self.layer_roles.count("soil"), + ], + ) + # Broadcast 1-dimensional array to grid and assign dimensions and coordinates + self.data["soil_moisture"] = DataArray( + np.broadcast_to( + soil_moisture_values, + (len(self.data.grid.cell_id), len(self.layer_roles)), + ).T, + dims=["layers", "cell_id"], + coords={ + "layers": np.arange(15), + "layer_roles": ("layers", self.layer_roles), + "cell_id": self.data.grid.cell_id, + }, + name="soil_moisture", + ) + + def spinup(self) -> None: + """Placeholder function to spin up the hydrology model.""" + + def update(self) -> None: + """Function to update the hydrology model. + + At the moment, this step updates the soil moisture and surface runoff . + """ + + # Precipitation at the surface is reduced as a function of leaf area index + precipitation_surface = self.data["precipitation"].isel( + time_index=self.time_index + ) * ( + 1 + - HydrologyParameters["water_interception_factor"] + * self.data["leaf_area_index"].sum(dim="layers") + ) + + # Mean soil moisture profile, [] and Runoff, [mm] + soil_moisture_only, surface_run_off = calculate_soil_moisture( + layer_roles=self.layer_roles, + precipitation_surface=precipitation_surface, + current_soil_moisture=self.data["soil_moisture"], + soil_moisture_capacity=HydrologyParameters["soil_moisture_capacity"], + ) + + self.data["soil_moisture"] = xr.concat( + [ + self.data["soil_moisture"].isel( + layers=np.arange( + 0, len(self.layer_roles) - self.layer_roles.count("soil") + ) + ), + soil_moisture_only, + ], + dim="layers", + ) + self.data["surface_run_off"] = surface_run_off + + self.time_index += 1 + + def cleanup(self) -> None: + """Placeholder function for hydrology model cleanup.""" + + +def calculate_soil_moisture( + layer_roles: List, + precipitation_surface: DataArray, + current_soil_moisture: DataArray, + soil_moisture_capacity: Union[DataArray, float] = HydrologyParameters[ + "soil_moisture_capacity" + ], +) -> Tuple[DataArray, DataArray]: + """Calculate surface runoff and update soil moisture for one time step. + + Soil moisture and surface runoff are calculated with a simple bucket model based on + :cite:t:`davis_simple_2017`: if precipitation exceeds soil moisture capacity, the + excess water is added to runoff and soil moisture is set to soil moisture capacity + value; if the soil is not saturated, precipitation is added to the current soil + moisture level and runoff is set to zero. Vertical flow, subsurface flow, and stream + flow are currently not simulated. + + Args: + layer_roles: list of layer roles (from top to bottom: above, canopy, subcanopy, + surface, soil) + precipitation_surface: precipitation that reaches surface, [mm], + current_soil_moisture: current soil moisture at upper layer, [mm], + soil_moisture_capacity: soil moisture capacity (optional), [relative water + content] + + Returns: + current soil moisture for one layer, [relative water content], surface runoff, + [mm] + """ + # Calculate how much water can be added to soil before capacity is reached. + + # To find out how much rain can be taken up by soil before bucket is full and rain + # goes to runoff, the relative water content (between 0 and 1) is converted to mm + # at defined depth with this equation: + # water content in mm = relative water content / 100 * depth in mm + # Example: for 20% water at 40 cm this would be: 20/100 * 400mm = 80 mm + # Here, we look at 1 m depth, so the relative water content is multiplied by 1000 mm + # to get the water content in mm + available_capacity = ( + soil_moisture_capacity - current_soil_moisture.mean(dim="layers") + ) * 1000 + + # calculate where precipitation exceeds available capacity + surface_runoff_cells = precipitation_surface.where( + precipitation_surface > available_capacity + ) + # calculate runoff + surface_runoff = ( + DataArray(surface_runoff_cells.data - available_capacity.data / 1000) + .fillna(0) + .rename("surface_runoff") + .rename({"dim_0": "cell_id"}) + .assign_coords({"cell_id": current_soil_moisture.cell_id}) + ) + + # calculate total water in each grid cell + total_water = ( + current_soil_moisture.mean(dim="layers") + precipitation_surface / 1000 + ) + + # calculate soil moisture for one layer (1 m depth) and copy to all layers + # TODO variable soil moisture with depth + soil_moisture = ( + DataArray(np.clip(total_water, 0, soil_moisture_capacity)) + .expand_dims(dim={"layers": np.arange(layer_roles.count("soil"))}, axis=0) + .assign_coords( + { + "cell_id": current_soil_moisture.cell_id, + "layers": [ + len(layer_roles) - layer_roles.count("soil"), + len(layer_roles) - 1, + ], + "layer_roles": ("layers", layer_roles.count("soil") * ["soil"]), + } + ) + ) + + return soil_moisture, surface_runoff diff --git a/virtual_rainforest/models/hydrology/hydrology_schema.json b/virtual_rainforest/models/hydrology/hydrology_schema.json new file mode 100644 index 000000000..0222620a8 --- /dev/null +++ b/virtual_rainforest/models/hydrology/hydrology_schema.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "properties": { + "hydrology": { + "description": "Configuration settings for the hydrology module", + "type": "object", + "properties": { + "initial_soil_moisture": { + "description": "Initial soil moisture for all layers", + "type": "number", + "exclusiveMinimum": 0, + "default": 0.5 + } + }, + "default": {}, + "required": [ + "initial_soil_moisture" + ] + } + }, + "required": [ + "hydrology" + ] +} \ No newline at end of file