Soil Moisture Sensor Calibration (Dong et al., 2020)
Rendered from 002-soil-sensor-calibration.ipynb
Soil Moisture Sensor Calibration (Dong et al.\ 2020)
Citation: Dong, Y., Miller, W.\ M., Kelley, K.\ M.\ (2020). Agriculture 10(12):598. doi:10.3390/agriculture10120598
AirSpring linkage: primal science.sensor_calibration; Rust binary validate_soil_sensors.
Source baseline: control/soil_sensors/calibration_dong2020.py; benchmark digits: control/soil_sensors/benchmark_dong2020.json.
Theory
Industry calibrations often begin from apparent permittivity $\varepsilon$ (FDR/TDR proxy). The Topp et al.\ (1980) cubic maps dielectric permittivity to volumetric water content $\theta$ (m$^3$ m$^{-3}$):
$$\theta(\varepsilon) = -5.3\times 10^{-2} + 2.92\times 10^{-2},\varepsilon - 5.5\times 10^{-4},\varepsilon^2 + 4.3\times 10^{-6},\varepsilon^3$$
Dong et al.\ (2020) evaluate RMSE, index-of-agreement, and bias between factory-calibrated probes and volumetric gravimetrics, then refine fits with linear/non-linear correction models (their Table 4), matching the scaffolding in calibration_dong2020.py.
import importlib.util
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/soil_sensors/benchmark_dong2020.json").resolve()
CONTROL_PATH = (NOTEBOOK_DIR / "../../control/soil_sensors/calibration_dong2020.py").resolve()
spec = importlib.util.spec_from_file_location("cal", CONTROL_PATH)
cal = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cal)
with open(BENCH_PATH, encoding="utf-8") as f:
dg = json.load(f)
criteria = dg["statistical_formulas"]["criteria"]
print("Loaded:", BENCH_PATH)
print("MBE thresh ±", criteria["mbe_threshold"], "| RMSE thresh <", criteria["rmse_threshold"])# Topp + statistics mirrored in calibration_dong2020.py
epsilons = np.linspace(3, 40, 200)
theta_line = np.array([cal.topp_equation(float(e)) for e in epsilons])
coeff = dg["topp_equation"]["coefficients"]
print(" coeffs :", coeff)
pairs = dg["table_3_factory_calibration"]
def flat_rmse(sensor, soil_key):
return pairs[sensor][soil_key]["rmse"]
print(" Example CS616/sand RMSE:", flat_rmse("cs616", "sand"))topp_block = dg["topp_equation"]
_tol = topp_block["tolerance"]
for pt in topp_block["published_points"]:
th = cal.topp_equation(pt["epsilon"])
assert abs(th - pt["theta_expected"]) <= _tol
meas = np.array([0.10, 0.15, 0.20, 0.25, 0.30])
pred = meas.copy()
assert abs(cal.compute_rmse(meas, pred)) < 1e-12
assert abs(cal.compute_ia(meas, pred) - 1.0) < 1e-12
assert abs(cal.compute_mbe(meas, pred)) < 1e-12
mbe_thresh = dg["statistical_formulas"]["criteria"]["mbe_threshold"]
for sensor_name, soils in dg["table_3_factory_calibration"].items():
if sensor_name.startswith("_"):
continue
for soil_name, stats in soils.items():
mbe_ok = abs(stats["mbe"]) <= mbe_thresh
rmse_ok = stats["rmse"] < dg["statistical_formulas"]["criteria"]["rmse_threshold"]
# Paper logic encoded in calibration_dong2020.py tests
if sensor_name == "cs616" and soil_name == "sand":
assert mbe_ok and rmse_ok
else:
assert (not mbe_ok) or (not rmse_ok)
field = dg["field_validation_rmse"]
for sensor_name, soils in field.items():
if sensor_name.startswith("_"):
continue
for soil_name, data in soils.items():
assert data["corrected"] < data["factory"]
print("PASS: Topp table, statistics identities, criteria rules, RMSE reductions")C_GREEN, C_RED, C_BLUE = "#2ecc71", "#e74c3c", "#3498db"
eps_pub = np.array([pt["epsilon"] for pt in topp_block["published_points"]])
theta_pub_meas = np.array([pt["theta_expected"] for pt in topp_block["published_points"]])
theta_pub_pred = np.array([cal.topp_equation(e) for e in eps_pub])
fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(epsilons, theta_line, color=C_BLUE, label="Topp curve (dense grid)")
ax.scatter(eps_pub, theta_pub_meas, color=C_RED, s=60, zorder=3, label="Published θ (benchmark)")
ax.scatter(eps_pub, theta_pub_pred, facecolors="none", edgecolors=C_GREEN, s=80, lw=2, label="Notebook eval")
for lo, hi, col in [(0.10, 0.30, "#cccccc")]:
ax.axhspan(lo, hi, color=col, alpha=0.08)
ax.set_xlabel("Dielectric ε (-)")
ax.set_ylabel("Volumetric water content θ (-)")
ax.set_title("Topp (1980) equation vs Dong benchmark anchors")
ax.legend(ncol=2, fontsize="small")
plt.tight_layout()
plt.show()Summary
We bound the Dong et al.\ digitized QA around the Topp dielectric calibration, analytic statistics checks (perfect/bias RMSE behaviors), categorical pass/fail gating mirrored from Table 3, and monotonic corrections where field-derived RMSE always improves—precisely aligning with how benchmark_dong2020.json informs validate_soil_sensors regressions alongside calibration_dong2020.py.