FAO-56 Chapter 8 — Daily Soil Water Balance Scheduling
Rendered from 004-fao56-water-balance.ipynb
FAO-56 Chapter 8 — Daily Soil Water Balance Scheduling
Citation: Allen, R.G., Pereira, L.S., Raes, D., Smith, M. (1998). FAO Irrigation and Drainage Paper 56, Chapter 8.
AirSpring linkage: primal science.water_balance; Rust binary validate_water_balance.
This notebook aligns with control/water_balance/fao56_water_balance.py and the digitized benchmark in control/water_balance/benchmark_water_balance.json.
Theory
FAO-56 expresses root-zone soil water deficit (depletion) $D_\mathrm{r,i}$ relative to maximum storage in the rooting depth. Chapter 8 gives the scheduling mass balance:
$$D_{\mathrm{r},i} = D_{\mathrm{r},i-1} - (P - \mathrm{RO})_i - I_i - \mathrm{CR}i + ET{\mathrm{c,adj},i} + DP_i$$
With $ET_{\mathrm{c,adj}} = K_s , K_\mathrm{c} , ET_0$, total available water $T_\mathrm{AW} = 1000(\theta_\mathrm{fc}-\theta_\mathrm{wp})Z_\mathrm{r}$ (mm), and readily available water $RAW = p , T_\mathrm{AW}$, the stress coefficient is
$$K_s = \begin{cases} 1 & D_\mathrm{r} \le RAW\ \frac{T_\mathrm{AW} - D_\mathrm{r}}{T_\mathrm{AW} - RAW} & D_\mathrm{r} > RAW \end{cases}$$
(with $K_s \in [0,1]$ in practice).
This reproduction follows the simplifying defaults in the control script ($\mathrm{RO}=\mathrm{CR}=0$, deep percolation when $D_\mathrm{r}$ would become negative).
import importlib.util
import json
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
plt.rcParams["figure.figsize"] = (10, 4)
plt.rcParams["axes.grid"] = True
NOTEBOOK_DIR = Path.cwd().resolve()
BENCH_PATH = (NOTEBOOK_DIR / "../../control/water_balance/benchmark_water_balance.json").resolve()
CONTROL_PATH = (NOTEBOOK_DIR / "../../control/water_balance/fao56_water_balance.py").resolve()
spec = importlib.util.spec_from_file_location("wb", CONTROL_PATH)
wb = importlib.util.module_from_spec(spec)
spec.loader.exec_module(wb)
with open(BENCH_PATH, encoding="utf-8") as f:
bench_wb = json.load(f)
tol_mb = bench_wb["mass_balance_test"]["tolerance"]
sl = bench_wb["soil_parameters"]["sandy_loam"]
corn = bench_wb["crop_parameters"]["corn"]
print("Benchmark:", BENCH_PATH)
print("Tolerance (mass balance):", tol_mb)# Core numerical core is in fao56_water_balance.py — functions used below:
def recap_api():
return {
"TAW(mm) example": wb.total_available_water(sl["theta_fc"], sl["theta_wp"], corn["root_depth_m"]),
"RAW(mm) example": wb.readily_available_water(
wb.total_available_water(sl["theta_fc"], sl["theta_wp"], corn["root_depth_m"]),
corn["depletion_fraction_p"],
),
"Ks at Dr=0": wb.stress_coefficient(0, 90, 49.5),
"daily_water_balance_step keys": wb.daily_water_balance_step(10, P=0, I=0, ET0=5.0, Kc=1.2, Ks=1.0, TAW=90).keys(),
"simulate_season keys": list(wb.simulate_season(np.array([5.0]), np.array([0.0]), Kc=1.2,
theta_fc=0.18, theta_wp=0.08, root_depth_m=0.9, p=0.55).keys()),
}
for k, v in recap_api().items():
print(k, ":", v)# Structural validation against benchmark fields + mass balance closures
scenario = bench_wb["michigan_summer_scenario"]
rng = np.random.default_rng(42)
n_days = 90
et0 = rng.normal(scenario["et0_mean_mm_day"], scenario["et0_std_mm_day"], n_days)
et0 = np.maximum(et0, 0.5)
rain_days = rng.random(n_days) < scenario["precip_prob"]
precip = np.zeros(n_days)
precip[rain_days] = rng.exponential(scenario["precip_depth_when_rain_mm"], np.sum(rain_days))
result = wb.simulate_season(
et0,
precip,
Kc=scenario["kc"],
theta_fc=sl["theta_fc"],
theta_wp=sl["theta_wp"],
root_depth_m=corn["root_depth_m"],
p=corn["depletion_fraction_p"],
irrigation_trigger=True,
irrig_depth_mm=25.0,
)
mb_error = wb.mass_balance_check(result)
assert mb_error <= tol_mb, mb_error
et_lo, et_hi = scenario["expected_seasonal_et_range_mm"]
assert et_lo <= result["total_et"] <= et_hi
assert result["irrig_events"] > 0
dry = wb.simulate_season(
np.full(30, 5.0),
np.zeros(30),
Kc=1.2,
theta_fc=0.18,
theta_wp=0.08,
root_depth_m=0.90,
p=0.55,
irrigation_trigger=False,
)
assert wb.mass_balance_check(dry) <= tol_mb
assert dry["Ks"][-1] < dry["Ks"][0]
print("PASS: Michigan summer + dry-down mass balance and scenario checks")
print(" Mass balance error (MI):", mb_error)
print(" Seasonal ET (mm):", float(result["total_et"]))C_GREEN, C_RED, C_BLUE = "#2ecc71", "#e74c3c", "#3498db"
fig, ax1 = plt.subplots()
ax2 = ax1.twinx()
ax1.plot(result["Dr"], color=C_RED, label="Dr (depletion, mm)")
ax1.set_ylabel("Depletion Dr (mm)", color=C_RED)
ax2.plot(result["Ks"], color=C_GREEN, label="Ks")
ax2.set_ylabel("Stress Ks (-)", color=C_GREEN)
ax1.set_xlabel("Day of season")
ax1.set_title("Michigan summer scenario — depletion and stress")
ax3 = ax1.twinx()
ax3.spines["right"].set_position(("axes", 1.12))
ax3.plot(result["ETc"], color=C_BLUE, alpha=0.85, label="ETc_adj (mm/d)")
ax3.set_ylabel("ETc_adj (mm/d)", color=C_BLUE)
lines, labels = [], []
for ax in (ax1, ax2, ax3):
L, lab = ax.get_legend_handles_labels()
lines += L
labels += lab
ax1.legend(lines, labels, loc="upper left", frameon=True)
plt.tight_layout()
plt.show()Summary
We reproduced the FAO-56 Chapter 8 daily water balance (depletion update, $K_s$, adjusted $ET_\mathrm{c}$, deep percolation handling) and checked mass balance closure at the benchmark tolerance. The Michigan summer scenario produces physically plausible seasonal $ET_\mathrm{c}$, irrigation events, declining then partially recovered $K_\mathrm{s}$, and root-zone deficit dynamics consistent with the digitized expectation ranges in benchmark_water_balance.json.