ET₀ Sensitivity Analysis (One-at-a-Time)
Rendered from 017-et0-sensitivity-analysis.ipynb
ET₀ Sensitivity Analysis (One-at-a-Time)
Citation: Gong L et al. (2006) Agricultural Water Management 86:188–198.
Primal: N/A · Rust: validate_sensitivity
Baseline: control/sensitivity/et0_sensitivity.py · Benchmark: control/sensitivity/benchmark_sensitivity.json
Theory
One-at-a-time (OAT) sensitivity: for each input $x_i$, perturb $\pm 10%$ about the baseline, holding others fixed.
Sensitivity index: $\partial \mathrm{ET}_0 / \partial x_i \approx (\mathrm{ET}_0^+ - \mathrm{ET}_0^-) / (2\Delta x_i)$.
Elasticity: fractional response in $\mathrm{ET}_0$ per fractional change in $x_i$.
The baseline meteorology follows FAO-56 Example 18 (Uccle, Belgium) as encoded in the benchmark.
import json
import math
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
REPO = Path('/home/eastgate/Development/ecoPrimals/springs/airSpring').resolve()
BENCH = REPO / "control/sensitivity/benchmark_sensitivity.json"
C_GREEN, C_RED, C_BLUE = "#2ecc71", "#e74c3c", "#3498db"
plt.rcParams.update({"figure.figsize": (8, 4.5), "axes.grid": True})def saturation_vapour_pressure(t_c):
return 0.6108 * math.exp(17.27 * t_c / (t_c + 237.3))
def slope_vapour_pressure_curve(t_c):
es = saturation_vapour_pressure(t_c)
return 4098.0 * es / (t_c + 237.3) ** 2
def atmospheric_pressure(altitude_m):
return 101.3 * ((293.0 - 0.0065 * altitude_m) / 293.0) ** 5.26
def psychrometric_constant(pressure_kpa):
return 0.000665 * pressure_kpa
def actual_vapour_pressure_rh(tmax_c, tmin_c, rhmax, rhmin):
e_tmin = saturation_vapour_pressure(tmin_c)
e_tmax = saturation_vapour_pressure(tmax_c)
return (e_tmin * (rhmax / 100.0) + e_tmax * (rhmin / 100.0)) / 2.0
def solar_declination(doy):
return 0.409 * math.sin(2.0 * math.pi / 365.0 * doy - 1.39)
def inverse_relative_distance(doy):
return 1.0 + 0.033 * math.cos(2.0 * math.pi / 365.0 * doy)
def sunset_hour_angle(lat_rad, dec_rad):
arg = max(-1.0, min(1.0, -math.tan(lat_rad) * math.tan(dec_rad)))
return math.acos(arg)
def extraterrestrial_radiation(lat_deg, doy):
gsc = 0.0820
phi = math.radians(lat_deg)
dr = inverse_relative_distance(doy)
delta = solar_declination(doy)
ws = sunset_hour_angle(phi, delta)
return (24.0 * 60.0 / math.pi) * gsc * dr * (
ws * math.sin(phi) * math.sin(delta) + math.cos(phi) * math.cos(delta) * math.sin(ws)
)
def clear_sky_radiation(alt_m, ra):
return (0.75 + 2e-5 * alt_m) * ra
def net_shortwave(rs, albedo=0.23):
return (1.0 - albedo) * rs
def net_longwave(tmax_c, tmin_c, ea_kpa, rs_over_rso):
sigma = 4.903e-9
tmax_k4 = (tmax_c + 273.16) ** 4
tmin_k4 = (tmin_c + 273.16) ** 4
avg_k4 = (tmax_k4 + tmin_k4) / 2.0
hf = 0.34 - 0.14 * math.sqrt(ea_kpa)
cf = 1.35 * rs_over_rso - 0.35
return sigma * avg_k4 * hf * cf
def fao56_pm(rn, g, tmean_c, u2, vpd_kpa, delta, gamma):
num = 0.408 * delta * (rn - g) + gamma * (900.0 / (tmean_c + 273.0)) * u2 * vpd_kpa
den = delta + gamma * (1.0 + 0.34 * u2)
return num / den
def compute_et0(params):
tmin = params["tmin_c"]
tmax = params["tmax_c"]
tmean = (tmin + tmax) / 2.0
rh_min = params["rh_min_pct"]
rh_max = params["rh_max_pct"]
u2 = params["wind_2m_m_s"]
rs = params["solar_rad_mj_m2_day"]
elev = params["elevation_m"]
lat = params["latitude_deg"]
doy = params["day_of_year"]
p = atmospheric_pressure(elev)
gamma = psychrometric_constant(p)
delta = slope_vapour_pressure_curve(tmean)
es = (saturation_vapour_pressure(tmax) + saturation_vapour_pressure(tmin)) / 2.0
ea = actual_vapour_pressure_rh(tmax, tmin, rh_max, rh_min)
vpd = es - ea
ra = extraterrestrial_radiation(lat, doy)
rso = clear_sky_radiation(elev, ra)
rns = net_shortwave(rs)
rs_rso = min(rs / rso, 1.0) if rso > 0 else 1.0
rnl = net_longwave(tmax, tmin, ea, rs_rso)
rn = rns - rnl
g = 0.0
return fao56_pm(rn, g, tmean, u2, vpd, delta, gamma)
def oat_sensitivity(baseline_params, var_name, pct=10.0):
et0_base = compute_et0(baseline_params)
x_base = baseline_params[var_name]
dx = abs(x_base) * pct / 100.0
params_plus = {**baseline_params, var_name: x_base + dx}
params_minus = {**baseline_params, var_name: x_base - dx}
et0_plus = compute_et0(params_plus)
et0_minus = compute_et0(params_minus)
sensitivity = (et0_plus - et0_minus) / (2.0 * dx) if dx > 0 else 0.0
elasticity = ((et0_plus - et0_minus) / et0_base) / (2.0 * pct / 100.0) if et0_base > 0 else 0.0
return et0_base, et0_plus, et0_minus, sensitivity, elasticity
def full_sensitivity_analysis(params, variables, pct=10.0):
results = []
for var in variables:
name = var["name"]
et0_base, et0_plus, et0_minus, sens, elast = oat_sensitivity(params, name, pct)
results.append(
{
"name": name,
"label": var["label"],
"sensitivity": sens,
"abs_sensitivity": abs(sens),
"elasticity": elast,
"et0_plus": et0_plus,
"et0_minus": et0_minus,
}
)
results.sort(key=lambda r: r["abs_sensitivity"], reverse=True)
return resultswith open(BENCH) as f:
benchmark = json.load(f)
bc = benchmark["baseline_conditions"]
baseline_params = {
"tmin_c": bc["tmin_c"],
"tmax_c": bc["tmax_c"],
"rh_min_pct": bc["rh_min_pct"],
"rh_max_pct": bc["rh_max_pct"],
"wind_2m_m_s": bc["wind_2m_m_s"],
"solar_rad_mj_m2_day": bc["solar_rad_mj_m2_day"],
"elevation_m": bc["elevation_m"],
"latitude_deg": bc["latitude_deg"],
"day_of_year": bc["day_of_year"],
}
et0 = compute_et0(baseline_params)
vc = benchmark["validation_checks"]["baseline_et0"]
assert abs(et0 - vc["expected"]) <= vc["tolerance"], et0
variables = benchmark["variables"]
pct = benchmark["perturbation_pct"]
results = full_sensitivity_analysis(baseline_params, variables, pct)
mc = benchmark["validation_checks"]["monotonicity"]
for r in results:
if r["name"] in mc["positive_sensitivity"]:
assert r["sensitivity"] > 0, r
elif r["name"] in mc["negative_sensitivity"]:
assert r["sensitivity"] < 0, r
ec = benchmark["validation_checks"]["elasticity_bounds"]
for r in results:
assert ec["min_elasticity"] <= r["elasticity"] <= ec["max_elasticity"]
rc = benchmark["validation_checks"]["sensitivity_ranking"]
top_two = [r["name"] for r in results[:2]]
assert any(n in rc["top_two_include"] for n in top_two), top_two
for site in benchmark["validation_checks"]["multi_site_consistency"]["sites"]:
sp = {
"tmin_c": site["tmin_c"],
"tmax_c": site["tmax_c"],
"rh_min_pct": site["rh_min_pct"],
"rh_max_pct": site["rh_max_pct"],
"wind_2m_m_s": site["wind_2m_m_s"],
"solar_rad_mj_m2_day": site["solar_rad_mj_m2_day"],
"elevation_m": site["elevation_m"],
"latitude_deg": site["latitude_deg"],
"day_of_year": site["day_of_year"],
}
rr = full_sensitivity_analysis(sp, variables, pct)
top3 = [x["name"] for x in rr[:3]]
assert "solar_rad_mj_m2_day" in top3, (site["name"], top3)
print("benchmark_sensitivity.json: baseline ET0, monotonicity, elasticity, ranking, multi-site OK.")labels = [r["label"] for r in results]
vals = [r["abs_sensitivity"] for r in results]
colors = [C_GREEN, C_BLUE, C_RED, C_GREEN, C_BLUE, C_RED][: len(labels)]
fig, ax = plt.subplots()
ax.barh(labels[::-1], vals[::-1], color=colors[::-1], edgecolor="black", linewidth=0.4)
ax.set_xlabel(r"$|\partial ET_0 / \partial x|$ (mm day$^{-1}$ per unit $x$)")
ax.set_title("OAT sensitivity ranking (FAO-56 Example 18 baseline)")
plt.tight_layout()
plt.show()Summary
- Baseline: FAO-56 Example 18 encoded in
benchmark_sensitivity.json; computed $\mathrm{ET}_0 \approx 3.88$ mm/day within tolerance. - OAT: ±10% perturbations reproduce expected monotonic signs, elasticity bounds, and literature-consistent dominance of radiation / humidity in the ranking.
- Provenance: JSON
_provenancereferences Allen (FAO-56), Gong et al., and related studies.