Hamon (1961) Temperature-Based PET

Rendered from 035-hamon-pet.ipynb

Hamon (1961) Temperature-Based PET

Experiment 035 — Python control baseline aligned with primal science.et0_hamon and Rust validate_hamon.

Theory

Hamon PET uses mean temperature and day length (possible sunshine hours). This notebook follows the Lu et al. (2005) formulation used in hamon_pet.py.

$$\mathrm{PET} = 0.1651 , N , \rho_{\mathrm{sat}} , K_{\mathrm{PEC}}$$

with saturated vapour pressure $e_s$ (kPa), $\rho_{\mathrm{sat}} = 216.7, e_s/(T+273.3)$ g m⁻³, $N$ daylight hours, and $K_{\mathrm{PEC}}=1$.

An equivalent textbook form is PET = 0.55 × (N/12)² × e_s(T)/100 × 25.4 under algebraic rearrangement of constants (see Lu et al. 2005; Hamon 1961).

Citation: Hamon WR (1961) J Hydraulics Div ASCE 87(HY3):107–120; Lu J et al. (2005) J Am Water Resour Assoc 41(3):621–633.

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" / "hamon").is_dir():
            return p
        p = p.parent
    return Path('/home/eastgate/Development/ecoPrimals/springs/airSpring')

REPO = _find_repo_root()
BENCHMARK_PATH = REPO / "control" / "hamon" / "benchmark_hamon.json"

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

print("Benchmark:", BENCHMARK_PATH)
KPEC = 1.0


def saturation_vapour_pressure(t):
    """e_s (kPa). FAO-56 Eq. 11."""
    return 0.6108 * math.exp(17.27 * t / (t + 237.3))


def saturated_absolute_humidity(t):
    """RHOSAT (g/m³) — saturated vapour density at temperature t (°C)."""
    es = saturation_vapour_pressure(t)
    return 216.7 * es / (t + 273.3)


def daylight_hours(latitude_deg, doy):
    """Possible sunshine hours N from latitude and day of year. FAO-56 Eq. 34."""
    lat_rad = latitude_deg * math.pi / 180.0
    delta = 0.409 * math.sin(2.0 * math.pi * doy / 365.0 - 1.39)
    arg = -math.tan(lat_rad) * math.tan(delta)
    arg = max(-1.0, min(1.0, arg))
    ws = math.acos(arg)
    return 24.0 * ws / math.pi


def hamon_pet(tmean, day_length_hours):
    """Hamon PET (mm/day) using Lu et al. (2005) formulation."""
    if tmean < 0.0 or day_length_hours <= 0.0:
        return 0.0
    rhosat = saturated_absolute_humidity(tmean)
    return 0.1651 * day_length_hours * rhosat * KPEC


def hamon_pet_from_location(tmean, latitude_deg, doy):
    """Hamon PET computing day length from solar geometry."""
    if tmean < 0.0:
        return 0.0
    n = daylight_hours(latitude_deg, doy)
    return hamon_pet(tmean, n)
def validate_analytical(benchmark):
    checks = benchmark["validation_checks"]["analytical"]["test_cases"]
    passed = 0
    for tc in checks:
        computed = hamon_pet(tc["tmean"], tc["day_length_hours"])
        expected = tc["expected_pet"]
        tol = tc["tolerance"]
        ok = abs(computed - expected) <= tol
        status = "PASS" if ok else "FAIL"
        print("  [%s] T=%s, N=%sh → PET=%.3f (expected %s, tol %s)" % (
            status, tc["tmean"], tc["day_length_hours"], computed, expected, tol))
        if ok:
            passed += 1
    return passed, len(checks)


