leaf_dynamics¶
Leaf area growth and senescence.
Implements daily leaf area growth (GLA), leaf death (DEATHL), and the specific leaf area (SLA) computation that links leaf mass to leaf area.
Growth regimes¶
Three regimes determine the daily LAI growth, with precedence emergence > juvenile > mature:
- Emergence (
LAI == 0):GLAI = LAII / DELT. - Juvenile (
DVS < 0.2andLAI < 0.75): exponential growthGLAI = LAI · (exp(RGRLAI · DTEFF) − 1) · TRANRF · exp(−NLAI · (1 − NPKI)). - Mature: source-limited
GLAI = SLA · GLV.
Senescence¶
Three independent death drivers are combined with max:
- Ageing/temperature:
RDRTMPis looked up from the mean air temperature and gated byDVSDLT. - Self-shading: activates above the critical LAI
LAICR. - Drought:
(1 − TRANRF) · RDRL.
Heat stress multiplies the resulting RDR, which is then capped at
1 d⁻¹. NPK-driven senescence is additive:
DLVNS = WLVG · RDRNS · (1 − NPKI), with DLAINS = DLVNS · SLA.
SLA itself carries an exp(−NSLA · (1 − NPKI)) reduction.
LeafDynamics (Module)
¶
Leaf area growth, senescence, dead-leaf accumulation.
Source code in torchcrop/processes/leaf_dynamics.py
class LeafDynamics(nn.Module):
"""Leaf area growth, senescence, dead-leaf accumulation."""
def forward(
self,
state: ModelState,
g_lv: torch.Tensor,
davtmp: torch.Tensor,
tranrf: torch.Tensor,
nstress: torch.Tensor,
params: CropParameters,
heat_stress: torch.Tensor | None = None,
emerg: torch.Tensor | None = None,
hybrid: "HybridManager | None" = None,
features: dict[str, torch.Tensor] | None = None,
) -> dict[str, torch.Tensor]:
"""Compute leaf area and leaf-biomass rates for one day.
Args:
state: Current state; uses ``state.lai``, ``state.wlv``,
``state.dvs``.
g_lv: Leaf growth allocated by partitioning
[g DM m⁻² d⁻¹], shape ``[B]``.
davtmp: Mean daily air temperature [°C], shape ``[B]``.
Drives ``RDRTMP`` via interpolation on ``rdrltb`` and the
juvenile-phase thermal time
``DTEFF = max(0, davtmp − tbase)`` (Lintul5.java:1436).
tranrf: Water-stress factor in ``[0, 1]``, shape ``[B]``.
nstress: NPK nutrient index ``NPKI`` in ``[0, 1]``,
shape ``[B]``.
params: Crop parameters; uses ``laicr``, ``rgrl``, ``tbase``,
``slatb``, ``scale_factor_sla``, ``nsla``, ``nlai``,
``laii``, ``rdrshm``, ``rdrl``, ``rdrns``, ``rdrltb``,
``scale_factor_rdr_leaves``, ``dvsdlt``.
heat_stress: Optional multiplicative heat-stress factor on
``RDR`` (default ``1.0``), broadcastable to ``[B]``.
emerg: Optional emergence mask in ``{0, 1}`` (broadcast to
``[B]``). When ``0``, both ``GLAI`` and ``DLV``/``DLAI``
are forced to zero so pre-emergence leaves neither grow
nor senesce. Defaults to
``state.tsump >= params.tsumem``.
hybrid: Optional `HybridManager`. When supplied together with
``features`` and a ``"leaf.rdr"`` slot is configured, the
relative death rate ``RDR`` receives a bounded multiplicative
residual correction before the ≤ 1 cap. ``None`` disables
the correction (pure mechanistic behaviour).
features: Optional per-day feature dict supplying the context
tensors for the ``"leaf.rdr"`` slot. Ignored when ``hybrid``
is ``None``.
Returns:
Dict of ``[B]`` tensors grouped as follows.
Rate variables (consumed by the engine for state update):
* ``lai_rate`` [m² m⁻² d⁻¹] — Net daily change in LAI
(``= glai − dlai``).
* ``wlv_rate`` [g DM m⁻² d⁻¹] — Net daily change in green
leaf weight (``= g_lv − dlv``).
* ``wlvd_rate`` [g DM m⁻² d⁻¹] — Daily senesced leaf mass
transferred into the dead-leaf pool ``wlvd``
(``= dlv``).
Diagnostics:
* ``lai_growth`` [m² m⁻² d⁻¹] — Daily LAI growth (GLA).
* ``lai_sen`` [m² m⁻² d⁻¹] — Daily LAI loss to
senescence (``DLAI = DLAIS + DLAINS``).
* ``rdr`` [d⁻¹] — Effective relative death rate (after
heat scaling and ≤ 1 cap), excluding the additive NPK
death.
* ``sla`` [m² g⁻¹] — Effective specific leaf area after
NPK reduction.
"""
lai = state.lai
wlv = state.wlv
dvs = state.dvs
if heat_stress is None:
heat_stress = torch.ones_like(lai)
if emerg is None:
emerg = (state.tsump >= params.tsumem).to(lai.dtype)
else:
emerg = emerg.to(lai.dtype)
# ----- SLA with NPK reduction -----
# SLA = cScaleFactorSLA * SLATB(DVS) * exp(-NSLA * (1 - NPKI))
sla_base = interpolate(params.slatb, dvs)
sla = params.scale_factor_sla * sla_base * torch.exp(
-params.nsla * (1.0 - nstress)
)
# ----- GLA: daily increase in leaf area index -----
# Branch precedence: emergence > juvenile > mature.
# Juvenile growth is driven by its own thermal time with base
# temperature TBASE (distinct from the phenology DTSMTB sum):
# DTEFF = max(0, davtmp - tbase)
dteff = torch.clamp(davtmp - params.tbase, min=0.0)
glai_mature = sla * g_lv
glai_juv = (
lai
* (torch.exp(params.rgrl * dteff) - 1.0)
* tranrf
* torch.exp(-params.nlai * (1.0 - nstress))
)
glai_emerg = torch.broadcast_to(params.laii, lai.shape)
juv_mask = (dvs < 0.2) & (lai < 0.75)
emerg_mask = lai <= 0.0
glai = torch.where(
emerg_mask,
glai_emerg,
torch.where(juv_mask, glai_juv, glai_mature),
)
# ----- DEATHL: relative death rates -----
rdrtmp = interpolate(params.rdrltb, davtmp) * params.scale_factor_rdr_leaves
rdrdv = torch.where(dvs < params.dvsdlt, torch.zeros_like(rdrtmp), rdrtmp)
rdrsh = torch.clamp(
params.rdrshm * (lai - params.laicr) / _safe(params.laicr),
min=0.0,
)
rdrdry = (1.0 - tranrf) * params.rdrl
rdr = torch.maximum(torch.maximum(rdrdv, rdrsh), rdrdry) * heat_stress
# Optional residual correction on the relative death rate, applied
# *before* the ≤ 1 cap. Correcting the rate constant (not the output
# flux) keeps the mass leg (``dlvs = wlv · rdr``) and the area leg
# (``dlais = lai · rdr``) consistent, so the green→dead-leaf transfer
# remains mass-conserving.
if hybrid is not None and features is not None:
rdr = hybrid.correct("leaf.rdr", rdr, features)
rdr = torch.clamp(rdr, max=1.0)
# Senescence from drivers (max of RDRDV / RDRSH / RDRDRY).
dlvs = wlv * rdr
dlais = lai * rdr
# Additive NPK senescence:
# DLVNS = WLVG * RDRNS * (1 - NPKI) when NPKI < 1
# DLAINS = DLVNS * SLA (mass → area via SLA,
# not via LAI/WLV ratio)
npki_deficit = torch.clamp(1.0 - nstress, min=0.0)
dlvns = wlv * params.rdrns * npki_deficit
dlains = dlvns * sla
dlv = dlvs + dlvns
dlai = dlais + dlains
# EMERG gating — GLA and DEATHL return 0 before emergence.
glai = glai * emerg
dlv = dlv * emerg
dlai = dlai * emerg
lai_rate = glai - dlai
wlv_rate = g_lv - dlv
return {
"lai_rate": lai_rate,
"wlv_rate": wlv_rate,
"wlvd_rate": dlv,
"lai_growth": glai,
"lai_sen": dlai,
"rdr": rdr,
"sla": sla,
}
forward(self, state, g_lv, davtmp, tranrf, nstress, params, heat_stress=None, emerg=None, hybrid=None, features=None)
¶
Compute leaf area and leaf-biomass rates for one day.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state |
ModelState |
Current state; uses |
required |
g_lv |
torch.Tensor |
Leaf growth allocated by partitioning
[g DM m⁻² d⁻¹], shape |
required |
davtmp |
torch.Tensor |
Mean daily air temperature [°C], shape |
required |
tranrf |
torch.Tensor |
Water-stress factor in |
required |
nstress |
torch.Tensor |
NPK nutrient index |
required |
params |
CropParameters |
Crop parameters; uses |
required |
heat_stress |
torch.Tensor | None |
Optional multiplicative heat-stress factor on
|
None |
emerg |
torch.Tensor | None |
Optional emergence mask in |
None |
hybrid |
'HybridManager | None' |
Optional |
None |
features |
dict[str, torch.Tensor] | None |
Optional per-day feature dict supplying the context
tensors for the |
None |
Returns:
| Type | Description |
|---|---|
Dict of ``[B]`` tensors grouped as follows.
Rate variables (consumed by the engine for state update) |
Diagnostics:
|
Source code in torchcrop/processes/leaf_dynamics.py
def forward(
self,
state: ModelState,
g_lv: torch.Tensor,
davtmp: torch.Tensor,
tranrf: torch.Tensor,
nstress: torch.Tensor,
params: CropParameters,
heat_stress: torch.Tensor | None = None,
emerg: torch.Tensor | None = None,
hybrid: "HybridManager | None" = None,
features: dict[str, torch.Tensor] | None = None,
) -> dict[str, torch.Tensor]:
"""Compute leaf area and leaf-biomass rates for one day.
Args:
state: Current state; uses ``state.lai``, ``state.wlv``,
``state.dvs``.
g_lv: Leaf growth allocated by partitioning
[g DM m⁻² d⁻¹], shape ``[B]``.
davtmp: Mean daily air temperature [°C], shape ``[B]``.
Drives ``RDRTMP`` via interpolation on ``rdrltb`` and the
juvenile-phase thermal time
``DTEFF = max(0, davtmp − tbase)`` (Lintul5.java:1436).
tranrf: Water-stress factor in ``[0, 1]``, shape ``[B]``.
nstress: NPK nutrient index ``NPKI`` in ``[0, 1]``,
shape ``[B]``.
params: Crop parameters; uses ``laicr``, ``rgrl``, ``tbase``,
``slatb``, ``scale_factor_sla``, ``nsla``, ``nlai``,
``laii``, ``rdrshm``, ``rdrl``, ``rdrns``, ``rdrltb``,
``scale_factor_rdr_leaves``, ``dvsdlt``.
heat_stress: Optional multiplicative heat-stress factor on
``RDR`` (default ``1.0``), broadcastable to ``[B]``.
emerg: Optional emergence mask in ``{0, 1}`` (broadcast to
``[B]``). When ``0``, both ``GLAI`` and ``DLV``/``DLAI``
are forced to zero so pre-emergence leaves neither grow
nor senesce. Defaults to
``state.tsump >= params.tsumem``.
hybrid: Optional `HybridManager`. When supplied together with
``features`` and a ``"leaf.rdr"`` slot is configured, the
relative death rate ``RDR`` receives a bounded multiplicative
residual correction before the ≤ 1 cap. ``None`` disables
the correction (pure mechanistic behaviour).
features: Optional per-day feature dict supplying the context
tensors for the ``"leaf.rdr"`` slot. Ignored when ``hybrid``
is ``None``.
Returns:
Dict of ``[B]`` tensors grouped as follows.
Rate variables (consumed by the engine for state update):
* ``lai_rate`` [m² m⁻² d⁻¹] — Net daily change in LAI
(``= glai − dlai``).
* ``wlv_rate`` [g DM m⁻² d⁻¹] — Net daily change in green
leaf weight (``= g_lv − dlv``).
* ``wlvd_rate`` [g DM m⁻² d⁻¹] — Daily senesced leaf mass
transferred into the dead-leaf pool ``wlvd``
(``= dlv``).
Diagnostics:
* ``lai_growth`` [m² m⁻² d⁻¹] — Daily LAI growth (GLA).
* ``lai_sen`` [m² m⁻² d⁻¹] — Daily LAI loss to
senescence (``DLAI = DLAIS + DLAINS``).
* ``rdr`` [d⁻¹] — Effective relative death rate (after
heat scaling and ≤ 1 cap), excluding the additive NPK
death.
* ``sla`` [m² g⁻¹] — Effective specific leaf area after
NPK reduction.
"""
lai = state.lai
wlv = state.wlv
dvs = state.dvs
if heat_stress is None:
heat_stress = torch.ones_like(lai)
if emerg is None:
emerg = (state.tsump >= params.tsumem).to(lai.dtype)
else:
emerg = emerg.to(lai.dtype)
# ----- SLA with NPK reduction -----
# SLA = cScaleFactorSLA * SLATB(DVS) * exp(-NSLA * (1 - NPKI))
sla_base = interpolate(params.slatb, dvs)
sla = params.scale_factor_sla * sla_base * torch.exp(
-params.nsla * (1.0 - nstress)
)
# ----- GLA: daily increase in leaf area index -----
# Branch precedence: emergence > juvenile > mature.
# Juvenile growth is driven by its own thermal time with base
# temperature TBASE (distinct from the phenology DTSMTB sum):
# DTEFF = max(0, davtmp - tbase)
dteff = torch.clamp(davtmp - params.tbase, min=0.0)
glai_mature = sla * g_lv
glai_juv = (
lai
* (torch.exp(params.rgrl * dteff) - 1.0)
* tranrf
* torch.exp(-params.nlai * (1.0 - nstress))
)
glai_emerg = torch.broadcast_to(params.laii, lai.shape)
juv_mask = (dvs < 0.2) & (lai < 0.75)
emerg_mask = lai <= 0.0
glai = torch.where(
emerg_mask,
glai_emerg,
torch.where(juv_mask, glai_juv, glai_mature),
)
# ----- DEATHL: relative death rates -----
rdrtmp = interpolate(params.rdrltb, davtmp) * params.scale_factor_rdr_leaves
rdrdv = torch.where(dvs < params.dvsdlt, torch.zeros_like(rdrtmp), rdrtmp)
rdrsh = torch.clamp(
params.rdrshm * (lai - params.laicr) / _safe(params.laicr),
min=0.0,
)
rdrdry = (1.0 - tranrf) * params.rdrl
rdr = torch.maximum(torch.maximum(rdrdv, rdrsh), rdrdry) * heat_stress
# Optional residual correction on the relative death rate, applied
# *before* the ≤ 1 cap. Correcting the rate constant (not the output
# flux) keeps the mass leg (``dlvs = wlv · rdr``) and the area leg
# (``dlais = lai · rdr``) consistent, so the green→dead-leaf transfer
# remains mass-conserving.
if hybrid is not None and features is not None:
rdr = hybrid.correct("leaf.rdr", rdr, features)
rdr = torch.clamp(rdr, max=1.0)
# Senescence from drivers (max of RDRDV / RDRSH / RDRDRY).
dlvs = wlv * rdr
dlais = lai * rdr
# Additive NPK senescence:
# DLVNS = WLVG * RDRNS * (1 - NPKI) when NPKI < 1
# DLAINS = DLVNS * SLA (mass → area via SLA,
# not via LAI/WLV ratio)
npki_deficit = torch.clamp(1.0 - nstress, min=0.0)
dlvns = wlv * params.rdrns * npki_deficit
dlains = dlvns * sla
dlv = dlvs + dlvns
dlai = dlais + dlains
# EMERG gating — GLA and DEATHL return 0 before emergence.
glai = glai * emerg
dlv = dlv * emerg
dlai = dlai * emerg
lai_rate = glai - dlai
wlv_rate = g_lv - dlv
return {
"lai_rate": lai_rate,
"wlv_rate": wlv_rate,
"wlvd_rate": dlv,
"lai_growth": glai,
"lai_sen": dlai,
"rdr": rdr,
"sla": sla,
}