Using the Two Leaf, Two Stream model

Run this notebook

from importlib import resources

import xarray
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize
import matplotlib.cm as cm
import matplotlib.dates as mdates

from pyrealm.core.solar import SolarPositions
from pyrealm.pmodel.pmodel import PModel, SubdailyPModel
from pyrealm.pmodel.pmodel_environment import PModelEnvironment
from pyrealm.pmodel.acclimation import AcclimationModel
from pyrealm.pmodel.two_leaf import TwoLeafIrradiance, TwoLeafAssimilation

from pyrealm.core.hygro import convert_sh_to_vpd

This page shows how to use the two leaf, two stream model of assimilation (De Pury and Farquhar, 1997) in pyrealm. The standard and subdaily P Model use the big leaf approximation of the canopy structure: assimilation is estimated as if all the available light falls directly as a beam onto a single large leaf. In the two leaf, two stream model, De Pury and Farquhar (1997) differentiate the absorbance of light into sunlit and shaded leaves (‘two leaf’) and also into direct and scattered radiation (‘two streams’). Within the canopy, the model separates out beam, scattered and diffuse irradiation and estimates how much of each stream is absorbed by the sunlit and shaded leaves.

The example code below uses the same dataset as the subdaily worked example, which is a two month extract of WFDE5 data for the United Kingdom:

  • time: 2018-06-01 00:00 to 2018-07-31 23:00 at 1 hour resolution.

  • longitude: 10°W to 4°E at 0.5° resolution.

  • latitude: 49°N to 60°N at 0.5° resolution

The code uses a subset of this wider arrays data to three sites to plot time series.

# Loading the example dataset:
dpath = (
    resources.files("pyrealm_build_data.uk_data") / "UK_WFDE5_FAPAR_2018_JuneJuly.nc"
)
ds = xarray.load_dataset(dpath)

datetimes = ds["time"]

# Define three sites for showing time series
sites = xarray.Dataset(
    data_vars=dict(
        lat=(["stid"], [50.73, 53.93, 58.03]), lon=(["stid"], [-3.52, -0.79, -4.41])
    ),
    coords=dict(stid=(["Exeter", "Pocklington", "Lairg"])),
)

The WFDE data need some conversion for use in the PModel, along with the definition of the atmospheric CO2 concentration.

# Variable set up
# Air temperature in °C from Tair in Kelvin
tc = ds["Tair"] - 273.15
# Atmospheric pressure in Pascals
patm = ds["PSurf"]
# Convert specific humidity to VPD and remove negative values
vpd = convert_sh_to_vpd(sh=ds["Qair"], ta=tc, patm=patm / 1000) * 1000
vpd = np.clip(vpd, 0, np.inf)
# Extract fAPAR (unitless)
fapar = ds["fAPAR"]
# Convert SW downwelling radiation from W/m^2 to PPFD µmole/m2/s1
ppfd = ds["SWdown"] * 2.04
# Define atmospheric CO2 concentration (ppm)
co2 = np.ones_like(tc) * 400

Fitting big leaf models

In pyrealm, we use an initial big leaf model (either a standard or subdaily P Model) to estimate four parameters that are required for the two leaf, two stream model. These are:

  • The maximum rates of carboxylation at the observation and standard temperatures (\(V_{cmax}\) and \(V_{cmax25}\)).

  • The limitation terms on rates of carboxylation (\(m_c\)) and electron transfer (\(m_j\)) from the calculation of optimal chi.

So the first step in calculating two leaf, two stream estimates is to fit a P Model:

# Generate and check the PModelEnvironment
pm_env = PModelEnvironment(tc=tc, patm=patm, vpd=vpd, co2=co2, fapar=fapar, ppfd=ppfd)

# Fit a standard P Model
standard_bigleaf = PModel(
    env=pm_env,
    method_kphio="fixed",
    method_optchi="prentice14",
)

# Create an acclimation model to fit a subdaily model
acclim_model = AcclimationModel(datetimes, alpha=1 / 15, allow_holdover=True)
acclim_model.set_window(
    window_center=np.timedelta64(12, "h"),
    half_width=np.timedelta64(1, "h"),
)

