File size: 13,871 Bytes
938949f 0231141 938949f 0231141 938949f 0231141 938949f 15be6bb 938949f 15be6bb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 | """
FarquharModel: mechanistic photosynthesis (Farquhar et al. 1980) with
Greer & Weedon (2012) temperature response for Vitis vinifera cv. Semillon.
Uses only on-site sensor inputs (PAR, Tleaf, CO2, VPD, Tair, etc.).
Parameters calibrated from:
Greer, D.H. & Weedon, M.M. (2012) Modelling photosynthetic responses to
temperature of grapevine (Vitis vinifera cv. Semillon) leaves on vines grown
in a hot climate. Plant, Cell & Environment 35, 1050-1064.
"""
from typing import Optional
import numpy as np
import pandas as pd
# Gas constant J/(mol·K)
R = 8.314
# O2 concentration at chloroplast (mmol mol-1)
OI = 210.0
# Curvature of J vs light (dimensionless)
THETA = 0.9
# Quantum efficiency (mol e- per mol photons) base; PRI can scale this
ALPHA_DEFAULT = 0.24
# Dark respiration as fraction of Vcmax
RD_FRAC = 0.015
# --- Greer & Weedon (2012) Table / Fig 11 fitted parameters ---
# Cc-based values from paper: k25_Vcmax=38.5, k25_Jmax=98.3
# Ha/Hd (activation/deactivation energies) and Topt from Arrhenius fitting.
# NOTE: We use Ci-based apparent k25 values (60.0, 120.0) scaled ~1.5x from the
# paper's Cc-based values to compensate for mesophyll conductance (gm=5-10
# µmol/m²/s/Pa, paper p.1054) not modelled explicitly. The temperature SHAPE
# (Ha, Hd, Topt) is preserved from the paper.
_GW12_VCMAX = dict(k25=60.0, Ha=87700.0, Hd=203500.0, Topt=312.15)
_GW12_JMAX = dict(k25=120.0, Ha=63500.0, Hd=202900.0, Topt=309.05)
def _entropy_from_topt(Ha: float, Hd: float, Topt: float) -> float:
"""Derive entropy term S from Topt using Medlyn et al. (2002) Eqn 9."""
return (Hd / Topt) + R * np.log(Ha / (Hd - Ha))
# Pre-compute entropy terms
_GW12_VCMAX["S"] = _entropy_from_topt(
_GW12_VCMAX["Ha"], _GW12_VCMAX["Hd"], _GW12_VCMAX["Topt"]
)
_GW12_JMAX["S"] = _entropy_from_topt(
_GW12_JMAX["Ha"], _GW12_JMAX["Hd"], _GW12_JMAX["Topt"]
)
def _modified_arrhenius(Tk: float, k25: float, Ha: float, Hd: float, S: float) -> float:
"""Modified Arrhenius with high-temperature deactivation (Medlyn et al. 2002 Eqn 8).
k(T) = k25 * exp(Ha*(Tk-298.15)/(298.15*R*Tk))
* (1 + exp((298.15*S - Hd)/(298.15*R)))
/ (1 + exp((S*Tk - Hd)/(R*Tk)))
"""
# Activation component
exp_ha = np.exp(Ha * (Tk - 298.15) / (298.15 * R * Tk))
# Deactivation at reference temperature (normalisation)
denom_25 = 1.0 + np.exp((298.15 * S - Hd) / (298.15 * R))
# Deactivation at leaf temperature
denom_tk = 1.0 + np.exp((S * Tk - Hd) / (R * Tk))
return k25 * exp_ha * denom_25 / denom_tk
class FarquharModel:
"""
Farquhar et al. (1980) with Bernacchi et al. (2001) temperature functions
and Greer & Weedon (2012) Vcmax/Jmax for grapevine.
"""
def __init__(
self,
k25_vcmax: float = _GW12_VCMAX["k25"],
k25_jmax: float = _GW12_JMAX["k25"],
Ha_vcmax: float = _GW12_VCMAX["Ha"],
Hd_vcmax: float = _GW12_VCMAX["Hd"],
S_vcmax: float = _GW12_VCMAX["S"],
Ha_jmax: float = _GW12_JMAX["Ha"],
Hd_jmax: float = _GW12_JMAX["Hd"],
S_jmax: float = _GW12_JMAX["S"],
alpha: float = ALPHA_DEFAULT,
theta: float = THETA,
rd_frac: float = RD_FRAC,
):
self.params = {
"k25_vcmax": k25_vcmax,
"k25_jmax": k25_jmax,
"Ha_vcmax": Ha_vcmax,
"Hd_vcmax": Hd_vcmax,
"S_vcmax": S_vcmax,
"Ha_jmax": Ha_jmax,
"Hd_jmax": Hd_jmax,
"S_jmax": S_jmax,
"alpha": alpha,
"theta": theta,
"rd_frac": rd_frac,
}
@staticmethod
def calc_Kc(Tk: float) -> float:
"""Michaelis constant for CO2 (Bernacchi et al. 2001), in ppm scale."""
return np.exp(38.05 - 79430.0 / (R * Tk))
@staticmethod
def calc_Ko(Tk: float) -> float:
"""Michaelis constant for O2 (Bernacchi et al. 2001)."""
return np.exp(20.30 - 36380.0 / (R * Tk)) * 1000.0 # scale to match OI
@staticmethod
def calc_gamma_star(Tk: float) -> float:
"""CO2 compensation point (Bernacchi et al. 2001), ppm."""
return np.exp(19.02 - 37830.0 / (R * Tk))
def calc_Vcmax(self, Tk: float) -> float:
"""Vcmax at leaf temperature (modified Arrhenius, Greer & Weedon 2012)."""
return _modified_arrhenius(
Tk,
self.params["k25_vcmax"],
self.params["Ha_vcmax"],
self.params["Hd_vcmax"],
self.params["S_vcmax"],
)
def calc_Jmax(self, Tk: float) -> float:
"""Jmax at leaf temperature (modified Arrhenius, Greer & Weedon 2012)."""
return _modified_arrhenius(
Tk,
self.params["k25_jmax"],
self.params["Ha_jmax"],
self.params["Hd_jmax"],
self.params["S_jmax"],
)
def calc_electron_transport(self, PAR: float, Jmax: float) -> float:
"""Solve theta*J^2 - (alpha*PFD + Jmax)*J + alpha*PFD*Jmax = 0 for J."""
alpha = self.params["alpha"]
theta = self.params["theta"]
if PAR <= 0 or Jmax <= 0:
return 0.0
pfd = PAR # umol photons m-2 s-1
b = alpha * pfd + Jmax
c = alpha * pfd * Jmax
disc = b * b - 4 * theta * c
if disc < 0:
return min(alpha * pfd, Jmax)
j = (b - np.sqrt(disc)) / (2 * theta)
return float(np.clip(j, 0, Jmax))
def calc_CWSI(
self,
Tleaf: float,
Tair: float,
VPD: float,
dTmin: Optional[float] = None,
dTmax: Optional[float] = None,
) -> float:
"""Crop Water Stress Index. dTmin/dTmax from data or defaults."""
dT = Tleaf - Tair
if dTmin is None:
dTmin = -2.0
if dTmax is None:
dTmax = 8.0
if dTmax <= dTmin:
return 0.0
cwsi = (dT - dTmin) / (dTmax - dTmin)
return float(np.clip(cwsi, 0.0, 1.0))
def _ci_from_ca(self, ca: float, VPD: float, CWSI: float = 0.0) -> float:
"""Intercellular CO2 from ambient; gs reduced by VPD and CWSI.
Calibrated so ci/ca ~ 0.7 at low VPD (Greer & Weedon 2012 Fig 2c),
declining with increasing VPD and water stress.
"""
vpd_scale = np.exp(-0.3 * max(0, VPD - 1.0)) if VPD is not None else 1.0
stress = 1.0 - 0.5 * (CWSI if CWSI is not None else 0.0)
gs_factor = 2.1 * vpd_scale * stress
if gs_factor <= 0:
return ca * 0.3
ci = ca * (1.0 - 1.0 / (1.6 * gs_factor))
return float(np.clip(ci, ca * 0.3, ca))
def _compute_rates(
self,
PAR: float,
Tleaf: float,
CO2: float,
VPD: float,
CWSI: Optional[float] = None,
) -> tuple[float, float, float]:
"""Shared FvCB core: compute Rubisco-limited (Ac), RuBP-limited (Aj), and dark respiration (Rd).
Returns (Ac, Aj, Rd) — all in umol CO2 m-2 s-1.
"""
Tk = Tleaf + 273.15
Kc = self.calc_Kc(Tk)
Ko = self.calc_Ko(Tk)
gamma_star = self.calc_gamma_star(Tk)
Vcmax = self.calc_Vcmax(Tk)
Jmax = self.calc_Jmax(Tk)
J = self.calc_electron_transport(PAR, Jmax)
Rd = self.params["rd_frac"] * Vcmax
ci = self._ci_from_ca(CO2, VPD, CWSI)
Ac = Vcmax * (ci - gamma_star) / (ci + Kc * (1.0 + OI / Ko))
Aj = J * (ci - gamma_star) / (4.0 * ci + 8.0 * gamma_star)
return Ac, Aj, Rd
def calc_photosynthesis(
self,
PAR: float,
Tleaf: float,
CO2: float,
VPD: float,
Tair: float,
CWSI: Optional[float] = None,
) -> float:
"""
Net assimilation A (umol CO2 m-2 s-1). PAR in umol m-2 s-1, T in degC,
CO2 in ppm, VPD in kPa.
"""
Ac, Aj, Rd = self._compute_rates(PAR, Tleaf, CO2, VPD, CWSI)
A = min(Ac, Aj) - Rd
return float(max(0.0, A))
def calc_photosynthesis_semillon(
self,
PAR: float,
Tleaf: float,
CO2: float,
VPD: float,
Tair: float,
CWSI: Optional[float] = None,
transition_temp: Optional[float] = None,
) -> tuple[float, str, bool]:
"""
FvCB with explicit Semillon state transition.
Returns (A, limiting_state, shading_helps):
- A: net assimilation (umol CO2 m-2 s-1), clipped to >= 0
- limiting_state: "RuBP_Limited" or "Rubisco_Limited"
- shading_helps: True ONLY when the vine is Rubisco-limited AND
light is abundant relative to Vcmax capacity (Aj > Ac), meaning
reducing PAR would lower Aj toward Ac without reducing A.
When False, shading would reduce A — keep panels tracking.
Parameters
----------
PAR : float
Photosynthetically active radiation (umol photons m-2 s-1).
Tleaf : float
Leaf temperature (°C).
CO2 : float
Ambient CO2 (ppm).
VPD : float
Vapour pressure deficit (kPa).
Tair : float
Air temperature (°C).
CWSI : float, optional
Crop Water Stress Index (0–1). Default 0 (no stress).
transition_temp : float, optional
Semillon RuBP→Rubisco transition temperature (°C).
Default from config: SEMILLON_TRANSITION_TEMP_C.
"""
if transition_temp is None:
from config.settings import SEMILLON_TRANSITION_TEMP_C
transition_temp = SEMILLON_TRANSITION_TEMP_C
Ac, Aj, Rd = self._compute_rates(PAR, Tleaf, CO2, VPD, CWSI or 0.0)
An = min(Ac, Aj) - Rd
# Smooth sigmoid transition (28–32°C) instead of hard threshold.
# rubisco_weight = 0 below 28°C, 1 above 32°C, sigmoid in between.
import math
_TRANSITION_WIDTH = 2.0 # °C half-width of sigmoid zone
rubisco_weight = 1.0 / (1.0 + math.exp(-(Tleaf - transition_temp) / (_TRANSITION_WIDTH / 2.5)))
if rubisco_weight < 0.3:
state = "RuBP_Limited"
elif rubisco_weight > 0.7:
state = "Rubisco_Limited"
else:
state = "Transition"
# shading_helps is weighted: only meaningful when Rubisco-limited
# AND light is abundant relative to enzyme capacity (Aj > Ac)
shading_helps = rubisco_weight > 0.5 and (Aj > Ac)
return float(max(0.0, An)), state, shading_helps
def compute_all(
self,
df: pd.DataFrame,
par_col: str = "Air1_PAR_ref",
tleaf_col: str = "Air1_leafTemperature_ref",
co2_col: str = "Air1_CO2_ref",
vpd_col: str = "Air1_VPD_ref",
tair_col: str = "Air1_airTemperature_ref",
humidity_col: Optional[str] = "Air1_airHumidity_ref",
) -> pd.Series:
"""
Compute A for each row using vectorized pandas operations (~100x faster).
Returns Series of A (umol CO2 m-2 s-1), index aligned to df.
"""
required = [par_col, tleaf_col, co2_col, vpd_col, tair_col]
for c in required:
if c not in df.columns:
return pd.Series(np.nan, index=df.index)
# Extract columns as float arrays
par = df[par_col].astype(float)
tleaf = df[tleaf_col].astype(float)
co2 = df[co2_col].astype(float)
vpd = df[vpd_col].astype(float)
tair = df[tair_col].astype(float)
# Vectorized CWSI from (Tleaf - Tair) with empirical bounds
dT = tleaf - tair
n_valid = dT.notna().sum()
dTmin = float(dT.quantile(0.05)) if n_valid > 10 else -2.0
dTmax = float(dT.quantile(0.95)) if n_valid > 10 else 8.0
if dTmax <= dTmin:
cwsi = pd.Series(0.0, index=df.index)
else:
cwsi = ((dT - dTmin) / (dTmax - dTmin)).clip(0.0, 1.0)
# Vectorized FvCB computation
Tk = tleaf + 273.15
# Michaelis constants (Bernacchi et al. 2001) — vectorized
Kc = np.exp(38.05 - 79430.0 / (R * Tk))
Ko = np.exp(20.30 - 36380.0 / (R * Tk)) * 1000.0
gamma_star = np.exp(19.02 - 37830.0 / (R * Tk))
# Vcmax and Jmax (modified Arrhenius) — vectorized
Vcmax = _modified_arrhenius(Tk, self.params["k25_vcmax"], self.params["Ha_vcmax"],
self.params["Hd_vcmax"], self.params["S_vcmax"])
Jmax = _modified_arrhenius(Tk, self.params["k25_jmax"], self.params["Ha_jmax"],
self.params["Hd_jmax"], self.params["S_jmax"])
# Electron transport J — vectorized quadratic solve
alpha = self.params["alpha"]
theta = self.params["theta"]
b = alpha * par + Jmax
c_val = alpha * par * Jmax
disc = b * b - 4 * theta * c_val
disc_safe = disc.clip(lower=0)
J = ((b - np.sqrt(disc_safe)) / (2 * theta)).clip(lower=0)
J = np.minimum(J, Jmax)
J = J.where(par > 0, 0.0)
# Dark respiration
Rd = self.params["rd_frac"] * Vcmax
# Intercellular CO2 — vectorized ci/ca
vpd_scale = np.exp(-0.3 * (vpd - 1.0).clip(lower=0))
stress = 1.0 - 0.5 * cwsi.fillna(0)
gs_factor = 2.1 * vpd_scale * stress
ci = co2 * (1.0 - 1.0 / (1.6 * gs_factor.clip(lower=0.01)))
ci = ci.clip(lower=co2 * 0.3, upper=co2)
# Rubisco-limited (Ac) and RuBP-limited (Aj) rates
Ac = Vcmax * (ci - gamma_star) / (ci + Kc * (1.0 + OI / Ko))
Aj = J * (ci - gamma_star) / (4.0 * ci + 8.0 * gamma_star)
# Net assimilation
An = np.minimum(Ac, Aj) - Rd
An = An.clip(lower=0.0)
# NaN where any input was NaN
valid = par.notna() & tleaf.notna() & co2.notna() & vpd.notna() & tair.notna()
An = An.where(valid, np.nan)
return An
|