Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Minimal carbon pool model #134

Merged
merged 51 commits into from
Feb 7, 2023
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
53b361d
Added skelton SoilCarbon class
jacobcook1995 Dec 13, 2022
893c098
Add basic pools init
jacobcook1995 Dec 13, 2022
3ae2dba
Created draft function for pool update function
jacobcook1995 Dec 13, 2022
363d0f3
Started to flesh out mineral_association function
jacobcook1995 Dec 13, 2022
fbd56a5
Wrote function for soil moisture scalar
jacobcook1995 Dec 14, 2022
02333af
Wrote function for soil temperature scalar
jacobcook1995 Dec 14, 2022
df6aab4
Added unit annotations
jacobcook1995 Dec 14, 2022
86e1929
Defined a time step for the function update
jacobcook1995 Dec 14, 2022
1a82ba1
Added error handling for bad soil carbon pool initialisation
jacobcook1995 Dec 15, 2022
ae2efd7
Merge branch 'develop' into feature/dummy_carbon
jacobcook1995 Dec 15, 2022
6cc5f3c
Added tests for scalar calculating functions
jacobcook1995 Dec 15, 2022
07b94fd
Added test for mineral association function
jacobcook1995 Dec 15, 2022
713f0af
Added test for update pools function
jacobcook1995 Dec 15, 2022
088e735
Changed qa python version to see if that fixes the 'Expected — Waitin…
jacobcook1995 Dec 15, 2022
3e0625a
Moved plant and soil module folders into new models folder
jacobcook1995 Dec 16, 2022
32fdf62
Updated test paths to match new models/soil/ directory structure
jacobcook1995 Dec 16, 2022
e0c1a2c
Switch to using np.exp and np.pi
jacobcook1995 Dec 16, 2022
7fcbbc1
Converted comments to docstrings
jacobcook1995 Dec 16, 2022
a1a0f57
Switched to returning single flux
jacobcook1995 Dec 16, 2022
5704337
Improved naming of scalar generating functions
jacobcook1995 Dec 16, 2022
9907355
Switched to using float for pool update time step
jacobcook1995 Dec 16, 2022
c2ec463
Started using np.allclose
jacobcook1995 Jan 3, 2023
ceedb05
Removed repeated dictionary accesses for temperature scalar
jacobcook1995 Jan 3, 2023
988b325
Switched to using np.any
jacobcook1995 Jan 3, 2023
ae8af43
Switched to defining attributes as docstrings rather than init docstring
jacobcook1995 Jan 9, 2023
bf18cc4
Added multiple tests for soil moisture and temp scalars
jacobcook1995 Jan 16, 2023
b0c048b
Made smaller functions for mineral association calculations
jacobcook1995 Jan 17, 2023
0cbd846
Nested binding affinity calculation
jacobcook1995 Jan 17, 2023
a8df83e
Added test for Langmuir binding coef function
jacobcook1995 Jan 17, 2023
a049f08
Added test function for Max soprtion capacity
jacobcook1995 Jan 17, 2023
820745b
Added test for equilibrium maom function
jacobcook1995 Jan 17, 2023
f84159c
Improved unit test for mineral association
jacobcook1995 Jan 17, 2023
d9e2a17
Paramaterised update_pools test
jacobcook1995 Jan 18, 2023
6b0621a
Adjusted api tree to reflect new models dir structure
jacobcook1995 Jan 18, 2023
f8bc6b0
Added details of units
jacobcook1995 Jan 18, 2023
a103e88
Added sanity checks for obviously bad but hard to spot input
jacobcook1995 Jan 18, 2023
01fdd56
Fixed minor issue with using any rather than np.any
jacobcook1995 Jan 18, 2023
9cf48f2
Switched to new api docs structure
jacobcook1995 Jan 18, 2023
e934c0a
Merge branch 'develop' into feature/dummy_carbon
jacobcook1995 Jan 24, 2023
21d5e0d
Caught pinned python version I previously missed
jacobcook1995 Jan 24, 2023
a848ff0
Merging changes from develop in and updating docs index to match new …
jacobcook1995 Jan 30, 2023
5026a22
Improved docstring style for soil.carbon module
jacobcook1995 Feb 2, 2023
1e1b759
Removed deprecated log_and_raise function from the soil.carbon module
jacobcook1995 Feb 2, 2023
4a82512
Removed unnecessary mocking from test_calculate_equilibrium_maom
jacobcook1995 Feb 2, 2023
040a1f7
Removed unneeded mocking from test_mineral_association
jacobcook1995 Feb 2, 2023
056dbdd
Removed unnecessary mocking from test_update_pools
jacobcook1995 Feb 3, 2023
7009b60
Merged in changes from develop
jacobcook1995 Feb 3, 2023
8d985d2
Fixed various problems with the docs
jacobcook1995 Feb 3, 2023
8c4d2d8
Converted dictonaries of fitting parameters to dataclasses
jacobcook1995 Feb 3, 2023
27f7498
Merge branch 'develop' into feature/dummy_carbon
jacobcook1995 Feb 3, 2023
4867277
Moved data classes to seperate constants.py script
jacobcook1995 Feb 6, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "3.9"
python-version: "3.9.16"
- uses: pre-commit/[email protected]

test:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
select_models,
vr_run,
)
from virtual_rainforest.soil.model import SoilModel
from virtual_rainforest.models.soil.model import SoilModel

from .conftest import log_check

Expand Down
2 changes: 1 addition & 1 deletion tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from numpy import datetime64, timedelta64

from virtual_rainforest.core.model import BaseModel, InitialisationError
from virtual_rainforest.soil.model import SoilModel
from virtual_rainforest.models.soil.model import SoilModel

from .conftest import log_check

Expand Down
137 changes: 137 additions & 0 deletions tests/test_soil_carbon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Test module for soil.carbon.py.

This module tests the functionality of the soil carbon module
"""

from contextlib import nullcontext as does_not_raise
from logging import CRITICAL
from math import isclose

import numpy as np
import pytest

from virtual_rainforest.core.model import InitialisationError
from virtual_rainforest.models.soil.carbon import (
SoilCarbonPools,
convert_moisture_to_scalar,
convert_temperature_to_scalar,
)
jacobcook1995 marked this conversation as resolved.
Show resolved Hide resolved

from .conftest import log_check


@pytest.mark.parametrize(
"maom,lmwc,raises,expected_log_entries",
[
(
np.array([23.0, 12.0], dtype=np.float32),
np.array([98.0, 7.0], dtype=np.float32),
does_not_raise(),
(),
),
(
np.array([23.0, 12.0], dtype=np.float32),
np.array([98.0], dtype=np.float32),
pytest.raises(InitialisationError),
(
(
CRITICAL,
"Dimension mismatch for initial carbon pools!",
),
),
),
(
np.array([23.0, 12.0], dtype=np.float32),
np.array([98.0, -24.0], dtype=np.float32),
pytest.raises(InitialisationError),
(
(
CRITICAL,
"Initial carbon pools contain at least one negative value!",
),
),
),
],
)
def test_soil_carbon_class(caplog, maom, lmwc, raises, expected_log_entries):
"""Test SoilCarbon class initialisation."""

# Check that initialisation fails (or doesn't) as expected
with raises:
soil_carbon = SoilCarbonPools(maom, lmwc)

assert (soil_carbon.maom == maom).all()
assert (soil_carbon.lmwc == lmwc).all()

log_check(caplog, expected_log_entries)


def test_update_pools():
"""Test that update_pools runs and generates the correct values."""

# Initialise soil carbon class
maom = np.array([23.0, 23.0], dtype=np.float32)
lmwc = np.array([98.0, 55.0], dtype=np.float32)
soil_carbon = SoilCarbonPools(maom, lmwc)
jacobcook1995 marked this conversation as resolved.
Show resolved Hide resolved

# Define all the required variables to run function
pH = np.array([7.0, 7.0], dtype=np.float32)
bulk_density = np.array([1350, 1350], dtype=np.float32)
percent_clay = np.array([50.0, 50.0], dtype=np.float32)
soil_moisture = np.array([0.5, 0.5], dtype=np.float32)
soil_temp = np.array([35.0, 35.0], dtype=np.float32)
dt = 2.0 / 24.0

soil_carbon.update_pools(
pH, bulk_density, soil_moisture, soil_temp, percent_clay, dt
)

# Check that pools are correctly incremented
assert isclose(soil_carbon.maom[0].item(), 28.82632255)
assert isclose(soil_carbon.lmwc[0].item(), 92.17367553)
assert isclose(soil_carbon.maom[1].item(), 25.73223495)
assert isclose(soil_carbon.lmwc[1].item(), 52.26776504)
jacobcook1995 marked this conversation as resolved.
Show resolved Hide resolved


def test_mineral_association():
"""Test that mineral_association runs and generates the correct values."""

# Initialise soil carbon class
maom = np.array([23.0, 23.0], dtype=np.float32)
lmwc = np.array([98.0, 55.0], dtype=np.float32)
soil_carbon = SoilCarbonPools(maom, lmwc)

# Define all the required variables to run function
pH = np.array([7.0, 7.0], dtype=np.float32)
bulk_density = np.array([1350, 1350], dtype=np.float32)
percent_clay = np.array([50.0, 50.0], dtype=np.float32)
soil_moisture = np.array([0.5, 0.5], dtype=np.float32)
soil_temp = np.array([35.0, 35.0], dtype=np.float32)

lmwc_to_maom = soil_carbon.mineral_association(
pH, bulk_density, soil_moisture, soil_temp, percent_clay
)

# Check that expected values are generated
assert isclose(lmwc_to_maom[0].item(), 69.9158630)
assert isclose(lmwc_to_maom[1].item(), 32.78682708)


def test_convert_temperature_to_scalar():
"""Test that scalar_temperature runs and generates the correct value."""

soil_temperature = np.array([35.0, 37.5], dtype=np.float32)
temp_scalar = convert_temperature_to_scalar(soil_temperature)

assert isclose(temp_scalar[0].item(), 1.271131634)
assert isclose(temp_scalar[1].item(), 1.271966338)


def test_convert_moisture_to_scalar():
"""Test that scalar_moisture runs and generates the correct value."""

soil_moisture = np.array([0.5, 0.7], dtype=np.float32)
moist_scalar = convert_moisture_to_scalar(soil_moisture)

assert isclose(moist_scalar[0].item(), 0.750035703)
assert isclose(moist_scalar[1].item(), 0.947787225)
6 changes: 3 additions & 3 deletions virtual_rainforest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

# Import all module schema here to ensure that they are added to the registry
from virtual_rainforest.core import schema # noqa
from virtual_rainforest.plants import schema # noqa
from virtual_rainforest.soil import schema # noqa
from virtual_rainforest.models.plants import schema # noqa
from virtual_rainforest.models.soil import schema # noqa

# Import models here so that they also end up in the registry
from virtual_rainforest.soil.model import SoilModel # noqa
from virtual_rainforest.models.soil.model import SoilModel # noqa

__version__ = importlib.metadata.version("virtual_rainforest")
203 changes: 203 additions & 0 deletions virtual_rainforest/models/soil/carbon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""The `soil.carbon` module.

This module simulates the radiation soil carbon cycle for the Virtual Rainforest. At the
jacobcook1995 marked this conversation as resolved.
Show resolved Hide resolved
moment only two pools are modelled, these are low molecular weight carbon (LMWC) and
mineral associated organic matter (MAOM). More pools and their interactions will be
added at a later date.
"""

