"""The :mod:`~virtual_ecosystem.models.animal.decay` module contains
pools which are still potentially forageable by animals but are in the process of
microbial decomposition. This includes excrement and carcasses that are tracked solely
in the animal module. This also includes plant litter which is mainly tracked in the
`litter` module, but is made available for animal consumption.
""" # noqa: D205
from dataclasses import dataclass, field
from math import exp
from virtual_ecosystem.core.data import Data
from virtual_ecosystem.core.logger import LOGGER
from virtual_ecosystem.models.animal.animal_traits import VerticalOccupancy
from virtual_ecosystem.models.animal.cnp import CNP
from virtual_ecosystem.models.animal.protocols import Consumer, ScavengeableResource
[docs]
class ScavengeableMixin:
"""Mixin for nutrient pools that can be scavenged by animal cohorts."""
[docs]
def get_eaten(
self: "ScavengeableResource",
consumed_mass: float,
scavenger: "Consumer",
) -> tuple[dict[str, float], dict[str, float]]:
"""Remove biomass from the scavengeable pool and return stoichiometric gain.
Args:
consumed_mass: Wet-mass the scavenger tries to eat [kg].
scavenger: The animal cohort consuming the material.
Returns:
Dict with keys ``"C"``, ``"N"``, ``"P"`` giving
the mass of each element actually ingested, and a second empty dict.
Raises:
ValueError: If ``consumed_mass`` is negative.
"""
if consumed_mass < 0:
raise ValueError("consumed_mass cannot be negative.")
available = self.scavengeable_cnp.total
if available == 0.0:
return {"C": 0.0, "N": 0.0, "P": 0.0}, {}
taken_wet = min(consumed_mass, available)
mech_eff = scavenger.functional_group.mechanical_efficiency
ingested_wet = taken_wet * mech_eff
missed_wet = taken_wet * (1.0 - mech_eff)
frac_C = self.scavengeable_cnp.C / available
frac_N = self.scavengeable_cnp.N / available
frac_P = self.scavengeable_cnp.P / available
ingested_cnp = {
"C": ingested_wet * frac_C,
"N": ingested_wet * frac_N,
"P": ingested_wet * frac_P,
}
# Update pool states
self.scavengeable_cnp.update(
C=-taken_wet * frac_C,
N=-taken_wet * frac_N,
P=-taken_wet * frac_P,
)
self.decomposed_cnp.update(
C=missed_wet * frac_C,
N=missed_wet * frac_N,
P=missed_wet * frac_P,
)
return ingested_cnp, {}
[docs]
@dataclass
class CarcassPool(ScavengeableMixin):
"""This class stores information about the carcass biomass in each grid cell."""
scavengeable_cnp: CNP = field(default_factory=lambda: CNP(C=0.0, N=0.0, P=0.0))
"""A CNP object storing animal-accessible nutrients in the carcass pool."""
decomposed_cnp: CNP = field(default_factory=lambda: CNP(C=0.0, N=0.0, P=0.0))
"""A CNP object storing decomposed nutrients in the carcass pool."""
cell_id: int = -1
"""Grid position of carcass pool."""
vertical_occupancy: VerticalOccupancy = VerticalOccupancy.GROUND
"""Vertical position of carcass pool."""
@property
def mass_current(self) -> float:
"""Total scavengeable carcass mass (kg)."""
return self.scavengeable_cnp.total
[docs]
def decomposed_nutrient_per_area(
self, nutrient: str, grid_cell_area: float
) -> float:
"""Convert decomposed carcass nutrient content to mass per area units.
Args:
nutrient (str): The name of the nutrient to calculate for.
grid_cell_area (float): The size of the grid cell [m^2].
Raises:
ValueError: If a nutrient other than carbon, nitrogen, or phosphorus is
chosen.
Returns:
float: The nutrient content of the decomposed carcasses on a per area basis
[kg m^-2].
"""
if nutrient not in {"C", "N", "P"}:
raise ValueError(
f"{nutrient} is not a valid nutrient. Valid options: 'C', 'N', or 'P'."
)
return getattr(self.decomposed_cnp, nutrient) / grid_cell_area
[docs]
def add_carcass(self, C: float, N: float, P: float) -> None:
"""Add carcass mass to the pool based on the provided mass.
Args:
C (float): The mass of carbon to add.
N (float): The mass of nitrogen to add.
P (float): The mass of phosphorus to add.
Raises:
ValueError: If any input mass is negative.
"""
if C < 0 or N < 0 or P < 0:
raise ValueError(
f"CNP values must be non-negative. Provided values: C={C}, N={N}, P={P}"
)
self.scavengeable_cnp.update(C=C, N=N, P=P)
[docs]
def reset(self) -> None:
"""Reset tracking of the nutrients associated with decomposed carcasses.
This function sets the decomposed carbon, nitrogen, and phosphorus to zero.
It should only be called after transfers to the soil model due to decomposition
have been calculated.
"""
self.decomposed_cnp = CNP(0.0, 0.0, 0.0)
[docs]
@dataclass
class ExcrementPool(ScavengeableMixin):
"""This class stores information about the amount of excrement in each grid cell."""
scavengeable_cnp: CNP = field(default_factory=lambda: CNP(C=0.0, N=0.0, P=0.0))
"""A CNP object storing animal-accessible nutrients in the excrement pool."""
decomposed_cnp: CNP = field(default_factory=lambda: CNP(C=0.0, N=0.0, P=0.0))
"""A CNP object storing decomposed nutrients in the excrement pool."""
cell_id: int = -1
"""Grid position of carcass pool."""
vertical_occupancy: VerticalOccupancy = VerticalOccupancy.GROUND
"""Vertical position of carcass pool."""
@property
def mass_current(self) -> float:
"""Total scavengeable excrement mass (kg)."""
return self.scavengeable_cnp.total
[docs]
def decomposed_nutrient_per_area(
self, nutrient: str, grid_cell_area: float
) -> float:
"""Convert decomposed excrement nutrient content to mass per area units.
Args:
nutrient (str): The name of the nutrient to calculate for.
grid_cell_area (float): The size of the grid cell [m^2].
Raises:
ValueError: If a nutrient other than carbon, nitrogen, or phosphorus is
chosen.
Returns:
float: The nutrient content of the decomposed excrement on a per area basis
[kg m^-2].
"""
if nutrient not in {"C", "N", "P"}:
raise ValueError(
f"{nutrient} is not a valid nutrient. Valid options: 'C','N', or 'P'."
)
return getattr(self.decomposed_cnp, nutrient) / grid_cell_area
[docs]
def add_excrement(self, C: float, N: float, P: float) -> None:
"""Add excrement mass to the pool based on the provided input mass.
Args:
C (float): The mass of carbon to add.
N (float): The mass of nitrogen to add.
P (float): The mass of phosphorus to add.
Raises:
ValueError: If any input mass is negative.
"""
if C < 0 or N < 0 or P < 0:
raise ValueError(
f"CNP values must be non-negative. Provided values: C={C}, N={N}, P={P}"
)
self.scavengeable_cnp.update(C=C, N=N, P=P)
[docs]
def reset(self) -> None:
"""Reset tracking of the nutrients associated with decomposed excrement.
This function sets the decomposed carbon, nitrogen, and phosphorus to zero.
It should only be called after transfers to the soil model due to decomposition
have been calculated.
"""
self.decomposed_cnp = CNP(C=0.0, N=0.0, P=0.0)
[docs]
def find_decay_consumed_split(
microbial_decay_rate: float, animal_scavenging_rate: float
):
"""Find fraction of biomass that is assumed to decay rather than being scavenged.
This should be calculated separately for each relevant biomass type (excrement and
carcasses). This function should could be replaced in future by something that
incorporates more of the factors determining this split (e.g. temperature).
Args:
microbial_decay_rate: Rate at which biomass type decays due to microbes [day^-1]
animal_scavenging_rate: Rate at which biomass type is scavenged due to animals
[day^-1]
"""
return microbial_decay_rate / (animal_scavenging_rate + microbial_decay_rate)
[docs]
class FungalFruitPool:
"""A class to track the mass of fungal fruiting bodies in each grid cell.
TODO - A proper explanation as I add stuff
"""
def __init__(
self,
cell_id: int,
data: "Data",
cell_area: float,
c_n_ratio: float,
c_p_ratio: float,
) -> None:
self.cell_id = cell_id
self.cell_area = cell_area
carbon_stock = (
data["fungal_fruiting_bodies"].sel(cell_id=cell_id).item()
) # kg C m⁻²
self.c_n_ratio = c_n_ratio
self.c_p_ratio = c_p_ratio
if min(self.c_n_ratio, self.c_p_ratio) <= 0:
raise ValueError(
f"Fungal fruiting bodies: non-positive C:N or C:P ratio in cell "
f"{cell_id}."
)
# Convert to absolute mass (kg) and build stoichiometry
carbon_mass = carbon_stock * cell_area
self.mass_cnp = CNP(
C=carbon_mass,
N=carbon_mass / self.c_n_ratio,
P=carbon_mass / self.c_p_ratio,
)
# Sanity-check
if self.mass_cnp.total < 0:
raise ValueError(
f"Fungal fruiting bodies: negative mass detected in cell {cell_id} "
f"({self.mass_cnp})."
)
vertical_occupancy: VerticalOccupancy = VerticalOccupancy.GROUND
"""Vertical position of fungal fruiting pool."""
@property
def mass_current(self) -> float:
"""Return current carbon mass in the pool [kg]."""
return self.mass_cnp.C
[docs]
def get_eaten(
self,
consumed_mass: float,
detritivore: "Consumer",
) -> tuple[dict[str, float], dict[str, float]]:
"""Remove biomass when a cohort consumes fungal fruiting bodies.
Args:
consumed_mass: Target wet-mass to consume **after** mechanical efficiency is
applied (kg). Any attempt to over-consume is automatically capped.
detritivore: The cohort that is feeding used only to obtain mechanical
efficiency.
Returns:
Dictionary of element masses actually assimilated, keys ``C``,
``N``, ``P`` (kg).
"""
if consumed_mass < 0:
raise ValueError("consumed_mass must be non-negative")
total_available = self.mass_cnp.total
mech_eff = detritivore.functional_group.mechanical_efficiency
actual = min(consumed_mass, total_available) * mech_eff
frac_C = self.mass_cnp.C / total_available
frac_N = self.mass_cnp.N / total_available
frac_P = self.mass_cnp.P / total_available
taken = {
"C": actual * frac_C,
"N": actual * frac_N,
"P": actual * frac_P,
}
# in-place update
self.mass_cnp.update(
C=-taken["C"],
N=-taken["N"],
P=-taken["P"],
)
return taken, {}
[docs]
def apply_decay(self, decay_constant: float, time_period: float) -> float:
"""Apply exponential decay to the fungal fruiting bodies pool.
Args:
decay_constant: The rate constant for fungal fruiting body decay [day^-1].
time_period: The time period over which decay occurs [day].
Returns:
The total amount of fungal fruiting bodies that decayed in this specific
grid cell (in carbon terms) [kg]
"""
# Calculate total decay in carbon terms
total_decay = (1 - exp(-decay_constant * time_period)) * self.mass_cnp.C
# And then update the pool masses based on this and the fixed stoichiometry
self.mass_cnp.update(
C=-total_decay,
N=-total_decay / self.c_n_ratio,
P=-total_decay / self.c_p_ratio,
)
return total_decay
[docs]
class SoilPool:
"""Interface between litter model variables in ``Data`` and the animal module.
One :class:`SoilPool` instance now represents **one soil pool type *in one grid
cell***.
"""
vertical_occupancy: VerticalOccupancy = VerticalOccupancy.SOIL
"""Vertical position of soil pool."""
def __init__(
self,
pool_name: str,
cell_id: int,
data: "Data",
cell_area: float,
max_depth_microbial_activity: float,
c_n_p_ratios: dict[str, dict[str, float]],
) -> None:
accepted_names = ["pom", "bacteria", "fungi"]
if pool_name not in accepted_names:
err = ValueError(
f"Invalid soil pool name provided ({pool_name}), pools available for "
f"animal consumption are: {accepted_names}"
)
LOGGER.critical(err)
raise err
self.pool_name = pool_name
self.cell_id = cell_id
self.cell_area = cell_area
if pool_name == "pom":
self.mass_cnp = self._extract_pom_cnp_mass(
data=data,
biotic_activity_depth=max_depth_microbial_activity,
)
elif pool_name == "bacteria":
self.mass_cnp = self._extract_bacteria_cnp_mass(
data=data,
biotic_activity_depth=max_depth_microbial_activity,
c_n_p_ratios_bacteria=c_n_p_ratios["bacteria"],
)
else:
self.mass_cnp = self._extract_fungi_cnp_mass(
data=data,
biotic_activity_depth=max_depth_microbial_activity,
c_n_p_ratios=c_n_p_ratios,
)
# Sanity-check
if self.mass_cnp.total < 0:
raise ValueError(
f"{pool_name}: negative mass detected in cell {cell_id} "
f"({self.mass_cnp})."
)
def _extract_pom_cnp_mass(self, data: Data, biotic_activity_depth: float):
"""Extract the CNP masses of the :term`POM` soil pool.
Args:
data: The Virtual Ecosystem data object
biotic_activity_depth: The soil depth at which biotic activity is assumed to
halt [m]
"""
carbon_stock = (
data["soil_cnp_pool_pom"].sel(cell_id=self.cell_id, element="C").item()
)
nitrogen_stock = (
data["soil_cnp_pool_pom"].sel(cell_id=self.cell_id, element="N").item()
)
phosphorus_stock = (
data["soil_cnp_pool_pom"].sel(cell_id=self.cell_id, element="P").item()
)
# Convert stocks (kg m^-3) into masses by multiplying by grid square area and by
# soil active depth
carbon_mass = carbon_stock * self.cell_area * biotic_activity_depth
nitrogen_mass = nitrogen_stock * self.cell_area * biotic_activity_depth
phosphorus_mass = phosphorus_stock * self.cell_area * biotic_activity_depth
return CNP(C=carbon_mass, N=nitrogen_mass, P=phosphorus_mass)
def _extract_bacteria_cnp_mass(
self,
data: Data,
biotic_activity_depth: float,
c_n_p_ratios_bacteria: dict[str, float],
):
"""Extract the CNP masses of the soil bacteria pool.
Args:
data: The Virtual Ecosystem data object
biotic_activity_depth: The soil depth at which biotic activity is assumed to
halt [m]
c_n_p_ratios_bacteria: Carbon to nitrogen and carbon to phosphorus ratios
for bacterial biomass [unitless]
"""
carbon_stock = (
data["soil_c_pool_bacteria"]
.sel(cell_id=self.cell_id)
.where(lambda x: x >= 0)
.fillna(0)
.item()
)
# Convert stock (kg m^-3) into mass by multiplying by grid square area and by
# soil active depth
carbon_mass = carbon_stock * self.cell_area * biotic_activity_depth
nitrogen_mass = carbon_mass / c_n_p_ratios_bacteria["N"]
phosphorus_mass = carbon_mass / c_n_p_ratios_bacteria["P"]
return CNP(C=carbon_mass, N=nitrogen_mass, P=phosphorus_mass)
def _extract_fungi_cnp_mass(
self,
data: Data,
biotic_activity_depth: float,
c_n_p_ratios: dict[str, dict[str, float]],
):
"""Extract the CNP masses of the soil fungi pools.
Animals are assumed to just generically eat soil fungi rather than being able to
choose a specific fungal functional group to eat. This means that the biomass
for all three groups is combined into one.
It's possible for the soil model to produce slightly negative mycorrhizal fungal
abundances, when that happens this will be treated as zero abundance, to prevent
the possibility of a negative rate of animal consumption.
Args:
data: The Virtual Ecosystem data object
biotic_activity_depth: The soil depth at which biotic activity is assumed to
halt [m]
c_n_p_ratios: Carbon to nitrogen and carbon to phosphorus ratios for soil
microbial pools [unitless]
"""
saprotrophic_stock = (
data["soil_c_pool_saprotrophic_fungi"]
.sel(cell_id=self.cell_id)
.where(lambda x: x >= 0)
.fillna(0)
.item()
)
arbuscular_mycorrhizal_stock = (
data["soil_c_pool_arbuscular_mycorrhiza"]
.sel(cell_id=self.cell_id)
.where(lambda x: x >= 0)
.fillna(0)
.item()
)
ectomycorrhizal_stock = (
data["soil_c_pool_ectomycorrhiza"]
.sel(cell_id=self.cell_id)
.where(lambda x: x >= 0)
.fillna(0)
.item()
)
# Individual stock sizes now used to find total stock and the overall C:N and
# C:P ratios of this total stock
carbon_stock = (
saprotrophic_stock + arbuscular_mycorrhizal_stock + ectomycorrhizal_stock
)
nitrogen_stock = (
(saprotrophic_stock / c_n_p_ratios["saprotrophic_fungi"]["N"])
+ (
arbuscular_mycorrhizal_stock
/ c_n_p_ratios["arbuscular_mycorrhiza"]["N"]
)
+ (ectomycorrhizal_stock / c_n_p_ratios["ectomycorrhiza"]["N"])
)
phosphorus_stock = (
(saprotrophic_stock / c_n_p_ratios["saprotrophic_fungi"]["P"])
+ (
arbuscular_mycorrhizal_stock
/ c_n_p_ratios["arbuscular_mycorrhiza"]["P"]
)
+ (ectomycorrhizal_stock / c_n_p_ratios["ectomycorrhiza"]["P"])
)
# Convert stock (kg m^-3) into mass by multiplying by grid square area and by
# soil active depth
carbon_mass = carbon_stock * self.cell_area * biotic_activity_depth
nitrogen_mass = nitrogen_stock * self.cell_area * biotic_activity_depth
phosphorus_mass = phosphorus_stock * self.cell_area * biotic_activity_depth
return CNP(C=carbon_mass, N=nitrogen_mass, P=phosphorus_mass)
@property
def mass_current(self) -> float:
"""Return current carbon mass in the pool [kg]."""
return self.mass_cnp.C
[docs]
def get_eaten(
self,
consumed_mass: float,
detritivore: "Consumer",
) -> tuple[dict[str, float], dict[str, float]]:
"""Remove biomass when a cohort consumes this soil pool.
In contrast to the LitterPool case, for soil pools mechanical efficiency is
assumed to be 100% so does not factor into this calculation.
Args:
consumed_mass: Target wet-mass to consume (kg). Any attempt to over-consume
is automatically capped.
detritivore: The cohort that is feeding, this is only needed to maintain
same function signature as SoilPool case
Returns:
Dictionary of element masses actually assimilated, keys ``C``,
``N``, ``P`` (kg).
"""
if consumed_mass < 0:
raise ValueError("consumed_mass must be non-negative")
total_available = self.mass_cnp.total
mech_eff = detritivore.functional_group.mechanical_efficiency
actual = min(consumed_mass, total_available) * mech_eff
frac_C = self.mass_cnp.C / total_available
frac_N = self.mass_cnp.N / total_available
frac_P = self.mass_cnp.P / total_available
taken = {
"C": actual * frac_C,
"N": actual * frac_N,
"P": actual * frac_P,
}
# in-place update
self.mass_cnp.update(
C=-taken["C"],
N=-taken["N"],
P=-taken["P"],
)
return taken, {}
[docs]
class HerbivoryWaste:
"""A class to track the amount of waste generated by each form of herbivory.
This is used as a temporary storage location before the wastes are added to the
litter model. As such it is not made available for animal consumption.
The litter model splits its plant matter into four classes: wood, leaves, roots, and
reproductive tissues (fruits and flowers). A separate instance of this class should
be used for each of these groups.
Args:
pool_name: Type of plant matter this waste pool contains.
Raises:
ValueError: If initialised for a plant matter type that the litter model doesn't
accept.
"""
def __init__(self, plant_matter_type: str) -> None:
# Check that this isn't being initialised for a plant matter type that the
# litter model doesn't use
accepted_plant_matter_types = [
"leaf",
"root",
"deadwood",
"reproductive_tissue",
]
if plant_matter_type not in accepted_plant_matter_types:
to_raise = ValueError(
f"{plant_matter_type} not a valid form of herbivory waste, valid forms "
f"are as follows: {accepted_plant_matter_types}"
)
LOGGER.error(to_raise)
raise to_raise
self.plant_matter_type = plant_matter_type
"""Type of plant matter this waste pool contains."""
self.mass_cnp: dict[str, float] = {
"C": 0.0,
"N": 0.0,
"P": 0.0,
}
"""The mass of each stoichiometric element found in the plant resources,
{"C": value, "N": value, "P": value}."""
self.lignin_proportion = 0.25
"""Proportion of the herbivory waste pool carbon that is lignin [unitless]."""
[docs]
def add_waste(self, input_mass_cnp: dict[str, float]) -> None:
"""Add waste to the pool based on the provided stoichiometric mass.
Args:
input_mass_cnp: Dictionary specifying the mass of each element in the waste
{"C": value, "N": value, "P": value}.
Raises:
ValueError: If the input dictionary is missing required elements or contains
negative values.
"""
# Validate input structure and content
required_keys = {"C", "N", "P"}
if not required_keys.issubset(input_mass_cnp.keys()):
raise ValueError(
f"mass_cnp must contain all required keys {required_keys}. "
f"Provided keys: {input_mass_cnp.keys()}"
)
if any(value < 0 for value in input_mass_cnp.values()):
raise ValueError(
f"CNP values must be non-negative. Provided values: {input_mass_cnp}"
)
# Add the masses to the current pool
for element, value in input_mass_cnp.items():
self.mass_cnp[element] += value