def validate_day_length(benchmark):
    checks = benchmark["validation_checks"]["day_length_computation"]["test_cases"]
    passed = 0
    for tc in checks:
        computed = daylight_hours(tc["latitude"], tc["doy"])
        expected = tc["expected_hours"]
        tol = tc["tolerance"]
        ok = abs(computed - expected) <= tol
        status = "PASS" if ok else "FAIL"
        print("  [%s] lat=%s°, DOY=%s → N=%.2fh (expected %s, tol %s)" % (
            status, tc["latitude"], tc["doy"], computed, expected, 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 = hamon_pet(tc["tmean"], tc["day_length_hours"])
        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: PET=%.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_t" in tc and "base_dl" in tc:
            low = hamon_pet(tc["base_t"], tc["base_dl"])
            high = hamon_pet(tc["high_t"], tc["high_dl"])
        elif "base_t" in tc:
            dl = tc["day_length_hours"]
            low = hamon_pet(tc["base_t"], dl)
            high = hamon_pet(tc["high_t"], dl)
        else:
            tmean = tc["tmean"]
            low = hamon_pet(tmean, tc["base_dl"])
            high = hamon_pet(tmean, tc["high_dl"])
        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, 42.0, 172),
        (30.0, 42.0, 196),
        (10.0, 42.0, 80),
        (25.0, 42.0, 265),
        (5.0, 42.0, 355),
    ]
    passed = 0
    our_vals = []
    pyet_vals = []
    for tmean, lat, doy in conditions:
        our = hamon_pet_from_location(tmean, lat, doy)
        try:
            pyet_val = float(pyet.hamon(
                pd.Series([tmean]),
                lat=lat * math.pi / 180.0,
                method=1,
            ).iloc[0])
        except Exception as e:
            print("  [SKIP] pyet.hamon failed:", e)
            continue
        our_vals.append(our)
        pyet_vals.append(pyet_val)
        ratio = our / pyet_val if pyet_val > 0 else float("nan")
        print("  [INFO] T=%s, lat=%s, DOY=%s: ours=%.3f, pyet=%.3f, ratio=%.2f" % (
            tmean, lat, doy, our, pyet_val, ratio))

    if len(our_vals) >= 3:
        monotonic_ok = True
        for i in range(len(our_vals) - 1):
            for j in range(i + 1, len(our_vals)):
                ours_dir = our_vals[i] - our_vals[j]
                pyet_dir = pyet_vals[i] - pyet_vals[j]
                if ours_dir * pyet_dir < 0:
                    monotonic_ok = False
        ok = monotonic_ok
        status = "PASS" if ok else "FAIL"
        print("  [%s] Rank correlation preserved between formulations" % status)
        if ok:
            passed += 1
        return passed, 1
    return 0, 0


total_passed = 0
total_checks = 0

print("\n── Analytical Benchmarks ──")
p, t = validate_analytical(benchmark)
total_passed += p
total_checks += t

print("\n── Day Length Computation ──")
p, t = validate_day_length(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=== Hamon PET: %s/%s PASS ===" % (total_passed, total_checks))
if total_passed != total_checks:
    sys.exit(1)
import matplotlib.pyplot as plt
import numpy as np

cases = benchmark["validation_checks"]["analytical"]["test_cases"]
xs = np.arange(len(cases))
comp = [hamon_pet(c["tmean"], c["day_length_hours"]) for c in cases]
exp = [c["expected_pet"] for c in cases]

t_grid = np.linspace(0, 35, 100)
n_fix = 14.0
pet_line = [hamon_pet(float(t), n_fix) for t in t_grid]

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("Hamon PET: analytical cases")
ax.set_xlabel("Test case")
ax.set_ylabel("PET (mm day⁻¹)")
ax.legend()

ax2 = axes[1]
ax2.plot(t_grid, pet_line, color="#e74c3c", lw=2, label="N = %g h" % n_fix)
ax2.set_xlabel("Mean temperature (°C)")
ax2.set_ylabel("PET (mm day⁻¹)")
ax2.set_title("PET vs temperature (Lu et al. 2005 form)")
ax2.grid(True, alpha=0.25)
ax2.legend()

plt.tight_layout()
plt.show()

Summary and provenance

  • Primal: science.et0_hamon
  • Rust: validate_hamon
  • Control script: control/hamon/hamon_pet.py
  • Benchmark: control/hamon/benchmark_hamon.json

Optional pyet validation compares ranking against a different published Hamon variant; analytical targets come from benchmark_hamon.json.