Source code for virtual_ecosystem.models.plants.subcanopy

"""The subcanopy module provides a representation of subcanopy biomass as two pools. The
first is a pool of subcanopy vegetation, implemented as layer of pure leaf tissue in the
surface layer of the model vertical structure. The second is a pool of subcanopy
seedbank biomass.

Both pools use a simplified stiochiometric system: this is defined independently of the
:mod:`virtual_ecosystem.models.plants.biomasses` module, as that class explicitly
handles communities of cohorts with multiple tissue types. The subcanopy has much
simpler structure with two stoichiometric masses per grid cell and so the dynamics are
more easily handled by a separate implementation.

The module implements the following classes:

* The :class:`Nutrient` class provides a representation of nutrient masses per grid
  cell.
* The :class:`SubcanopyBiomass` class then tracks the carbon mass and an associated set
  of nutrient masses for a given pool.
* The :class:`Subcanopy` then maintains subcanopy biomass pools for the vegetation and
  seedbank and provides methods to update the light gathering and ecological dynamics of
  the subcanopy at each update step.

TODO - lot more overlap between the SubcanopyBiomass and Biomasses classes than there
       used to be with the old stoichiometry module - can we replace the code here with
       a new tissue? The problem is the lack of a community object to initialise.

"""  # noqa:  D205

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
from numpy.typing import NDArray
from pyrealm.constants import CoreConst
from xarray import DataArray, full_like

from virtual_ecosystem.core.core_components import ModelTiming
from virtual_ecosystem.core.data import Data
from virtual_ecosystem.models.plants.model_config import PlantsConstants


