Source code for virtual_ecosystem.models.abiotic_simple.microclimate_simple

r"""The ``models.abiotic_simple.microclimate_simple`` module uses linear regressions
from :cite:t:`hardwick_relationship_2015` and :cite:t:`jucker_canopy_2018` to predict
atmospheric temperature, relative humidity, and vapour pressure deficit at ground level
(1.5 m) given the above canopy conditions and leaf area index of intervening canopy. A
within canopy profile is then interpolated using an exponential curve between the above
canopy observation and ground level prediction. The same method is applied to derive a
vertical wind profile within the canopy, except that we use a logarithmic interpolation.
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}` as well as a profile of net radiation.

TODO change temperatures to Kelvin
"""  # noqa: D205

import numpy as np
from numpy.typing import NDArray
from pyrealm.constants import CoreConst as PyrealmCoreConst
from pyrealm.core.hygro import calc_vp_sat
from xarray import DataArray

from virtual_ecosystem.core.core_components import LayerStructure
from virtual_ecosystem.core.data import Data
from virtual_ecosystem.core.model_config import CoreConstants
from virtual_ecosystem.models.abiotic import abiotic_tools, energy_balance
from virtual_ecosystem.models.abiotic.model_config import AbioticConstants
from virtual_ecosystem.models.abiotic_simple.model_config import (
    AbioticSimpleBounds,
    AbioticSimpleConstants,
)


