Turc (1961) Temperature-Radiation ET₀

Rendered from 034-turc-et0.ipynb

Turc (1961) Temperature-Radiation ET₀

Experiment 034 — Python control baseline aligned with primal science.et0_turc and Rust validate_turc.

Theory

Turc estimates ET₀ from mean daily temperature and solar radiation, with a humidity correction when mean relative humidity is below 50%.

For $RH \geq 50%$: $$\mathrm{ET}_0 = 0.013 , \frac{T}{T+15} , (23.8846, R_s + 50)$$

For $RH < 50%$, multiply by $\bigl(1 + (50 - RH)/70\bigr)$.

Here $T$ is mean temperature (°C), $R_s$ is solar radiation (MJ m⁻² day⁻¹), and 23.8846 converts MJ m⁻² day⁻¹ to cal cm⁻² day⁻¹.

Citation: Turc L (1961) Annales Agronomiques 12:13–49.

import json
import math
import sys
from pathlib import Path

def _find_repo_root() -> Path:
    p = Path.cwd().resolve()
    for _ in range(8):
        if (p / "control" / "turc").is_dir():
            return p
        p = p.parent
    return Path('/home/eastgate/Development/ecoPrimals/springs/airSpring')

REPO = _find_repo_root()
BENCHMARK_PATH = REPO / "control" / "turc" / "benchmark_turc.json"

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

print("Benchmark:", BENCHMARK_PATH)
MJ_TO_CAL_CM2 = 23.8846


def turc_et0(tmean, rs_mj, rh):
    """Turc (1961) ET₀ (mm/day)."""
    if tmean + 15.0 == 0.0:
        return 0.0
    t_factor = tmean / (tmean + 15.0)
    if t_factor < 0.0:
        return 0.0
    rs_cal = MJ_TO_CAL_CM2 * rs_mj + 50.0
    et0 = 0.013 * t_factor * rs_cal
    if rh < 50.0:
        et0 *= 1.0 + (50.0 - rh) / 70.0
    return max(0.0, et0)
def validate_analytical_high_rh(benchmark):
    checks = benchmark["validation_checks"]["analytical_high_rh"]["test_cases"]
    passed = 0
    for tc in checks:
        computed = turc_et0(tc["tmean"], tc["rs_mj"], tc["rh"])
        expected = tc["expected_et0"]
        tol = tc["tolerance"]
        ok = abs(computed - expected) <= tol
        status = "PASS" if ok else "FAIL"
        print("  [%s] T=%s, Rs=%s, RH=%s%% → ET₀=%.3f (expected %s, tol %s)" % (
            status, tc["tmean"], tc["rs_mj"], tc["rh"], computed, expected, tol))
        if ok:
            passed += 1
    return passed, len(checks)


def validate_analytical_low_rh(benchmark):
    checks = benchmark["validation_checks"]["analytical_low_rh"]["test_cases"]
    passed = 0
    for tc in checks:
        computed = turc_et0(tc["tmean"], tc["rs_mj"], tc["rh"])
        expected = tc["expected_et0"]
        tol = tc["tolerance"]
        ok = abs(computed - expected) <= tol
        status = "PASS" if ok else "FAIL"
        print("  [%s] T=%s, Rs=%s, RH=%s%% → ET₀=%.3f (expected %s, tol %s)" % (
            status, tc["tmean"], tc["rs_mj"], tc["rh"], computed, expected, tol))
        if ok:
            passed += 1
    return passed, len(checks)


def validate_humidity_boundary(benchmark):
    checks = benchmark["validation_checks"]["humidity_boundary"]["test_cases"]
    passed = 0
    for tc in checks:
        tmean = tc["tmean"]
        rs_mj = tc["rs_mj"]
        tol = tc["tolerance"]
        et0_at_50 = turc_et0(tmean, rs_mj, 50.0)
        et0_at_49 = turc_et0(tmean, rs_mj, 49.99)
        diff = abs(et0_at_50 - et0_at_49)
        ok = diff < tol
        status = "PASS" if ok else "FAIL"
        print("  [%s] RH=50: %.4f, RH=49.99: %.4f, diff=%.6f (tol %s)" % (
            status, et0_at_50, et0_at_49, diff, tol))
        if ok:
            passed += 1
    return passed, len(checks)


def validate_edge_cases(benchmark):
    checks = benchmark["validation_checks"]["edge_cases"]["test_cases"]
    passed = 0
    for tc in checks:
        computed = turc_et0(tc["tmean"], tc["rs_mj"], tc["rh"])
        check = tc["check"]
        if check == "positive":
            ok = computed > 0.0
        elif check == "non_negative":
            ok = computed >= 0.0
        elif check == "zero":
            ok = computed == 0.0
        else:
            ok = False
        status = "PASS" if ok else "FAIL"
        print("  [%s] %s: ET₀=%.4f" % (status, tc["label"], computed))
        if ok:
            passed += 1
    return passed, len(checks)