# Fit a Subdaily P Model
subdaily_bigleaf = SubdailyPModel(
    env=pm_env,
    acclim_model=acclim_model,
    method_optchi="prentice14",
    method_kphio="fixed",
)
/home/docs/checkouts/readthedocs.org/user_builds/pyrealm/checkouts/latest/pyrealm/pmodel/pmodel.py:481: UserWarning: 
    Pyrealm 2.0.0 uses a new default for the quantum yield of photosynthesis (phi0=1/8).
    You may need to change settings to duplicate results from pyrealm 1.0.0.
            
  warn(

Estimating solar elevation and irradiances

In contrast to the big leaf model, where light hits a single flat surface, the two leaf, two stream model models a canopy with depth and where the behaviour of light varies with the angle of solar elevation. Lower angles lead to more light scattering, because of the longer light path through the atmosphere, and then light penetration into the canopy varies with incident angle.

The SolarPositions class can be used to calculate solar elevation for observations, given the latitude, longitude and time of an observation. The code below takes the latitude, longitude and time coordinates of the example data and converts them into three dimensional arrays, so that the solar elevations of all observations can be calculated.

# Convert latitude, longitude and time coordinates into 3D arrays
datetime_array, latitude_array, longitude_array = np.meshgrid(
    datetimes, ds.lat, ds.lon, indexing="ij"
)

# Calculate solar positions
solar_pos = SolarPositions(
    latitude=latitude_array,
    longitude=longitude_array,
    datetime=datetime_array,
)

The SolarPositions object contains more detailed solar data (such as the hour angle), but the critical parameter for the two leaf, two stream model is the solar elevation.

The plot below shows the resulting solar elevation curves for three sites from the gridded data. The elevations show the expected patterns for the summer in the Northern Hemisphere: more northly sites have lower consistently elevation angles but also have shorter night times (solar elevation < 0°).

# Store the predictions in the xarray Dataset to use indexing
ds["solar_elevation"] = (ds["Tair"].dims, solar_pos.solar_elevation)

# Get a four day time series of the three sites
site_ds = ds.sel(sites, method="nearest")
site_ds = site_ds.where(site_ds.time < np.datetime64("2018-06-05"), drop=False)

# Plot solar elevation for each site
fig, ax = plt.subplots(figsize=(12, 6))
for st in sites["stid"].values:

    ax.plot(
        site_ds["time"],
        np.rad2deg(site_ds["solar_elevation"].sel(stid=st)),
        label=st,
    )

ax.axhline(0, linestyle="--", color="k")
ax.set_xlabel("Datetime")
ax.set_ylabel("Solar elevation angle (°)")
plt.legend(frameon=False)
plt.tight_layout()
../../../_images/bae9b639b86062b2a68fcab5a0676047ee5a15f66bce52d2b9079668dec7581b.png

These solar elevation values can then be used to calculate the irradiances absorbed by sunlit and shaded leaves within the two-leaf, two-stream models. These values are independent of the type of P Model being used, so the calculations can be used across multiple P Models. The irradiance calculation requires:

irradiances = TwoLeafIrradiance(
    solar_elevation=solar_pos.solar_elevation,
    ppfd=ppfd,
    leaf_area_index=2,
    patm=patm,
)
/home/docs/checkouts/readthedocs.org/user_builds/pyrealm/checkouts/latest/pyrealm/core/experimental.py:72: ExperimentalFeatureWarning: 'Be aware that TwoLeafIrradiance is an experimental feature of pyrealm and the implementation and API may change within major versions.'
  warn(qualname, ExperimentalFeatureWarning)
/home/docs/checkouts/readthedocs.org/user_builds/pyrealm/checkouts/latest/pyrealm/pmodel/two_leaf.py:328: RuntimeWarning: overflow encountered in power
  f_d = (1 - atmos_transmission_par**m) / (
/home/docs/checkouts/readthedocs.org/user_builds/pyrealm/checkouts/latest/pyrealm/pmodel/two_leaf.py:329: RuntimeWarning: overflow encountered in power
  1 + (atmos_transmission_par**m * (1 / atmospheric_scattering - 1))
/home/docs/checkouts/readthedocs.org/user_builds/pyrealm/checkouts/latest/pyrealm/pmodel/two_leaf.py:328: RuntimeWarning: invalid value encountered in divide
  f_d = (1 - atmos_transmission_par**m) / (
/home/docs/checkouts/readthedocs.org/user_builds/pyrealm/checkouts/latest/pyrealm/pmodel/two_leaf.py:337: UserWarning: Negative diffuse radiation fractions clamped to zero.
  warn("Negative diffuse radiation fractions clamped to zero.")

The plot below shows the resulting absorbed radiation for the two leaf types for the three sites.

Caution

The shortwave downwelling radiation used here is a “ground level” estimate accounting for cloud cover, which is why the plots show a jagged profile. This may not be appropriate for use with the two leaf, two stream model.

# Store the predictions in the xarray Dataset to use indexing
ds["sunlit_absorbed_irradiance"] = (
    ds["Tair"].dims,
    irradiances.sunlit_absorbed_irradiance,
)
ds["shaded_absorbed_irradiance"] = (
    ds["Tair"].dims,
    irradiances.shaded_absorbed_irradiance,
)


# Get a four day time series of the three sites
site_ds = ds.sel(sites, method="nearest")
site_ds = site_ds.where(site_ds.time < np.datetime64("2018-06-05"), drop=False)

# Plot solar elevation for each site
fig, ax = plt.subplots(figsize=(12, 6))
for st, col in zip(sites["stid"].values, ("C0", "C1", "C2")):

    ax.plot(
        site_ds["time"],
        np.rad2deg(site_ds["sunlit_absorbed_irradiance"].sel(stid=st)),
        label=st + " (sunlit)",
        color=col,
    )

    ax.plot(
        site_ds["time"],
        np.rad2deg(site_ds["shaded_absorbed_irradiance"].sel(stid=st)),
        label=st + " (shaded)",
        linestyle="--",
        color=col,
    )

ax.set_xlabel("Datetime")
ax.set_ylabel("Irradiances")
plt.legend(frameon=False)
plt.tight_layout()
../../../_images/74b7bfc8d85a0ee8120886632b4b6e3e736357b18467effb9fee4f810940996b.png

Assimilation under the two leaf, two stream model

The last step is to calculate the assimilation resulting from those irradiance values, given the estimated photosynthetic behaviour from the P Model, using the TwoLeafAssimilation class. A detailed description of the calculations are given in the API documentation for the class, but in brief:

  • The carboxylation capacity (\(V_cmax\)) varies with depth in the canopy, where we use leaf area index (\(L\)) to represent canopy depth (Lloyd et al., 2010).

  • The resulting standardized carboxylation capacity \(V_{cmax25}\) through the canopy is partitioned between sunlit and shaded leaves.

  • Currently, the standardized electron transfer capacity \(J_{max25}\) is calculated as an empirical function of \(V_{cmax25}\) for sunlit and shaded leaves (Wullschleger, 1993).

  • The Arrhenius scaling method used with the P Model is then used to adjust these estimates at standard temperature to the actual temperature. estimates to the observed temperatures.

  • Separately for sunlit and shaded leaves, the limitation terms from the calculation of optimal \(\chi\) are then used to calculate the actual maximum assimilation via the carboxylation (\(A_v\)) and electron transfer (\(A_j\)) pathways and then the realised assimilation (\(A = \min \left( A_{v}, A_{j} \right)\)).

  • The sum of the realised sunlit and shaded assimilation then gives the total assimilation, which is multiplied by the molar mass of carbon to express GPP as µg C m-2 s-1.

# Calculate the two leaf assimilation
standard_two_leaf = TwoLeafAssimilation(pmodel=standard_bigleaf, irradiance=irradiances)
subdaily_two_leaf = TwoLeafAssimilation(pmodel=subdaily_bigleaf, irradiance=irradiances)
/home/docs/checkouts/readthedocs.org/user_builds/pyrealm/checkouts/latest/pyrealm/core/experimental.py:72: ExperimentalFeatureWarning: 'Be aware that TwoLeafAssimilation is an experimental feature of pyrealm and the implementation and API may change within major versions.'
  warn(qualname, ExperimentalFeatureWarning)
# Store the predictions in the xarray Dataset to use indexing
ds["standard_big_leaf"] = (ds["Tair"].dims, standard_bigleaf.gpp)
ds["subdaily_big_leaf"] = (ds["Tair"].dims, subdaily_bigleaf.gpp)
ds["standard_two_leaf"] = (ds["Tair"].dims, standard_two_leaf.gpp)
ds["subdaily_two_leaf"] = (ds["Tair"].dims, subdaily_two_leaf.gpp)
# Get a four day time series of the three sites
site_ds = ds.sel(sites, method="nearest")
site_ds = site_ds.where(site_ds.time < np.datetime64("2018-06-05"), drop=False)

# Plot solar elevation for each site
fig, axes = plt.subplots(nrows=3, ncols=2, figsize=(10, 8), sharex=True, sharey=True)

for (lax, rax), st in zip(axes, sites["stid"].values):

    lax.plot(
        site_ds["time"],
        site_ds["standard_big_leaf"].sel(stid=st),
    )
    lax.plot(
        site_ds["time"],
        site_ds["standard_two_leaf"].sel(stid=st),
    )

    rax.plot(
        site_ds["time"],
        site_ds["subdaily_big_leaf"].sel(stid=st),
        label="Big leaf",
    )

    rax.plot(
        site_ds["time"],
        site_ds["subdaily_two_leaf"].sel(stid=st),
        label="Two leaf",
    )

    # Annotations
    lax.set_ylabel("GPP (µg C m-2 s-1)")
    rax.text(0.95, 0.9, st, ha="right", transform=rax.transAxes)
    if st == "Exeter":
        lax.set_title("Standard P Model")
        rax.set_title("Subdaily P Model")
        rax.legend(frameon=False)


plt.tight_layout()
../../../_images/1040a4aa5af6bc724e9bf576f7b5e4b8d5debbc348b51f11887a2831a55eafb7.png

The plots above have extracted site-specific time series to show temporal patterns, but the calculations of GPP have been conducted across the whole of the spatial grid. The plot below shows the comparative estimates of GPP from the four model fitted for a noon observation.

single_time = ds.where(ds.time == np.datetime64("2018-06-04 12:00:00"), drop=True)

# Shared image scale
gpp_data = np.concatenate(
    [
        single_time["standard_big_leaf"],
        single_time["subdaily_big_leaf"],
        single_time["standard_two_leaf"],
        single_time["subdaily_two_leaf"],
    ]
)
gpp_min = np.nanmin(gpp_data)
gpp_max = np.nanmax(gpp_data)


fig, axes = plt.subplots(
    ncols=2, nrows=2, layout="constrained", sharex=True, sharey=True
)

ax1, ax2, ax3, ax4 = axes.flatten()
cm = ax1.imshow(
    single_time["standard_big_leaf"].squeeze(),
    vmin=gpp_min,
    vmax=gpp_max,
    origin="lower",
)
ax2.imshow(
    single_time["subdaily_big_leaf"].squeeze(),
    vmin=gpp_min,
    vmax=gpp_max,
    origin="lower",
)
ax3.imshow(
    single_time["standard_two_leaf"].squeeze(),
    vmin=gpp_min,
    vmax=gpp_max,
    origin="lower",
)
ax4.imshow(
    single_time["subdaily_two_leaf"].squeeze(),
    vmin=gpp_min,
    vmax=gpp_max,
    origin="lower",
)

labels = (
    "Standard\nBig Leaf",
    "Subdaily\nBig Leaf",
    "Standard\nTwo Leaf",
    "Subdaily\nTwo Leaf",
)

for ax, lab in zip(axes.flatten(), labels):
    ax.text(0.95, 0.9, lab, va="top", ha="right", transform=ax.transAxes)


_ = plt.colorbar(
    cm, ax=axes[:, 1], location="right", shrink=0.6, label="GPP (µg C m-2 s-1)"
)
../../../_images/e3b140ebcde156fc5d1539574409457f36fc37165fe28c029d7ddaaf3007da0d.png