Michigan Crop Water Atlas — 100 Stations, 80 Years

Rendered from 018-michigan-crop-water-atlas.ipynb

Michigan Crop Water Atlas — 100 Stations, 80 Years

Citation / data: ERA5-derived daily weather courtesy of Open-Meteo hourly archive; methodological footprint described in airSpring Experiment 018 README.

This is the flagship integration experiment combining FAO-56 reference evaporation, staged crop coefficients, soil-water stress, MAD-style replenishment irrigation, and Stewart-type yield bookkeeping.

AirSpring linkage: primal ecology.full_pipeline; Rust validator validate_atlas.

Baseline driver: control/atlas/atlas_water_budget.py — benchmark snapshot: control/atlas/benchmark_atlas.json.

Theory

Atlas processing threads together:

$$\mathrm{ET_0}=f_{\mathrm{PM}}\left(R_n,u_2,e_s-e_a,\Delta,\gamma\right)\quad\text{(FAO-56 Penman-Monteith)}$$

$$ET_{\mathrm{c},i}=K_{\mathrm{c},i}(t)\ ET_{0,i}, \quad ET_{\mathrm{a},i}=K_{\mathrm{s},i} ET_{\mathrm{c},i}$$ with $K_{\mathrm{c}}$ evolving through empirical initial/mid/end plateaus over the simulated season fraction, identical to Chapter 8 stress reduction with managed irrigation pulses when depletion crosses $RAW=p,T_{\mathrm{AW}}$.

$$\frac{Y_a}{Y_m} = \max\left(0, \min\left(1, 1 - K_y (1 - \mathrm{ETA}/\mathrm{ETC})\right)\right)$$

where seasonal $\mathrm{ETA}/\mathrm{ETC}$ is accumulated actual vs unstressed totals.

The Python atlas script parses Open-Meteo CSV exports, restricts to agronomic summers (approximately DOY $121\le d \le 273$ when valid), and summarizes per crop means for cross-validation.

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/atlas/benchmark_atlas.json").resolve()
CONTROL_PATH = (NOTEBOOK_DIR / "../../control/atlas/atlas_water_budget.py").resolve()

with open(BENCH_PATH, encoding="utf-8") as f:
    atlas = json.load(f)

stations = atlas.get("stations", {})
print("Control script reference:", CONTROL_PATH.name)
print("Stations encoded:", len(stations))
print("Bench file:", BENCH_PATH)
sample_key = sorted(stations.keys())[0]
print("Example station key:", sample_key)
print(atlas["stations"][sample_key].keys())
# Key pipeline logic is in atlas_water_budget.py (ET0 + MAD irrigation + staged Kc).


def recap_atlas_station(station_id):
    sd = atlas["stations"][station_id]
    crops = sd.get("crops", {})
    return {
        "n_days": sd.get("n_days"),
        "n_years": sd.get("n_years"),
        "mean_ET0_mm_yr": sd.get("mean_annual_et0"),
        "crop_count": len(crops),
        "Corn mean_yield": crops.get("Corn", {}).get("mean_yield_ratio"),
    }


example = recap_atlas_station(sample_key)

print(example)
assert example["Corn mean_yield"] is not None
# Structural validation suitable for notebooks (no filesystem weather dependency)

station_ids = list(stations.keys())
assert station_ids

for sid in station_ids[:20]:
    blob = atlas["stations"][sid]
    assert blob["mean_annual_et0"] >= 200
    for crop_name, cr in blob["crops"].items():
        for key in ["mean_et_mm", "mean_precip_mm", "mean_stress_days", "mean_yield_ratio", "mean_irrig_mm"]:
            assert key in cr
        assert 0 <= cr["mean_yield_ratio"] <= 1.01 + 1e-3

spread_corn_yield = np.array([
    atlas["stations"][sid]["crops"]["Corn"]["mean_yield_ratio"] for sid in station_ids if "Corn" in atlas["stations"][sid]["crops"]
])

print("Corn yield-ratio spread (min/med/max):", float(np.min(spread_corn_yield)), float(np.median(spread_corn_yield)), float(np.max(spread_corn_yield)))
print("PASS: structural QA on first 20 stations + Corn coverage")
C_GREEN, C_RED, C_BLUE = "#2ecc71", "#e74c3c", "#3498db"

corn_et = []
corn_yield = []

for sid in station_ids:
    c = atlas["stations"][sid]["crops"].get("Corn")
    if c is None:
        continue
    corn_et.append(c["mean_et_mm"])
    corn_yield.append(c["mean_yield_ratio"])

fig, ax = plt.subplots(figsize=(8, 4))
ax.scatter(corn_et, corn_yield, alpha=0.35, edgecolors="none", color=C_GREEN, label="Corn — stations")
ax.scatter(
    corn_et[:3],
    corn_yield[:3],
    alpha=1.0,
    color=C_RED,
    marker="x",
    s=40,
    label="First trio (markers)",
)

ce = np.array(corn_et)
cy = np.array(corn_yield)
if len(ce) > 5:
    m, b = np.polyfit(ce, cy, 1)
    xx = np.linspace(ce.min(), ce.max(), 200)
    ax.plot(xx, m * xx + b, linestyle="--", color=C_BLUE, lw=2, label="OLS trend")

ax.set_xlabel("Mean seasonal corn ET (mm)")
ax.set_ylabel("Mean yield ratio (-)")
ax.set_title("Experiment 018 — Corn ET vs modeled yield coefficient")
ax.legend(markerscale=1.8)
plt.tight_layout()
plt.show()

Summary

The atlas benchmark JSON embeds aggregates for every simulated station—the exact artifact validate_atlas diff-checks against the Python driver. Notebook-side QA confirms sane ranges for reference $\mathrm{ET}_0$, per-crop bookkeeping fields, and Corn yield ratios across the lattice. The Corn scatter uses the requested palette; an OLS trend summarizes bulk association over the archived stations. For regenerated goldens, rerun python control/atlas/atlas_water_budget.py with local Open-Meteo CSV holdings.