def validate_monotonicity(benchmark):
    checks = benchmark["validation_checks"]["monotonicity"]["test_cases"]
    passed = 0
    for tc in checks:
        label = tc["label"]
        if "base_rs" in tc:
            low = turc_et0(tc["tmean"], tc["base_rs"], tc["rh"])
            high = turc_et0(tc["tmean"], tc["high_rs"], tc["rh"])
        elif "base_t" in tc:
            low = turc_et0(tc["base_t"], tc["rs_mj"], tc["rh"])
            high = turc_et0(tc["high_t"], tc["rs_mj"], tc["rh"])
        else:
            low = turc_et0(tc["tmean"], tc["rs_mj"], tc["base_rh"])
            high = turc_et0(tc["tmean"], tc["rs_mj"], tc["low_rh"])
        ok = high > low
        status = "PASS" if ok else "FAIL"
        print("  [%s] %s: %.3f < %.3f" % (status, label, low, high))
        if ok:
            passed += 1
    return passed, len(checks)


def validate_pyet_cross():
    try:
        import pyet
        import pandas as pd
    except ImportError:
        print("  [SKIP] pyet not installed — skipping cross-validation")
        return 0, 0

    conditions = [
        (20.0, 15.0, 70.0),
        (30.0, 25.0, 55.0),
        (10.0, 8.0, 80.0),
        (30.0, 25.0, 40.0),
        (25.0, 20.0, 20.0),
    ]
    tol = 0.1
    passed = 0
    for tmean, rs, rh in conditions:
        our = turc_et0(tmean, rs, rh)
        try:
            pyet_val = float(pyet.turc(
                pd.Series([tmean]), pd.Series([rs]), pd.Series([rh])
            ).iloc[0])
        except Exception as e:
            print("  [SKIP] pyet.turc failed:", e)
            continue
        diff = abs(our - pyet_val)
        ok = diff <= tol
        status = "PASS" if ok else "FAIL"
        print("  [%s] T=%s, Rs=%s, RH=%s: ours=%.3f, pyet=%.3f, diff=%.4f" % (
            status, tmean, rs, rh, our, pyet_val, diff))
        if ok:
            passed += 1
    return passed, len(conditions)


total_passed = 0
total_checks = 0

print("\n── Analytical (RH ≥ 50%) ──")
p, t = validate_analytical_high_rh(benchmark)
total_passed += p
total_checks += t

print("\n── Analytical (RH < 50%) ──")
p, t = validate_analytical_low_rh(benchmark)
total_passed += p
total_checks += t

print("\n── Humidity Boundary (RH=50%) ──")
p, t = validate_humidity_boundary(benchmark)
total_passed += p
total_checks += t

print("\n── Edge Cases ──")
p, t = validate_edge_cases(benchmark)
total_passed += p
total_checks += t

print("\n── Monotonicity ──")
p, t = validate_monotonicity(benchmark)
total_passed += p
total_checks += t

print("\n── pyet Cross-Validation ──")
p, t = validate_pyet_cross()
total_passed += p
total_checks += t

print("\n=== Turc ET₀: %s/%s PASS ===" % (total_passed, total_checks))
if total_passed != total_checks:
    sys.exit(1)
import matplotlib.pyplot as plt
import numpy as np

# Compare high-RH analytical cases
cases = benchmark["validation_checks"]["analytical_high_rh"]["test_cases"]
xs = np.arange(len(cases))
comp = [turc_et0(c["tmean"], c["rs_mj"], c["rh"]) for c in cases]
exp = [c["expected_et0"] for c in cases]

rh_vals = np.linspace(10, 90, 50)
et0_rh = [turc_et0(28.0, 22.0, float(rh)) for rh in rh_vals]

fig, axes = plt.subplots(1, 2, figsize=(11, 4.2))

ax = axes[0]
w = 0.35
ax.bar(xs - w / 2, comp, w, label="Computed", color="#2ecc71", edgecolor="white")
ax.bar(xs + w / 2, exp, w, label="Benchmark", color="#3498db", edgecolor="white")
ax.set_xticks(xs)
ax.set_xticklabels([str(i + 1) for i in range(len(cases))])
ax.set_title("Turc analytical (RH ≥ 50%)")
ax.set_xlabel("Test case")
ax.set_ylabel("ET₀ (mm day⁻¹)")
ax.legend()

ax2 = axes[1]
ax2.plot(rh_vals, et0_rh, color="#e74c3c", lw=2, label="T=28°C, Rs=22 MJ m⁻² d⁻¹")
ax2.axvline(50, color="0.4", ls="--", lw=1, label="RH = 50%")
ax2.set_xlabel("Relative humidity (%)")
ax2.set_ylabel("ET₀ (mm day⁻¹)")
ax2.set_title("Humidity correction branch")
ax2.legend()
ax2.grid(True, alpha=0.25)

plt.tight_layout()
plt.show()

Summary and provenance

  • Primal: science.et0_turc
  • Rust: validate_turc
  • Control script: control/turc/turc_et0.py
  • Benchmark: control/turc/benchmark_turc.json

Turc coefficients and humidity split follow the project’s Python baseline. Optional pyet checks require that package.