import numpy as np
from numpy.typing import NDArray

from virtual_rainforest.core.logger import log_and_raise
from virtual_rainforest.core.model import InitialisationError

# from core.constants import CONSTANTS as C
# but for meanwhile define all the constants needed here
BINDING_WITH_PH = {
"slope": -0.186, # unitless
"intercept": -0.216, # unitless
}
"""From linear regression (Mayes et al. (2012))."""
MAX_SORPTION_WITH_CLAY = {
"slope": 0.483, # unitless
"intercept": 2.328, # unitless
}
"""From linear regression (Mayes et al. (2012))."""
MOISTURE_SCALAR = {
"coefficient": 30.0, # unitless
"exponent": 9.0, # unitless
}
"""Used in Abramoff et al. (2018), but can't trace it back to anything more concrete."""
TEMP_SCALAR = {
"t_1": 15.4, # C
"t_2": 11.75, # unclear
"t_3": 29.7, # unclear
"t_4": 0.031, # unclear
"ref_temp": 30.0, # C
}
"""Used in Abramoff et al. (2018), but can't trace it back to anything more concrete."""


class SoilCarbonPools:
"""Class containing the full set of soil carbon pools.

At the moment, only two pools are included. Functions exist for the transfer of
carbon between these pools, but not with either the yet to be implemented soil
carbon pools, other pools in the soil module, or other modules.

Attributes:
maom: Mineral associated organic matter pool
lmwc: Low molecular weight carbon pool
"""

def __init__(self, maom: NDArray[np.float32], lmwc: NDArray[np.float32]) -> None:
"""Initialise set of carbon pools."""

# Check that arrays are of equal size and shape
if maom.shape != lmwc.shape:
log_and_raise(
"Dimension mismatch for initial carbon pools!",
InitialisationError,
)

# Check that negative initial values are not given
if any(i < 0 for i in maom) or any(i < 0 for i in lmwc):
jacobcook1995 marked this conversation as resolved.
Show resolved Hide resolved
log_and_raise(
"Initial carbon pools contain at least one negative value!",
InitialisationError,
)

self.maom = maom
self.lmwc = lmwc

