Source code for virtual_ecosystem.models.soil.env_factors

"""The ``models.soil.env_factors`` module contains functions that are used to
capture the impact that environmental factors have on microbial rates. These include
temperature, soil water potential, pH and soil texture.
"""  # noqa: D205

from dataclasses import dataclass

import numpy as np
from numpy.typing import NDArray
from scipy.constants import convert_temperature, gas_constant
from scipy.special import expit
from xarray import DataArray

from virtual_ecosystem.core.core_components import LayerStructure
from virtual_ecosystem.core.logger import LOGGER
from virtual_ecosystem.models.soil.model_config import SoilConstants


[docs] @dataclass class EnvironmentalEffectFactors: """The various factors through which the environment effects soil cycling rates.""" water: NDArray[np.floating] """Impact of soil water potential on enzymatic rates [unitless].""" pH: NDArray[np.floating] """Impact of soil pH on enzymatic rates [unitless].""" clay_saturation: NDArray[np.floating] """Impact of soil clay fraction on enzyme saturation constants [unitless]."""
[docs] def calculate_environmental_effect_factors( soil_water_potential: NDArray[np.floating], pH: NDArray[np.floating], clay_fraction: NDArray[np.floating], constants: SoilConstants, ) -> EnvironmentalEffectFactors: """Calculate the effects that the environment has on relevant biogeochemical rates. For each environmental effect a multiplicative factor is calculated, and all of them are returned in a single object for use elsewhere in the soil model. Args: soil_water_potential: Soil water potential for each grid cell [kPa] pH: pH values for each soil grid cell [unitless] clay_fraction: The clay fraction for each soil grid cell [unitless] constants: Set of constants for the soil model Returns: An object containing four environmental factors, one for the effect of water potential on enzyme rates, one for the effect of pH on enzyme rates, one for the effect of clay fraction on enzyme saturation, and one for the effect of clay on necromass decay destination. """ # Calculate the impact that each environment variable has on the relevant # biogeochemical soil processes water_factor = calculate_water_potential_impact_on_microbes( water_potential=soil_water_potential, water_potential_halt=constants.soil_microbe_water_potential_halt, water_potential_opt=constants.soil_microbe_water_potential_optimum, response_curvature=constants.microbial_water_response_curvature, ) pH_factor = calculate_pH_suitability( soil_pH=pH, maximum_pH=constants.max_pH_microbes, minimum_pH=constants.min_pH_microbes, lower_optimum_pH=constants.lowest_optimal_pH_microbes, upper_optimum_pH=constants.highest_optimal_pH_microbes, ) clay_factor_saturation = calculate_clay_impact_on_enzyme_saturation( clay_fraction=clay_fraction, base_protection=constants.base_soil_protection, protection_with_clay=constants.soil_protection_with_clay, ) # Combine all factors into a single EnvironmentalFactors object return EnvironmentalEffectFactors( water=water_factor, pH=pH_factor, clay_saturation=clay_factor_saturation, )
[docs] def calculate_temperature_effect_on_microbes( soil_temperature: NDArray[np.floating], activation_energy: float, reference_temperature: float, ) -> NDArray[np.floating]: """Calculate the effect that temperature has on microbial metabolic rates. This uses a standard Arrhenius equation to calculate the impact of temperature. This function takes temperatures in Celsius as inputs and converts them into Kelvin for use in the Arrhenius equation. TODO - review this after we have decided how to handle these conversions in general. Args: soil_temperature: The temperature of the soil [Celsius] activation_energy: Energy of activation [J mol^-1] reference_temperature: The reference temperature of the Arrhenius equation [Celsius] Returns: A multiplicative factor capturing the effect of temperature on microbial rates """ # Convert the temperatures to Kelvin soil_temp_in_kelvin = convert_temperature( soil_temperature, old_scale="Celsius", new_scale="Kelvin" ) ref_temp_in_kelvin = convert_temperature( reference_temperature, old_scale="Celsius", new_scale="Kelvin" ) return np.exp( (-activation_energy / gas_constant) * ((1 / (soil_temp_in_kelvin)) - (1 / (ref_temp_in_kelvin))) )
[docs] def calculate_water_potential_impact_on_microbes( water_potential: NDArray[np.floating], water_potential_halt: float, water_potential_opt: float, response_curvature: float, ) -> NDArray[np.floating]: """Calculate the effect that soil water potential has on microbial rates. This function produces values of one (i.e. no suppression due to soil water) when soil water potentials that are greater than the optimal water potential. Below the water potential at which microbial activity ceases suppression is (by definition) total, so values of zero are produced. Args: water_potential: Soil water potential [kPa] water_potential_halt: Water potential at which all microbial activity stops [kPa] water_potential_opt: Optimal water potential for microbial activity [kPa] response_curvature: Parameter controlling the curvature of function that captures the response of microbial rates to water potential [unitless] Returns: A multiplicative factor capturing the impact of moisture on soil microbe rates decomposition [unitless] """ # Calculate how much moisture suppresses microbial activity suppression = ( (np.log10(-water_potential) - np.log10(-water_potential_opt)) / (np.log10(-water_potential_halt) - np.log10(-water_potential_opt)) ) ** response_curvature # Above optimum no suppression, and below halting threshold suppression is total return np.where( water_potential > water_potential_opt, 1, (np.where(water_potential < water_potential_halt, 0, 1 - suppression)), )
[docs] def calculate_pH_suitability( soil_pH: NDArray[np.floating], maximum_pH: float, minimum_pH: float, upper_optimum_pH: float, lower_optimum_pH: float, ) -> NDArray[np.floating]: """Calculate the suitability of the soil pH for microbial activity. This function is taken from :cite:t:`orwin_organic_2011`. pH values within the optimal range are assumed to cause no microbial inhibition, and pH values above a certain value or below a certain value are assumed to cause total inhibition. Linear declines then occur between the edges of the optimal range and the zone of total inhibition. Args: soil_pH: The pH of the soil [unitless] maximum_pH: pH above which microbial rates are completely inhibited [unitless] minimum_pH: pH below which microbial rates are completely inhibited [unitless] upper_optimum_pH: pH above which suitability declines [unitless] lower_optimum_pH: pH below which suitability declines [unitless] Returns: A multiplicative factor capturing the effect of pH on microbial rates """ # TODO - This check is necessary to prevent nonsensical output being generated, # however it could be done when constants are loaded, rather than for every function # call if ( maximum_pH <= upper_optimum_pH or upper_optimum_pH <= lower_optimum_pH or lower_optimum_pH <= minimum_pH ): to_raise = ValueError("At least one pH threshold has an invalid value!") LOGGER.error(to_raise) raise to_raise pH_factors = np.full(len(soil_pH), np.nan) # zero below minimum or above maximum pH pH_factors[soil_pH < minimum_pH] = 0 pH_factors[soil_pH > maximum_pH] = 0 # and one between the two thresholds pH_factors[(lower_optimum_pH <= soil_pH) & (soil_pH <= upper_optimum_pH)] = 1 # Find points that lie between optimal region and maximum/minimum between_opt_and_min = (minimum_pH <= soil_pH) & (soil_pH < lower_optimum_pH) between_opt_and_max = (upper_optimum_pH < soil_pH) & (soil_pH <= maximum_pH) # Linear increase from minimum pH value to lower threshold pH_factors[between_opt_and_min] = (soil_pH[between_opt_and_min] - minimum_pH) / ( lower_optimum_pH - minimum_pH ) # Linear decrease from the upper threshold to maximum pH pH_factors[between_opt_and_max] = (maximum_pH - soil_pH[between_opt_and_max]) / ( maximum_pH - upper_optimum_pH ) return pH_factors
[docs] def calculate_clay_impact_on_enzyme_saturation( clay_fraction: NDArray[np.floating], base_protection: float, protection_with_clay: float, ) -> NDArray[np.floating]: """Calculate the impact that the soil clay fraction has on enzyme saturation. This factor impacts enzyme saturation constants, based on the assumption that finely textured soils will restrict enzyme access to available C substrates (which protects them). Its form is taken from :cite:t:`fatichi_mechanistic_2019`. Args: clay_fraction: The fraction of the soil which is clay [unitless] base_protection: The protection that a soil with no clay provides [unitless] protection_with_clay: The rate at which protection increases with increasing clay [unitless] Returns: A multiplicative factor capturing how much the protection due to soil structure changes the effective saturation constant by [unitless] """ return base_protection + protection_with_clay * clay_fraction
[docs] def calculate_nitrification_temperature_factor( soil_temp: NDArray[np.floating], optimum_temp: float, max_temp: float, thermal_sensitivity: int, ) -> NDArray[np.floating]: """Calculate factor that captures the effect of temperature on nitrification rate. Form of this function is taken from :cite:t:`xu-ri_terrestrial_2008`. Args: soil_temp: Temperature of the relevant segment of soil [Celsius] optimum_temp: Temperature at which nitrification is maximised [Kelvin] max_temp: Maximum temperature for which this expression still gives a meaningful result [Kelvin] thermal_sensitivity: Sensitivity of the factor to changes in temperature [unitless] Returns: A factor capturing the impact of soil temperature on the nitrification rate [unitless]. """ # TODO - This will be removed once temperatures start being supplied in Kelvin # Convert the temperatures to Kelvin soil_temp_in_kelvin = convert_temperature( soil_temp, old_scale="Celsius", new_scale="Kelvin" ) return ( ((max_temp - soil_temp_in_kelvin) / (max_temp - optimum_temp)) ** thermal_sensitivity ) * np.exp( thermal_sensitivity * ((soil_temp_in_kelvin - optimum_temp) / (max_temp - optimum_temp)) )
[docs] def calculate_nitrification_moisture_factor(effective_saturation: NDArray[np.floating]): """Calculate factor that captures the effect of soil moisture on nitrification rate. Form of this function is taken from :cite:t:`fatichi_mechanistic_2019`, where it is provided with basically no justification. Args: effective_saturation: Effective saturation of the soil with water [unitless] Returns: A factor capturing the impact of soil moisture on the nitrification rate [unitless]. """ return effective_saturation * (1 - effective_saturation) / 0.25
[docs] def calculate_denitrification_temperature_factor( soil_temp: NDArray[np.floating], factor_at_infinity: float, minimum_temp: float, thermal_sensitivity: float, ): """Calculate factor that captures the effect of temperature on denitrification rate. Form of this function is a slight rearranged of one provided in :cite:t:`xu-ri_terrestrial_2008`. Args: soil_temp: Temperature of the relevant segment of soil [Celsius] factor_at_infinity: Value of temperature factor at infinite temperature [unitless] minimum_temp: Minimum temperature at which denitrification can still happen [Kelvin] thermal_sensitivity: Sensitivity of the factor to changes in temperature [Kelvin] Returns: A factor capturing the impact of soil temperature on the denitrification rate [unitless]. """ # TODO - This will be removed once temperatures start being supplied in Kelvin # Convert the temperatures to Kelvin soil_temp_in_kelvin = convert_temperature( soil_temp, old_scale="Celsius", new_scale="Kelvin" ) return np.where( soil_temp_in_kelvin <= minimum_temp, 0, factor_at_infinity * np.exp(-thermal_sensitivity / (soil_temp_in_kelvin - minimum_temp)), )
[docs] def calculate_symbiotic_nitrogen_fixation_carbon_cost( soil_temp: NDArray[np.floating], cost_at_zero_celsius: float, infinite_temp_cost_offset: float, thermal_sensitivity: float, cost_equality_temp: float, ): """Calculate the cost of symbiotic nitrogen fixation in carbon terms. The function used here is adapted from an empirical function provided in :cite:t:`brzostek_modeling_2014`. As the function is not defined below zero degrees celsius if a negative temperature is input an infinite cost is returned. I could not sensibly convert this empirically derived function from Celsius to Kelvin units so this is the only function in the soil model to use Celsius units. Args: soil_temp: Temperature of the relevant soil zone [Celsius] cost_at_zero_celsius: The cost nitrogen fixation at zero Celsius [kg{C} kg{N}^-1] infinite_temp_cost_offset: The difference between the nitrogen fixation cost at zero Celsius and the cost that it tends towards at very high temperatures [kg{C} kg{N}^-1] thermal_sensitivity: Sensitivity of nitrogen fixation cost to changes in temperature [Celsius^-1] cost_equality_temp: Temperature (positive) at which the nitrogen fixation cost is the same as it is at zero Celsius [Celsius] Returns: The carbon cost that plants have to pay their microbial symbionts to fix per unit of nitrogen fixed [kg{C} kg{N}^-1] """ return np.where( soil_temp < 0.0, np.inf, cost_at_zero_celsius + infinite_temp_cost_offset * ( np.exp( thermal_sensitivity * soil_temp * (1 - (soil_temp / cost_equality_temp)) ) - 1 ), )
[docs] def calculate_solute_removal_by_soil_water( solute_density: NDArray[np.floating], exit_rate: NDArray[np.floating], soil_moisture: NDArray[np.floating], solubility_coefficient: float, ) -> NDArray[np.floating]: """Calculate rate at which water removes a given solute based on flow rate. This functional form is adapted from :cite:t:`porporato_hydrologic_2003`. The amount of solute that is expected to be found in dissolved form is calculated by multiplying the solute density by its solubility coefficient. This is then multiplied by the frequency with which the water column in the microbially active depth is completely replaced. This replacement can happen through downwards flow (leaching) or through horizontal flow. The replacement frequency can be found as the ratio the total rate at which water exits the microbially active portion of the soil to soil moisture in mm. Args: solute_density: The density of the solute in the soil [kg{solute} m^-3] exit_rate: Rate at which water exits the microbially active portion of the soil [mm day^-1] soil_moisture: Volume of water contained in topsoil layer [mm] solubility_coefficient: The solubility coefficient of the solute in question [unitless] Returns: The rate at which the solute in question is removed from the soil by the flow of water [kg{solute} m^-3 day^-1] """ return np.where( solute_density >= 0, solubility_coefficient * solute_density * exit_rate / soil_moisture, 0, )
[docs] def calculate_carbon_use_efficiency( soil_temp: NDArray[np.floating], reference_cue_logit: float, cue_reference_temp: float, logit_cue_with_temp: float, ) -> NDArray[np.floating]: """Calculate the (temperature dependent) carbon use efficiency. We model the carbon use efficiency using a logistic function. This is to ensure that carbon use efficiency values remain bounded between zero and one. TODO - This should be adapted to use an Arrhenius function at some point. Args: soil_temp: soil temperature for each soil grid cell [Celsius] reference_cue_logit: Logit of the carbon use efficiency at reference temp [unitless] cue_reference_temp: Reference temperature [Celsius] logit_cue_with_temp: Rate of change in the logit of carbon use efficiency with increasing temperature [Celsius^-1] Returns: The carbon use efficiency (CUE) of the microbial community """ return expit( reference_cue_logit + logit_cue_with_temp * (soil_temp - cue_reference_temp) )
[docs] def find_total_soil_moisture_for_microbially_active_depth( soil_moistures: DataArray, layer_structure: LayerStructure, ) -> NDArray[np.floating]: """Find total soil moisture for the microbially active depth. The proportion of each soil layer that lies within the microbially active zone is first found. The soil moisture for each layer is then multiplied by this proportion and summed, to find the total soil moisture in the microbially active zone. Args: soil_moistures: Soil moistures across all soil layers [mm] layer_structure: The LayerStructure instance for the simulation. From this we use the thickness of each layer, as well as `soil_layer_active_thickness` which is how much of each layer lies within the microbially active zone Returns: The total soil moisture in the microbially active depth [mm] """ # Find the fraction of each layer that lies within the microbially active zone layer_weights = ( layer_structure.soil_layer_active_thickness / layer_structure.soil_layer_thickness ) return np.dot(layer_weights, soil_moistures[layer_structure.index_all_soil])
[docs] def find_water_outflow_rates( vertical_flow: NDArray[np.floating], layer_structure: LayerStructure ) -> NDArray[np.floating]: """Find the rate at which water leaves the microbially active soil region. This functions calculates the rate at which soil water in the microbially active region is refreshed with "new" water from rainfall. The reason to specifically care about "new" water is that it does not carry any significant amount of nutrients with it (in contrast to water moving from a different part of the soil), meaning that the soil nutrients will dissolve from the soil without impediment (which is the assumption underlying the :func:`~virtual_ecosystem.models.soil.env_factors.calculate_solute_removal_by_soil_water` function). The rate of "new" water refreshing the microbially active column will be equivalent to the rate at which water escapes from this region. For the upper soil layers, all water flows are vertical rather than horizontal, so this function only considers vertical flows. If the implementation of the hydrology model changes so that the upper layers also have horizontal water movements this function will need to change to ensure that nutrient flows properly track the water flows. The water column that the soil model is interested in (i.e. the amount of water down to the maximum depth of microbial activity) generally spans a fractional number of soil hydrology layers, meaning that water exits the microbially active region within a specific soil hydrology layer rather than at the boundary of two layers. This complicates things as the vertical flow rates are defined for passing between hydrology layers. We therefore calculate two separate exit rates which we then sum to find the combined rate. Firstly, we calculate the rate at which water flows into the microbially inactive portion of the partially microbially active layer. This is found by multiplying the vertical flow into the layer by the fraction of the layer that is microbially inactive. Secondly, we calculate the rate at which water flows from the microbially active portion of this layer to the microbially inactive layer below. This flow is found by multiplying the vertical flow to the lower layer by the fraction of the upper layer that is microbially active. Args: vertical_flow: The flow rate between each soil layer [mm day^-1] layer_structure: The LayerStructure instance for the simulation. From this we use the thickness of each layer, as well as `soil_layer_active_thickness` which is how much of each layer lies within the microbially active zone Returns: The rate at which water leaves the microbially active region of the soil [mm day^-1] """ # Find the fraction of each layer that lies within the microbially active zone layer_weights = ( layer_structure.soil_layer_active_thickness / layer_structure.soil_layer_thickness ) # Water only leaves the microbial zone from the bottom two microbially active # layers. (If only the top layer is active use it and the layer beneath) non_zero_indices = np.flatnonzero(layer_weights) if len(non_zero_indices) == 1: lowest_active_layers = np.array([non_zero_indices[0], non_zero_indices[0] + 1]) else: lowest_active_layers = np.array([non_zero_indices[-2], non_zero_indices[-1]]) lowest_layer_weight = layer_weights[lowest_active_layers[1]] # Need to switch from soil layers (which the weights are counted in) to the total # layers in the layer structure (which vertical flow is measured in) lowest_active_layers += layer_structure.index_topsoil_scalar vertical_exit_flow = ( lowest_layer_weight * vertical_flow[lowest_active_layers[1]] + (1 - lowest_layer_weight) * vertical_flow[lowest_active_layers[0]] ) return vertical_exit_flow