[docs] def run_simple_microclimate( data: Data, layer_structure: LayerStructure, time_index: int, # could be datetime? constants: AbioticSimpleConstants | AbioticConstants, core_constants: CoreConstants, pyrealm_core_constants: PyrealmCoreConst, bounds: AbioticSimpleBounds, ) -> dict[str, DataArray]: r"""Calculate simple microclimate. This function uses empirical relationships between leaf area index (LAI) and atmospheric temperature, relative humidity, vapour pressure deficit, and wind speed to derive vertical profiles of these variables from external climate data such as regional climate models or satellite observations. Note that these sources provide data at different heights and with different underlying assumptions which lead to different biases in the model output. For below canopy values (1.5 m), the implementation is based on :cite:t:`hardwick_relationship_2015` as :math:`y = m * LAI + c` where :math:`y` is the variable of interest, :math:`m` is the gradient (:data:`~virtual_ecosystem.models.abiotic_simple.model_config.AbioticSimpleConstants`) and :math:`c` is the intersect which we set to the external data values. We assume that the gradient remains constant. The values for all atmospheric layers as defined by 'layer_heights' in the Virtual Ecosystem (including canopy layers and surface layer) are calculated by exponential (for atmospheric temperature, relative humidity, vapour pressure deficit) or logarithmic (for wind speed) regression and interpolation between the input at the top of the canopy and the 1.5 m values. Soil temperature is interpolated between the surface layer and the temperature at 1 m depth which which approximately equals the mean annual temperature, i.e. can assumed to be constant over the year. The function also broadcasts the reference values for atmospheric pressure and :math:`\ce{CO2}` to all atmospheric levels as they are currently assumed to remain constant during one time step. Net radiation for canopy and topsoil layer is also returned. The `layer_roles` list is composed of the following layers (index 0 above canopy): * above canopy (canopy height + 2 m) * canopy layers * surface layer * soil layers The function expects a data object with the following variables: * air_temperature_ref [C] * relative_humidity_ref [] * vapour_pressure_deficit_ref [kPa] * atmospheric_pressure_ref [kPa] * atmospheric_co2_ref [ppm] * wind_speed_ref [m s-1] * leaf_area_index [m m-1] * layer_heights [m] Args: data: Data object layer_structure: The LayerStructure instance for the simulation. time_index: Time index, integer constants: Set of constants for the abiotic simple model core_constants: Set of constants shared across all models pyrealm_core_constants: Set of constants from pyrealm package bounds: Upper and lower allowed values for vertical profiles, used to constrain log/exp interpolation. Note that currently no conservation of water and energy! 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], wind speed [m s-1] """ output = {} # Sum leaf area index over all canopy layers, [m m-1] # This step excludes the understorey vegetation, assuming that the relationship # between LAI and the variables is purely based on the vegetation above the # measurement height of 1m :cite:p:`hardwick_relationship_2015`. leaf_area_index_sum = np.nansum( data["leaf_area_index"][layer_structure.index_filled_canopy], axis=0 ) # Interpolate atmospheric profiles for var in [ "air_temperature", "relative_humidity", "vapour_pressure_deficit", ]: lower, upper, gradient = getattr(bounds, var) output[var] = exp_interpolation( reference_data=data[var + "_ref"].isel(time_index=time_index).to_numpy(), leaf_area_index_sum=leaf_area_index_sum, layer_structure=layer_structure, layer_heights=data["layer_heights"].to_numpy(), upper_bound=upper, lower_bound=lower, gradient=gradient, ).rename(var) # Interpolate wind profiles lower_wind, upper_wind, gradient_wind = getattr(bounds, "wind_speed") output["wind_speed"] = log_interpolation( reference_data=data["wind_speed_ref"].isel(time_index=time_index).to_numpy(), leaf_area_index_sum=leaf_area_index_sum, layer_structure=layer_structure, layer_heights=data["layer_heights"].to_numpy(), upper_bound=upper_wind, lower_bound=lower_wind, gradient=gradient_wind, ).rename(var) # Vapour pressure, [kPa] vapour_pressure = abiotic_tools.calculate_actual_vapour_pressure( air_temperature=output["air_temperature"], relative_humidity=output["relative_humidity"], pyrealm_core_constants=pyrealm_core_constants, ) output["vapour_pressure"] = layer_structure.from_template() output["vapour_pressure"] = vapour_pressure # Mean atmospheric pressure profile, [kPa] output["atmospheric_pressure"] = abiotic_tools.update_profile_from_reference( layer_structure=layer_structure, mask_variable=output["air_temperature"], variable_name=data["atmospheric_pressure_ref"], time_index=time_index, ) # Mean atmospheric C02 profile, [ppm] output["atmospheric_co2"] = abiotic_tools.update_profile_from_reference( layer_structure=layer_structure, mask_variable=output["air_temperature"], variable_name=data["atmospheric_co2_ref"], time_index=time_index, ) # Calculate soil temperatures, [C] lower, upper = getattr(bounds, "soil_temperature") output["soil_temperature"] = interpolate_soil_temperature( layer_heights=data["layer_heights"], surface_temperature=output["air_temperature"].isel( layers=layer_structure.index_surface ), mean_annual_temperature=data["mean_annual_temperature"].isel( time_index=time_index ), layer_structure=layer_structure, upper_bound=upper, lower_bound=lower, ) # Initialise canopy and understorey temperature, [C] canopy_temperature = layer_structure.from_template() canopy_temperature[layer_structure.index_filled_canopy] = output["air_temperature"][ layer_structure.index_filled_canopy ] canopy_temperature[layer_structure.index_surface_scalar] = output[ "air_temperature" ][layer_structure.index_surface_scalar] # Calculate net radiation, [W m-2]. canopy_longwave_emission = energy_balance.calculate_longwave_emission( temperature=canopy_temperature.to_numpy(), emissivity=constants.leaf_emissivity, stefan_boltzmann=core_constants.stefan_boltzmann_constant, ) soil_longwave_emission = energy_balance.calculate_longwave_emission( temperature=output["soil_temperature"][ layer_structure.index_topsoil_scalar ].to_numpy(), emissivity=constants.soil_emissivity, stefan_boltzmann=core_constants.stefan_boltzmann_constant, ) net_radiation_canopy = ( data["shortwave_absorption"][layer_structure.index_filled_canopy].to_numpy() - canopy_longwave_emission[layer_structure.index_filled_canopy] ) net_radiation_understorey = ( data["shortwave_absorption"][layer_structure.index_surface_scalar].to_numpy() - canopy_longwave_emission[layer_structure.index_surface_scalar] ) net_radiation_soil = ( data["shortwave_absorption"][layer_structure.index_topsoil_scalar].to_numpy() - soil_longwave_emission ) net_radiation = layer_structure.from_template() net_radiation[layer_structure.index_filled_canopy] = net_radiation_canopy net_radiation[layer_structure.index_surface_scalar] = net_radiation_understorey net_radiation[layer_structure.index_topsoil_scalar] = net_radiation_soil output["net_radiation"] = net_radiation return output
[docs] def log_interpolation( reference_data: NDArray[np.floating], leaf_area_index_sum: NDArray[np.floating], layer_structure: LayerStructure, layer_heights: NDArray[np.floating], upper_bound: float, lower_bound: float, gradient: float, ) -> DataArray: """LAI regression and logarithmic interpolation of variables above ground. Args: reference_data: Input variable at reference height leaf_area_index_sum: Leaf area index summed over all canopy layers, [m m-1] layer_structure: The LayerStructure instance for the simulation. layer_heights: Vertical layer heights, [m] lower_bound: Minimum allowed value, used to constrain log interpolation. Note that currently no conservation of water and energy! upper_bound: Maximum allowed value, used to constrain log interpolation. gradient: Gradient of regression from :cite:t:`hardwick_relationship_2015` Returns: vertical logarithmic profile of provided variable """ # Calculate microclimatic variable at 1.5 m as function of leaf area index lai_regression = leaf_area_index_sum * gradient + reference_data # Avoid invalid heights positive_layer_heights = np.where(layer_heights > 0, layer_heights, np.nan) # Top height reference_height = positive_layer_heights[layer_structure.index_above] # Calculate per cell slope and intercept for logarithmic within-canopy profile slope = (reference_data - lai_regression) / (np.log(reference_height) - np.log(1.5)) intercept = lai_regression - slope * np.log(1.5) # Calculate the values within cells by layer layer_values = np.log(positive_layer_heights) * slope + intercept # set upper and lower bounds return_array = layer_structure.from_template() return_array[:] = np.clip(layer_values, lower_bound, upper_bound) return return_array
[docs] def exp_interpolation( reference_data: NDArray[np.floating], leaf_area_index_sum: NDArray[np.floating], layer_structure: LayerStructure, layer_heights: NDArray[np.floating], upper_bound: float, lower_bound: float, gradient: float, ) -> DataArray: """LAI regression and exponential interpolation of variables above ground. Args: reference_data: Input variable at reference height leaf_area_index_sum: Leaf area index summed over all canopy layers, [m m-1] layer_structure: The LayerStructure instance for the simulation. layer_heights: Vertical layer heights, [m] lower_bound: Minimum allowed value, used to constrain exp interpolation. Note that currently no conservation of water and energy! upper_bound: Maximum allowed value, used to constrain exp interpolation. gradient: Gradient of regression from :cite:t:`hardwick_relationship_2015` Returns: vertical exponential profile of provided variable """ # Value at 1.5 m from LAI regression lai_regression = leaf_area_index_sum * gradient + reference_data # Avoid invalid heights positive_layer_heights = np.where(layer_heights > 0, layer_heights, np.nan) # Top height reference_height = positive_layer_heights[layer_structure.index_above] # Normalized vertical coordinate: 0 at canopy top, 1 at 1.5 m relative_canopy_depth = (reference_height - positive_layer_heights) / ( reference_height - 1.5 ) # Normalized exponential profile exp_profile = (1 - np.exp(-gradient * relative_canopy_depth)) / ( 1 - np.exp(-gradient) ) # Interpolate between top and bottom layer_values = (lai_regression - reference_data) * exp_profile + reference_data # Apply bounds return_array = layer_structure.from_template() return_array[:] = np.clip(layer_values, lower_bound, upper_bound) return return_array
[docs] def calculate_vapour_pressure_deficit( temperature: DataArray, relative_humidity: DataArray, pyrealm_core_constants: PyrealmCoreConst, ) -> dict[str, DataArray]: """Calculate vapour pressure and vapour pressure deficit, kPa. Vapor pressure deficit is defined as the difference between saturated vapour pressure and actual vapour pressure. Args: temperature: temperature, [C] relative_humidity: relative humidity, [] pyrealm_core_constants: Set of core constants from pyrealm which include factors for saturation vapour pressure calculation Return: vapour pressure, [kPa], vapour pressure deficit, [kPa] """ output = {} saturation_vapour_pressure_numpy = calc_vp_sat( ta=temperature.to_numpy(), core_const=pyrealm_core_constants, ) saturation_vapour_pressure = saturation_vapour_pressure_numpy actual_vapour_pressure = saturation_vapour_pressure * (relative_humidity / 100) output["vapour_pressure"] = actual_vapour_pressure output["vapour_pressure_deficit"] = ( saturation_vapour_pressure - actual_vapour_pressure ) return output
[docs] def interpolate_soil_temperature( layer_heights: DataArray, surface_temperature: DataArray, mean_annual_temperature: DataArray, layer_structure: LayerStructure, upper_bound: float, lower_bound: float, ) -> DataArray: """Interpolate soil temperature using logarithmic function. Args: layer_heights: Vertical layer heights, [m] layer_roles: List of layer roles (from top to bottom: above, canopy, subcanopy, surface, soil) surface_temperature: Surface temperature, [C] mean_annual_temperature: Mean annual temperature, [C] layer_structure: The LayerStructure instance for the simulation. upper_bound: Maximum allowed value, used to constrain log interpolation. Note that currently no conservation of water and energy! lower_bound: Minimum allowed value, used to constrain log interpolation. Returns: soil temperature profile, [C] """ # Select surface layer (atmosphere) and generate interpolation heights surface_layer = layer_heights[layer_structure.index_surface].to_numpy() soil_depths = layer_heights[layer_structure.index_all_soil].to_numpy() interpolation_heights = np.concatenate( [surface_layer, -1 * soil_depths + surface_layer] ) # Calculate per cell slope and intercept for logarithmic soil temperature profile slope = (surface_temperature.to_numpy() - mean_annual_temperature.to_numpy()) / ( np.log(interpolation_heights[0]) - np.log(interpolation_heights[-1]) ) intercept = surface_temperature.to_numpy() - slope * np.log( interpolation_heights[0] ) # Calculate the values within cells by layer and clip by the bounds layer_values = np.clip( np.log(interpolation_heights) * slope + intercept, lower_bound, upper_bound ) # return return_xarray = layer_structure.from_template() return_xarray[layer_structure.index_all_soil] = layer_values[1:] return return_xarray