def update_pools(
self,
pH: NDArray[np.float32],
bulk_density: NDArray[np.float32],
soil_moisture: NDArray[np.float32],
soil_temp: NDArray[np.float32],
percent_clay: NDArray[np.float32],
dt: float,
) -> None:
"""Update all soil carbon pools.

This function calls lower level functions which calculate the transfers between
pools. When all transfers have been calculated the net transfer is used to
update the soil pools.

Args:
pH: pH values for each soil grid cell
bulk_density: bulk density values for each soil grid cell
soil_moisture: soil moisture for each soil grid cell
soil_temp: soil temperature for each soil grid cell
percent_clay: Percentage clay for each soil grid cell
dt: time step (days)
"""
# TODO - Add interactions which involve the three missing carbon pools

lmwc_to_maom = self.mineral_association(
pH, bulk_density, soil_moisture, soil_temp, percent_clay
)

# Once changes are determined update all pools
self.lmwc -= lmwc_to_maom * dt
self.maom += lmwc_to_maom * dt

def mineral_association(
self,
pH: NDArray[np.float32],
bulk_density: NDArray[np.float32],
soil_moisture: NDArray[np.float32],
soil_temp: NDArray[np.float32],
percent_clay: NDArray[np.float32],
) -> NDArray[np.float32]:
"""Calculates net rate of LMWC association with soil minerals.

Following Abramoff et al. (2018), mineral adsorption of carbon is controlled by
a Langmuir saturation function. At present, binding affinity and Q_max are
recalculated on every function called based on pH, bulk density and clay
content. Once a decision has been reached as to how fast pH and bulk density
will change (if at all), this calculation may need to be moved elsewhere.

Args:
pH: pH values for each soil grid cell
bulk_density: bulk density values for each soil grid cell
soil_moisture: soil moisture for each soil grid cell
soil_temp: soil temperature for each soil grid cell
percent_clay: Percentage clay for each soil grid cell

Returns:
lmwc_to_maom: The net flux from LMWC to MAOM
"""

# This expression is drawn from (Mayes et al. (2012))
binding_affinity = 10.0 ** (
BINDING_WITH_PH["slope"] * pH + BINDING_WITH_PH["intercept"]
)
jacobcook1995 marked this conversation as resolved.
Show resolved Hide resolved

# This expression is also drawn from Mayes et al. (2012)
# Original paper also depends on Fe concentration, but we are ignoring this for
# now
Q_max = bulk_density * 10 ** (
MAX_SORPTION_WITH_CLAY["slope"] * np.log10(percent_clay)
+ MAX_SORPTION_WITH_CLAY["intercept"]
jacobcook1995 marked this conversation as resolved.
Show resolved Hide resolved
)

# Using the above calculate the equilibrium MAOM pool
equib_maom = (binding_affinity * Q_max * self.lmwc) / (
1 + self.lmwc * binding_affinity
)

# Find scalar factors that multiple rates
temp_scalar = convert_temperature_to_scalar(soil_temp)
moist_scalar = convert_moisture_to_scalar(soil_moisture)

return temp_scalar * moist_scalar * self.lmwc * (equib_maom - self.maom) / Q_max


def convert_temperature_to_scalar(
soil_temp: NDArray[np.float32],
) -> NDArray[np.float32]:
"""Convert soil temperature into a factor to multiply rates by.

This form is used in Abramoff et al. (2018) to minimise differences with the
CENTURY model. We very likely want to define our own functional form here. I'm
also a bit unsure how this form was even obtained, so further work here is very
needed.

Args:
soil_temp: soil temperature for each soil grid cell
"""

# This expression is drawn from Abramoff et al. (2018)
numerator = TEMP_SCALAR["t_2"] + (TEMP_SCALAR["t_3"] / np.pi) * np.arctan(
np.pi * (soil_temp - TEMP_SCALAR["t_1"])
)
denominator = TEMP_SCALAR["t_2"] + (TEMP_SCALAR["t_3"] / np.pi) * np.arctan(
np.pi * TEMP_SCALAR["t_4"] * (TEMP_SCALAR["ref_temp"] - TEMP_SCALAR["t_1"])
)
jacobcook1995 marked this conversation as resolved.
Show resolved Hide resolved

return np.divide(numerator, denominator)


def convert_moisture_to_scalar(
soil_moisture: NDArray[np.float32],
) -> NDArray[np.float32]:
"""Convert soil moisture into a factor to multiply rates by.

This form is used in Abramoff et al. (2018) to minimise differences with the
CENTURY model. We very likely want to define our own functional form here. I'm
also a bit unsure how this form was even obtained, so further work here is very
needed.

Args:
soil_moisture: soil moisture for each soil grid cell
"""

# This expression is drawn from Abramoff et al. (2018)
return 1 / (
1
+ MOISTURE_SCALAR["coefficient"]
* np.exp(-MOISTURE_SCALAR["exponent"] * soil_moisture)
)