Stewart (1977) Yield Response to Water Stress
Rendered from 008-yield-response-stewart.ipynb
Stewart (1977) Yield Response to Water Stress
Citation: Stewart, J.I. et al. (1977); documented in Allen et al. (1998) FAO-56, Chapter 10.
AirSpring linkage: primal science.yield_response; Rust binary validate_yield_response.
Sources: control/yield_response/yield_response.py + benchmark_yield_response.json.
The proportional deficit model relates actual to maximum yield through the seasonal water deficit and crop-specific sensitivity $K_\mathrm{y}$.
Theory
Following Stewart’s single-stage form (FAO-56 Chapter 10 presentation):
$$\left(1 - \frac{Y_\mathrm{a}}{Y_\mathrm{m}}\right) = K_\mathrm{y}\left(1 - \frac{ET_\mathrm{a}}{ET_\mathrm{m}}\right)$$
Equivalently, as implemented in airSpring controls:
$$\frac{Y_\mathrm{a}}{Y_\mathrm{m}} = 1 - K_\mathrm{y}\left(1 - \frac{ET_\mathrm{a}}{ET_\mathrm{c}}\right)$$
Multi-stage formulations multiply stage factors (FAO-56 Eq.\ 90). Water-use efficiency aggregates yield per unit actual evaporation: $\mathrm{WUE} = Y / ET_\mathrm{a}$ using the mm–ha volumetric conventions in yield_response.py.
import importlib.util
import json
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
NOTEBOOK_DIR = Path.cwd().resolve()
BENCH_PATH = (NOTEBOOK_DIR / "../../control/yield_response/benchmark_yield_response.json").resolve()
CONTROL_PATH = (NOTEBOOK_DIR / "../../control/yield_response/yield_response.py").resolve()
spec = importlib.util.spec_from_file_location("yr", CONTROL_PATH)
yr = importlib.util.module_from_spec(spec)
spec.loader.exec_module(yr)
with open(BENCH_PATH, encoding="utf-8") as f:
yb = json.load(f)
print("Loaded benchmark:", BENCH_PATH)# Core functions exercised (see yield_response.py)
ky_corn = yb["ky_values"]["corn"]["ky_total"]
grid = np.linspace(0, 1, 121)
yield_curve = np.array([yr.yield_ratio_single(ky_corn, r) for r in grid])
wue_demo = yr.water_use_efficiency(12000, 500)
print("Corn Ky (table):", ky_corn, "| WUE demo (kg/m3):", wue_demo)
ms = yr.yield_ratio_multistage([0.40, 1.50, 0.50, 0.20], [0.9, 0.9, 0.9, 0.9])
assert abs(ms - 0.7597) <= 1e-3
print("Multi-stage corn uniform deficit ratio:", ms)passed = []
for tc in yb["validation_checks"]["single_stage_analytical"]["test_cases"]:
c = yr.yield_ratio_single(tc["ky"], tc["eta_etc"])
assert abs(c - tc["expected_ratio"]) <= tc["tolerance"]
passed.append(tc["label"])
for tc in yb["validation_checks"]["multi_stage_analytical"]["test_cases"]:
c = yr.yield_ratio_multistage(tc["stages_ky"], tc["stages_eta_etc"])
assert abs(c - tc["expected_ratio"]) <= tc["tolerance"]
for tc in yb["validation_checks"]["water_use_efficiency"]["test_cases"]:
c = yr.water_use_efficiency(tc["yield_kg_ha"], tc["eta_mm"])
assert abs(c - tc["expected_wue_kg_m3"]) <= tc["tolerance"]
scenario = yb["validation_checks"]["scheduling_comparison"]["scenario"]
strategies = yb["validation_checks"]["scheduling_comparison"]["strategies"]
np.random.seed(42)
season_days = scenario["season_days"]
et0_daily = np.maximum(0.5, np.random.normal(scenario["et0_mean_mm_day"], 1.5, season_days))
precip_mean = strategies["no_irrigation"]["precip_mean_mm_day"]
precip_prob = strategies["no_irrigation"]["precip_prob"]
rain_days = np.random.random(season_days) < precip_prob
precip_daily = np.where(
rain_days,
np.random.exponential(precip_mean / precip_prob, season_days),
0.0,
)
results = {}
for name, strat in strategies.items():
thresh_frac = strat.get("irrigation_threshold_frac", None)
irrig_depth = strat.get("irrigation_depth_mm", 25.0) if thresh_frac else 0.0
r = yr.simulate_season_with_yield(
season_days=season_days,
et0_daily=et0_daily,
precip_daily=precip_daily,
kc=1.2,
ky_total=scenario["ky_total"],
theta_fc=scenario["theta_fc"],
theta_wp=scenario["theta_wp"],
root_depth_m=scenario["root_depth_m"],
p=scenario["p"],
irrigation_threshold_frac=thresh_frac,
irrigation_depth_mm=irrig_depth,
)
results[name] = r
yr_lo, yr_hi = strat["expected_yield_ratio_range"]
sd_lo, sd_hi = strat["expected_stress_days_range"]
yr_clamped = r["yield_ratio_clamped"]
assert yr_lo <= yr_clamped <= yr_hi
assert sd_lo <= r["stress_days"] <= sd_hi
assert results["threshold_mad"]["yield_ratio_clamped"] > results["no_irrigation"]["yield_ratio_clamped"]
assert results["threshold_mad"]["stress_days"] < results["no_irrigation"]["stress_days"]
print("PASS analytical + scheduling envelope checks,", len(passed), "single-stage labels")C_GREEN, C_RED, C_BLUE = "#2ecc71", "#e74c3c", "#3498db"
fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(grid, yield_curve, color=C_BLUE, lw=2, label="Ya/Ym vs ETa/Etc")
ax.axhline(1.0, color=C_GREEN, ls=":", alpha=0.8, label="No yield loss")
stress_x = np.array([0.9, 0.75, 0.50])
stress_y = np.array([yr.yield_ratio_single(ky_corn, x) for x in stress_x])
ax.scatter(stress_x, stress_y, color=C_RED, s=52, zorder=3, label="Example deficits")
ax.set_xlabel("ET$_a$/ET$_c$ (-)")
ax.set_ylabel("$Y_a / Y_{m}$ (-)")
ax.set_title(f"Single-stage Stewart curve (Ky={ky_corn}, corn)")
ax.legend()
plt.tight_layout()
plt.show()Summary
The Stewart proportional-deficit formulation and multi-stage extension were checked against analytic expectations in benchmark_yield_response.json, including WUE scalings. A stochastic Michigan-style scheduling trio (rain-fed vs threshold irrigation) verifies that MAD-style irrigation lifts clamped yield ratio and trims stress-days relative to rain-fed realizations seeded identically across strategies—precisely mirroring how yield_response.py gates cross-language validation (validate_yield_response).