Outlook to Live, humidity calibration, Models tab rebuilt without scrollers

Seven day outlook moves from History to Live, which now runs four rows.
Conditions ahead tightened so the Live column no longer needs a scroller.

Adds HumidityCompensator: an additive RH offset estimated by one-step RLS from
a trusted hygrometer, clamped to +/-35%, persisted, exposed at
POST /api/calibrate/humidity and on the renamed Models and calibration tab.

It also implements the psychrometric term (RH moved from element temperature
onto air temperature via conserved vapour pressure) but leaves it OFF by
default. The thermal argument predicts a hot element reads low; measured
against a reference hygrometer this board read 75.4% where the truth was
50.4%, so it reads HIGH and that correction would push it the wrong way. When
the flag is enabled, simulate.py applies the exact inverse, per the
simulator/compensator trap in DESIGN.md section 2.

Models pane rebuilt: the scorecard is one column per target so all 18 heads
are visible, and no panel on the tab uses an internal scroller. Verified in
Chromium at 1600x900: Live, History and Models all report zero scrollbars,
zero clipping, no page scroll, zero console errors. Backtest is numerically
identical to the previous commit, confirming the humidity work is a no-op
while the flag is off.
This commit is contained in:
2026-08-15 21:36:10 +01:00
parent 49c0aee2e1
commit e27a4b41c8
9 changed files with 336 additions and 96 deletions
+14 -3
View File
@@ -62,7 +62,8 @@ from ashvale.storage import Store # noqa: E402
def generate(days: float, step_s: int, lat: float, lon: float,
seed: int = 11, end: float | None = None) -> dict:
seed: int = 11, end: float | None = None,
psychrometric: bool = False) -> dict:
rng = np.random.default_rng(seed)
n = int(days * 86400 / step_s)
# Anchoring to wall clock makes a fixed seed insufficient for reproducibility:
@@ -131,11 +132,21 @@ def generate(days: float, step_s: int, lat: float, lon: float,
# no amount of calibration can remove, and quietly caps your skill score.
k_true = 0.55
temp_raw = (temp + k_true * cpu) / (1.0 + k_true) + 0.05 * rng.normal(size=n)
# If the compensator will move RH from the element temperature onto the air
# temperature, the forward model here must be its exact inverse, or the
# synthetic data bakes in a bias no calibration can remove. Same trap as the
# thermal algebra above. Off by default, matching sensor.hum_psychrometric.
if psychrometric:
es_raw = 6.112 * np.exp(17.625 * temp_raw / (243.04 + temp_raw))
rh_sensor = np.clip(rh * es_t / es_raw, 0.0, 100.0)
else:
rh_sensor = rh
press_station = press_slp / (1.0 + 0.0) - 1.8 # nominal 15 m offset
press_station += 0.05 * rng.normal(size=n)
return {
"ts": ts, "temp": temp, "temp_raw": temp_raw, "rh": rh + 0.4 * rng.normal(size=n),
"ts": ts, "temp": temp, "temp_raw": temp_raw, "rh": rh_sensor + 0.4 * rng.normal(size=n),
"press": press_station, "press_slp": press_slp, "cpu": cpu,
"lux": lux * (0.85 + 0.3 * rng.random(n)), "dew": dew, "cloud": cloud,
}
@@ -176,7 +187,7 @@ def main() -> None:
print("cleared existing telemetry, forecasts and scores")
data = generate(args.days, args.step, cfg.site.latitude, cfg.site.longitude,
args.seed, args.end)
args.seed, args.end, cfg.sensor.hum_psychrometric)
tracker = SignalTracker(cfg)
n = data["ts"].size