[docs] @dataclass class Nutrient: """Dataclass for subcanopy elemental nutrient details. Args: name: The elemental nutrient name ideal_ratio: The ideal ratio for subcanopy tissue of the nutrient values: An array of per-grid-cell values """ name: str ideal_ratio: float masses: NDArray[np.floating]
[docs] @classmethod def from_constants( cls, tissue_name: str, element: str, constants: PlantsConstants, masses: NDArray[np.floating], ) -> Nutrient: """Factory method for Nutrient instances from the ideal ratio in constants. Args: tissue_name: The tissue name used in the plant constants element: The element name constants: A PlantConstants instance masses: The carbon biomasses of cells for the tissue. """ ideal_ratio = getattr(constants, f"{tissue_name}_c_{element}_ratio") return cls(name=element, ideal_ratio=ideal_ratio, masses=masses / ideal_ratio)
type SubcanopyNutrients = dict[str, Nutrient] """A type to indicate a dictionary of Nutrient instances."""
[docs] class SubcanopyBiomass: """A stochiometric biomass class for Subcanopy vegetation. The class tracks the carbon and elemental nutrient masses across an array of grid cells and provides properties to report the nutrient ratios. It also provides methods to add and remove masses from the class and to remove excess nutrients above ideal ratios. """ def __init__( self, carbon_mass: NDArray[np.floating], nutrients: SubcanopyNutrients, ) -> None: # Store Init arguments self.carbon_mass: NDArray[np.floating] = carbon_mass self.nutrients: SubcanopyNutrients = nutrients def __repr__(self) -> str: """Simple representation of class.""" return f"SubcanopyBiomass(carbon={self.carbon_mass})"
[docs] @classmethod def from_constants( cls, tissue_name: str, elements: tuple[str, ...], constants: PlantsConstants, masses: NDArray[np.floating], ) -> SubcanopyBiomass: """Factory method to generate a SubcanopyBiomass object from constants. The returned instance uses the provided carbon masses and initialises the named element masses at the ideal ratios set in the constants. """ nutrients = { elem: Nutrient.from_constants( tissue_name=tissue_name, element=elem, constants=constants, masses=masses, ) for elem in elements } return cls(carbon_mass=masses, nutrients=nutrients)
[docs] def c_x_ratio(self, nutrient: str) -> NDArray[np.floating]: """Return the current CN ratio for the biomass.""" return self.carbon_mass / self.nutrients[nutrient].masses
[docs] def remove_mass_fraction( self, mass_fraction: float | NDArray[np.floating] ) -> SubcanopyBiomass: """Remove a proportion of the biomass. This function returns a new SubcanopyBiomass object containing the requested fraction of the carbon biomass. The removed carbon biomass is removed from the parent instance. The nitrogen and phosphorous masses are split using the same fraction to maintain the same CN and CP ratios. Args: mass_fraction: The proportion of mass to remove from each cell in the instance. """ # Calculate extracted carbon and nutrient masses carbon_out = self.carbon_mass * mass_fraction nutrients_out = { nm: Nutrient( name=nm, ideal_ratio=nutr.ideal_ratio, masses=nutr.masses * mass_fraction, ) for nm, nutr in self.nutrients.items() } # Remove masses from self self.carbon_mass -= carbon_out for nm in self.nutrients: self.nutrients[nm].masses -= nutrients_out[nm].masses return SubcanopyBiomass(carbon_mass=carbon_out, nutrients=nutrients_out)
[docs] def add_mass(self, source: SubcanopyBiomass | SubcanopyNutrients): """Add biomass to a SubcanopyBiomass instance. The method adds carbon and nutrient biomasses (source is of type ``SubcanopyBiomass``) or just nutrient biomasses (source is of type ``SubcanopyNutrients``) to the calling instance. Args: source: The source ``SubcanopyBiomass`` or ``SubcanopyNutrients`` instance. """ # Add the carbon biomass and then drop down to just the nutrients if isinstance(source, SubcanopyBiomass): self.carbon_mass += source.carbon_mass source = source.nutrients for nm in source: self.nutrients[nm].masses += source[nm].masses
[docs] def get_excess_nutrients(self) -> SubcanopyNutrients: """Extract excess nutrients. This method calculates the excess nitrogen and phosphorous biomass in a SubcanopyBiomass instance, given the provided ideal ratios. The method returns a SubcanopyNutrients instance containing excess nutrient masses: these will be be zero where the source biomass in a cell is at or below the ideal ratio. """ # Subcanopy nutrients dictionary to return excesses excess_nutrients: SubcanopyNutrients = {} for nm, nutr in self.nutrients.items(): # Calculate the excess for each nutrient, remove it from the instance mass # and add a corresponding Nutrient to the return value. excess = np.maximum(nutr.masses - (self.carbon_mass / nutr.ideal_ratio), 0) nutr.masses -= excess excess_nutrients[nm] = Nutrient( name=nm, ideal_ratio=nutr.ideal_ratio, masses=excess, ) return excess_nutrients
[docs] class Subcanopy: """Representation of the subcanopy biomasses. This class maintains the representation of the subcanopy vegetation across grid cells within the Plants Model. The class maintains two biomass pools within each cell, the subcanopy vegetation and the seedbank for that vegetation, and tracks the carbon, nitrogen and phosphorous masses present in each pool. The class provides methods: * to calculate the leaf area index and fAPAR associated with the with the subcanopy, and * to calculate the dynamics of the subcanopy vegetation at each time step. Args: data: The model Data instance pyrealm_core_constants: The PModel core constants for the simulation. model_constants: The PlantModel constants for the simulation layer_index: The layer index of the surface layer in the vertical layer axis. model_timing: The core ModelTiming instance for the simulation. data_object_template: A template for creating CNP element arrays. """ elements: tuple[str, ...] = ("n", "p") """The set of nutrient elements currently tracked within the simulation.""" def __init__( self, data: Data, pyrealm_core_constants: CoreConst, model_constants: PlantsConstants, layer_index: int, model_timing: ModelTiming, data_object_template: DataArray, ) -> None: # Init attributes self.data: Data = data self.pyrealm_core_constants: CoreConst = pyrealm_core_constants self.model_constants: PlantsConstants = model_constants self.model_timing: ModelTiming = model_timing self.layer_index: int = layer_index self.data_object_template: DataArray = data_object_template # TODO: Currently initialising from constants using ideal ratios but should load # nutrient masses from init data. See: # https://github.com/ImperialCollegeLondon/virtual_ecosystem/issues/1334 # Stochiometry of vegetation and seedbank self.vegetation_biomass: SubcanopyBiomass = SubcanopyBiomass.from_constants( masses=data["subcanopy_vegetation_biomass"].to_numpy(), elements=self.elements, tissue_name="subcanopy_vegetation", constants=self.model_constants, ) self.seedbank_biomass: SubcanopyBiomass = SubcanopyBiomass.from_constants( masses=data["subcanopy_seedbank_biomass"].to_numpy(), elements=self.elements, tissue_name="subcanopy_seedbank", constants=self.model_constants, ) # Write the initial values to data. This currently: # # * Assumes no initial litter from either pool # * Duplicates code used in the update method (calculate_dynamics) # * Uses a meaningless "ideal" ratio for litter # # But - both of those should be fixed in a more general refactor of this module # to use the CNP array structure. That will likely change a lot of this code, so # not currently adding a SubcanopyBiomass.to_data() method etc etc. empty_litter = np.zeros_like(self.seedbank_biomass.carbon_mass) initial_litter = SubcanopyBiomass( carbon_mass=empty_litter.copy(), nutrients={ "n": Nutrient(name="n", ideal_ratio=0, masses=empty_litter.copy()), "p": Nutrient(name="p", ideal_ratio=0, masses=empty_litter.copy()), }, ) # Write biomasses to Data biomasses: dict[str, SubcanopyBiomass] = { "subcanopy_vegetation": self.vegetation_biomass, "subcanopy_seedbank": self.seedbank_biomass, "subcanopy_vegetation_litter": initial_litter, "subcanopy_seedbank_litter": initial_litter, } for var, biomass in biomasses.items(): biomass_array = self.data_object_template.copy() biomass_array.loc[:, "C"] = biomass.carbon_mass for elem in self.elements: biomass_array.loc[:, elem.upper()] = biomass.nutrients[elem].masses self.data[f"{var}_cnp"] = biomass_array # Type other attributes not populated at __init__ self.lai: NDArray[np.floating] self.light_transmission: NDArray[np.floating] self.fapar: NDArray[np.floating]
[docs] def calculate_dynamics( self, lue: NDArray[np.floating], iwue: NDArray[np.floating], swd: NDArray[np.floating], ) -> None: r"""Estimate the dynamics of subcanopy vegetation. This method models the biomass dynamics with the subcanopy vegetation and subcanopy seedbank pools during a model update. 1. A fraction of the biomass in each pool is allocated to turnover, and passed into litter pools. The stoichiometric ratios of turnover biomass are identical to the pool biomasses. 2. The predicted light use and intrinsic water use efficiencies (LUE and iWUE) in the surface layer are taken from the P Model and used to estimate gross primary productivity (GPP) and transpiration. GPP is reduced by respiration and yield to give net primary productivity NPP, which is added as new carbon biomass to the subcanopy vegetation. The soil dissolved nitrate, ammonium and phosphorous concentrations are then used to calculate the nutrient uptake associated with the transpiration volume and these are added to the subcanopy vegetation pool. 3. A fraction of the subcanopy vegetation biomass is then removed to represent reproductive output to the seedbank pool. The stochiometric ratio of the reproductive biomass is initially identical to the vegetation biomass but any excess nitrogen and phosphorous above the configured ideal ratios is also transferred to the seedbank to represent seed provisioning. 4. Lastly, new vegetative biomass is added from sprouting from the seedbank. The initial amount of sprouting biomass is set by the ``subcanopy_sprout_rate`` constant but the contribution to subcanopy biomass is reduced using the ``subcanopy_sprout_yield`` constant. The remainder of the sprouting biomass is allocated to litter. .. TODO:: Timing of turnover The timing of turnover is going to affect growth patterns - it is currently placed right at the start of the dynamics, but it might be better to calculate an average biomass to spread turnover through the update period. """ # Apply turnover for this update vegetation_turnover = self.vegetation_biomass.remove_mass_fraction( self.model_constants.subcanopy_vegetation_turnover / self.model_timing.updates_per_year ) seedbank_turnover = self.seedbank_biomass.remove_mass_fraction( self.model_constants.subcanopy_vegetation_turnover / self.model_timing.updates_per_year ) # Calculate the gross primary productivity since the last update. # LUE 1 layer [gC mol-1] # * canopy top SWD 1 layer [µmol m-2 s-1] # * subcanopy fapar 1 layer [-] # * DST to PPFD scalar [-] # * time elapsed scalar [s] # Units: # gC mol-1 * µmol m-2 s-1 * (-) * (-) * s = µg C m-2 # # This calculation handles non-estimable LUE from the P Model by setting np.nan # values to zero. subcanopy_gpp = ( np.nan_to_num(lue) * swd * self.fapar * self.model_constants.dsr_to_ppfd * self.model_timing.update_interval_seconds ) # Calculate NPP, converting µg C m-2 to kg C m-2 # TODO - what is the fate of the (1- self.model_constants.subcanopy_yield). The # assumption here is that it is lost to the atmosphere, but that is # basically the same as respiration? subcanopy_npp = ( self.model_constants.subcanopy_yield * (subcanopy_gpp * 1e-9) * (1 - self.model_constants.subcanopy_respiration_fraction) ) # Transpiration and nutrient acquisition # - Calculate the transpiration associated with the GPP in moles self.subcanopy_transpiration = ( subcanopy_gpp / (self.pyrealm_core_constants.k_c_molmass * 1e6) ) * iwue # Calculate the volume of water from µmol to m3 to convert soil water nutrient # concentrations in kg m3 into uptake nutrient mass. Water has 1e6 g / 18.015 g # mol ~ 55509.2 moles per m3, so transpiration in µmol is (T * 1e-6) / (1e6 / # 18.015) = T * 1.8015e-11 metres cubed. subcanopy_volume_m3 = self.subcanopy_transpiration * 18.015e-11 # Now calculate uptakes of nutrients through transpired water ammonium_uptake_kg = ( subcanopy_volume_m3 * self.data["dissolved_ammonium"].to_numpy() ) nitrate_uptake_kg = ( subcanopy_volume_m3 * self.data["dissolved_nitrate"].to_numpy() ) phosphorus_uptake_kg = ( subcanopy_volume_m3 * self.data["dissolved_phosphorus"].to_numpy() ) # TODO need to remove uptake from soil # Assimilate the gained masses into the vegetation first to update the # nutrient masses that are available for allocation to seedbank # TODO: Note that this section does not cleanly handle additional elements. self.vegetation_biomass.add_mass( SubcanopyBiomass( carbon_mass=subcanopy_npp, nutrients={ "n": Nutrient( name="n", ideal_ratio=self.model_constants.subcanopy_vegetation_c_n_ratio, masses=ammonium_uptake_kg + nitrate_uptake_kg, ), "p": Nutrient( name="p", ideal_ratio=self.model_constants.subcanopy_vegetation_c_p_ratio, masses=phosphorus_uptake_kg, ), }, ) ) # Extract the new carbon allocation for the seedbank using those new nutrient # ratios, catching cells with no vegetation biomass seedbank_carbon_fraction: NDArray[np.floating] = np.where( self.vegetation_biomass.carbon_mass > 0, subcanopy_npp * self.model_constants.subcanopy_reproductive_allocation / self.vegetation_biomass.carbon_mass, 0, ) seedbank_allocation = self.vegetation_biomass.remove_mass_fraction( mass_fraction=seedbank_carbon_fraction ) # Extract seedbank provisioning using excess nutrients in vegetative biomass # TODO - how do these nutrients make it to the seedbank if there are excess # nutrients but no carbon? seedbank_extra_nutrients = self.vegetation_biomass.get_excess_nutrients() # Get the new sprouted biomass from the seedbank during the time period sprouting_biomass = self.seedbank_biomass.remove_mass_fraction( self.model_constants.subcanopy_sprout_rate / self.model_timing.updates_per_year ) # Remove the sprouting biomass yield losses from the total mass sprouting_yield_losses = sprouting_biomass.remove_mass_fraction( mass_fraction=1 - self.model_constants.subcanopy_sprout_yield ) # Now allocate new biomasses to pools self.seedbank_biomass.add_mass(seedbank_allocation) self.seedbank_biomass.add_mass(seedbank_extra_nutrients) self.vegetation_biomass.add_mass(sprouting_biomass) seedbank_turnover.add_mass(sprouting_yield_losses) # Insert DataArrays with new values - could simply overwrite data but these # variables are created in the first update, so easier to just write afresh. coords = {"cell_id": self.data["cell_id"].data} # Write biomasses to Data biomasses: dict[str, SubcanopyBiomass] = { "subcanopy_vegetation": self.vegetation_biomass, "subcanopy_seedbank": self.seedbank_biomass, "subcanopy_vegetation_litter": vegetation_turnover, "subcanopy_seedbank_litter": seedbank_turnover, } for var, biomass in biomasses.items(): self.data[f"{var}_cnp"] = self.data_object_template.copy() self.data[f"{var}_cnp"].loc[:, "C"] = biomass.carbon_mass for elem in self.elements: self.data[f"{var}_cnp"].loc[:, elem.upper()] = biomass.nutrients[ elem ].masses # Write lignin concentrations for litter components self.data["subcanopy_vegetation_litter_lignin"] = full_like( self.data["cell_id"], self.model_constants.subcanopy_vegetation_lignin ) self.data["subcanopy_seedbank_litter_lignin"] = full_like( self.data["cell_id"], self.model_constants.subcanopy_seedbank_lignin ) # Write nutrient uptakes for name, values in ( ("subcanopy_ammonium_uptake", ammonium_uptake_kg), ("subcanopy_nitrate_uptake", nitrate_uptake_kg), ("subcanopy_phosphorus_uptake", phosphorus_uptake_kg), ): self.data[name] = DataArray(values, coords=coords) # Write transpiration self.data["transpiration"][self.layer_index] = subcanopy_volume_m3 / 1000
[docs] def set_light_capture(self, below_canopy_light_fraction: NDArray) -> None: r"""Calculate the leaf area index and absorption of subcanopy vegetation. The subcanopy vegetation is represented as pure leaf biomass (:math:`M_{SC}`, kg m-2), with an associated extinction coefficient (:math:`k`) and specific leaf area (:math:`\sigma`, kg m-2) set in the model constants. These can be used to calculate the leaf area index (:math:`L`) and hence the absorption fraction (:math:`f_{a}`) of the subcanopy vegetation layer via the Beer-Lambert law: .. math :: :nowrap: \[ \begin{align*} L &= M_{SC} \sigma \\ f_a = e^{-kL} \end{align*} \] """ # Calculate the leaf area index - values are already in kg m-2 so no need to # account for the area occupied by the biomass - and set the leaf area self.lai = ( self.data["subcanopy_vegetation_biomass"].to_numpy() * self.model_constants.subcanopy_specific_leaf_area ) # Beer-Lambert transmission - note that this is 1 when there is no biomass and # so no light is absorbed by the vegetation and all of the subcanopy light # reaches the ground. self.light_transmission = np.exp( -self.model_constants.subcanopy_extinction_coef * self.lai ) # Absorb a fraction of the below canopy light and pass the rest on to the ground # incident light fraction self.fapar = below_canopy_light_fraction * (1 - self.light_transmission) # Store those values self.data["leaf_area_index"][self.layer_index] = self.lai self.data["layer_fapar"][self.layer_index] = self.fapar