mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
A room held at a setpoint is a different process from one left to drift. It is
a closed loop, and persistence, the baseline everything here is scored against,
is the wrong statement about it: the truth is not that it stays where it is, it
is that it returns to the setpoint.
So site.heating adds a fourth ensemble member, first order because that is what
a controlled system is:
dT_set(h) = (T_set - T_now) * (1 - exp(-h / tau))
Humidity follows and is the part that is easy to get wrong. Heating adds no
moisture, so vapour pressure is conserved and not relative humidity:
RH(h) = RH_now * es(T_now) / es(T_now + dT_set(h))
Warm the air and RH falls although nothing was dried, which is why a heated
house in winter is dry. The test asserts the dew point is unchanged to 1e-6.
Pressure gets zero: a thermostat cannot move the synoptic field.
Offered, not imposed. Hedge scores this member on realised error like any
other, so a wrong tau or a stale setpoint costs accuracy and gets down-weighted
rather than quietly biasing every forecast. Verified: on history with no
heating the ensemble assigned it weight 0.000. With heating off it returns zero
and is identical to persistence.
Going from three members to four means old saved heads must migrate.
from_dict reinitialises weights and member_mae. I missed member_mae first time
and it did not fail on load, it failed later inside learn() on a broadcast
error, which is a much worse place to find out; the migration test now covers
both and calls learn() to prove it.
Settings tab gains the toggle, setpoint and time constant. Turning heating on
or off is treated as a regime change like a door: discontinuity marker plus a
queued retrain.
266 lines
12 KiB
Python
266 lines
12 KiB
Python
# Copyright 2026 Kemal Yaylali
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Multi-horizon forecasting: one direct head per (target, horizon).
|
|
|
|
Direct rather than recursive. A recursive one-step model iterated 288
|
|
times to reach 24 hours compounds its own bias into a beautifully smooth
|
|
lie. Direct heads cost more memory (six horizons x three targets = 18
|
|
small models, about 150 kB total) and are worth every byte.
|
|
|
|
Each head predicts a *delta from now*, then the ensemble blends three
|
|
opinions with weights that are themselves learned online:
|
|
|
|
persistence : it will be exactly as it is now
|
|
climatology : it will be whatever this hour of this day usually is
|
|
learned RLS : it will be now plus what the regressors imply
|
|
|
|
Persistence wins at 15 minutes. Climatology wins at 24 hours. The RLS
|
|
head wins in the middle, which is exactly the region a physical
|
|
forecaster finds hardest. The blend weights are updated by exponentiated
|
|
gradient (Hedge), so the ensemble is never worse than its best member by
|
|
more than a log factor, and it re-weights itself within a day when the
|
|
season turns.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
import numpy as np
|
|
|
|
from ..features import N_FEATURES, Standardiser, supervised_pairs
|
|
from .rls import AdaptiveConformal, RecursiveLeastSquares
|
|
|
|
MEMBERS = ("persistence", "climatology", "learned", "setpoint")
|
|
|
|
|
|
class ForecastHead:
|
|
"""One target, one horizon."""
|
|
|
|
def __init__(self, target: str, horizon_s: int, n_features: int = N_FEATURES,
|
|
forgetting: float = 0.9985, delta: float = 100.0,
|
|
alpha: float = 0.10, conformal_window: int = 400,
|
|
gamma: float = 0.01, hedge_eta: float = 0.35):
|
|
self.target = target
|
|
self.horizon_s = int(horizon_s)
|
|
self.model = RecursiveLeastSquares(n_features, forgetting, delta)
|
|
self.conformal = AdaptiveConformal(alpha, conformal_window, gamma)
|
|
self.weights = np.ones(len(MEMBERS)) / len(MEMBERS)
|
|
self.eta = float(hedge_eta)
|
|
self.member_mae = np.zeros(len(MEMBERS))
|
|
self.n_scored = 0
|
|
|
|
# -------------------------------------------------------- prediction
|
|
|
|
def predict(self, x: np.ndarray, anchor: float,
|
|
climatology_delta: float = 0.0,
|
|
setpoint_delta: float = 0.0) -> Dict[str, float]:
|
|
learned_delta = self.model.predict(x)
|
|
deltas = np.array([0.0, float(climatology_delta), float(learned_delta),
|
|
float(setpoint_delta)])
|
|
blended = float(np.dot(self.weights, deltas))
|
|
mu = float(anchor + blended)
|
|
sigma = self.model.predict_std(x, self.model.noise_var)
|
|
lo, hi = self.conformal.interval(mu, fallback_sigma=sigma)
|
|
return {
|
|
"mu": mu,
|
|
"lo": lo,
|
|
"hi": hi,
|
|
"sigma": sigma,
|
|
"delta": blended,
|
|
"members": {m: float(anchor + d) for m, d in zip(MEMBERS, deltas)},
|
|
"weights": {m: float(w) for m, w in zip(MEMBERS, self.weights)},
|
|
}
|
|
|
|
# ---------------------------------------------------------- learning
|
|
|
|
def learn(self, x: np.ndarray, anchor: float, truth: float,
|
|
climatology_delta: float = 0.0,
|
|
setpoint_delta: float = 0.0) -> float:
|
|
"""One supervised step given a matured target."""
|
|
deltas = np.array([0.0, float(climatology_delta),
|
|
float(self.model.predict(x)),
|
|
float(setpoint_delta)])
|
|
member_pred = anchor + deltas
|
|
losses = np.abs(member_pred - truth)
|
|
|
|
# Hedge / exponentiated gradient on normalised losses
|
|
scale = max(float(np.max(losses)), 1e-6)
|
|
self.weights *= np.exp(-self.eta * losses / scale)
|
|
self.weights = np.clip(self.weights, 1e-4, None)
|
|
self.weights /= self.weights.sum()
|
|
|
|
blended = float(np.dot(self.weights, member_pred))
|
|
residual = truth - blended
|
|
self.conformal.observe(residual)
|
|
|
|
self.model.update(x, truth - anchor)
|
|
|
|
self.member_mae = 0.98 * self.member_mae + 0.02 * losses
|
|
self.n_scored += 1
|
|
return residual
|
|
|
|
def to_dict(self) -> Dict:
|
|
return {"target": self.target, "horizon_s": self.horizon_s,
|
|
"model": self.model.to_dict(), "conformal": self.conformal.to_dict(),
|
|
"weights": self.weights.tolist(), "eta": self.eta,
|
|
"member_mae": self.member_mae.tolist(), "n_scored": self.n_scored}
|
|
|
|
@classmethod
|
|
def from_dict(cls, s: Dict) -> "ForecastHead":
|
|
h = cls(s["target"], s["horizon_s"])
|
|
h.model = RecursiveLeastSquares.from_dict(s["model"])
|
|
h.conformal = AdaptiveConformal.from_dict(s["conformal"])
|
|
w = np.array(s["weights"], dtype=float)
|
|
if w.size != len(MEMBERS):
|
|
# A saved head from before the setpoint member existed. Reinitialise
|
|
# uniformly rather than guessing: the Hedge weights re-converge in
|
|
# about a day, which is far cheaper than silently mismatching a
|
|
# member to the wrong loss and corrupting every blend until someone
|
|
# notices.
|
|
w = np.ones(len(MEMBERS)) / len(MEMBERS)
|
|
h.weights = w
|
|
h.eta = s["eta"]
|
|
mae = np.array(s["member_mae"], dtype=float)
|
|
# Same migration as the weights. Missing this one did not fail on load,
|
|
# it failed later inside learn() on a shape mismatch, which is a worse
|
|
# place to find out.
|
|
if mae.size != len(MEMBERS):
|
|
mae = np.zeros(len(MEMBERS))
|
|
h.member_mae = mae
|
|
h.n_scored = s.get("n_scored", 0)
|
|
return h
|
|
|
|
|
|
class NowcastEnsemble:
|
|
"""The full bank of heads plus the shared feature standardiser."""
|
|
|
|
def __init__(self, targets: Tuple[str, ...], horizons_s: Tuple[int, ...],
|
|
cfg_model):
|
|
self.targets = tuple(targets)
|
|
self.horizons = tuple(int(h) for h in horizons_s)
|
|
self.grid_s = int(cfg_model.grid_s)
|
|
self.scaler = Standardiser(N_FEATURES)
|
|
self.heads: Dict[Tuple[str, int], ForecastHead] = {
|
|
(t, h): ForecastHead(
|
|
t, h, N_FEATURES, cfg_model.rls_forgetting, cfg_model.rls_delta,
|
|
cfg_model.conformal_alpha, cfg_model.conformal_window,
|
|
cfg_model.conformal_gamma,
|
|
)
|
|
for t in self.targets for h in self.horizons
|
|
}
|
|
self.trained_rows = 0
|
|
|
|
# ------------------------------------------------------------ train
|
|
|
|
def fit(self, X: np.ndarray, valid: np.ndarray, series: Dict[str, np.ndarray],
|
|
climatology=None, grid_ts: Optional[np.ndarray] = None,
|
|
passes: int = 1, max_pairs: int = 2500, setpoint_fn=None) -> Dict[str, int]:
|
|
"""Batch-update every head from history.
|
|
|
|
`max_pairs` bounds the work per head to the most recent samples.
|
|
This is not a shortcut: with a forgetting factor of 0.9985 the
|
|
effective memory is about 11 hours, so the 4000th-most-recent
|
|
sample carries a weight of roughly e^-6. Training on it costs
|
|
real seconds on a Cortex-A53 and buys nothing measurable.
|
|
"""
|
|
"""Batch pass over history. Called on startup and every retrain tick."""
|
|
if X.shape[0] < 10:
|
|
return {"rows": 0}
|
|
self.scaler.partial_fit(X[valid][:: max(1, X.shape[0] // 2000)])
|
|
Xs = self.scaler.transform(X)
|
|
|
|
counts = {}
|
|
for target in self.targets:
|
|
y = series[target]
|
|
for h in self.horizons:
|
|
steps = max(int(round(h / self.grid_s)), 1)
|
|
Xa, dy, anchor = supervised_pairs(Xs, valid, y, steps)
|
|
if Xa.shape[0] < 5:
|
|
counts[f"{target}@{h}"] = 0
|
|
continue
|
|
if Xa.shape[0] > max_pairs:
|
|
Xa, dy, anchor = Xa[-max_pairs:], dy[-max_pairs:], anchor[-max_pairs:]
|
|
head = self.heads[(target, h)]
|
|
clim = np.zeros(Xa.shape[0])
|
|
if climatology is not None and grid_ts is not None and climatology.ready:
|
|
n = grid_ts.size
|
|
ts_a = grid_ts[:n - steps]
|
|
mask_len = min(ts_a.size, Xa.shape[0])
|
|
clim_now = climatology.predict(target, ts_a[-mask_len:])
|
|
clim_fut = climatology.predict(target, ts_a[-mask_len:] + h)
|
|
clim = np.zeros(Xa.shape[0])
|
|
clim[-mask_len:] = clim_fut - clim_now
|
|
for _ in range(max(int(passes), 1)):
|
|
for i in range(Xa.shape[0]):
|
|
head.learn(Xa[i], anchor[i], anchor[i] + dy[i], clim[i],
|
|
setpoint_fn(target, h, anchor[i]) if setpoint_fn else 0.0)
|
|
counts[f"{target}@{h}"] = int(Xa.shape[0])
|
|
self.trained_rows = int(X.shape[0])
|
|
return counts
|
|
|
|
# --------------------------------------------------------- inference
|
|
|
|
def forecast(self, x_raw: np.ndarray, anchors: Dict[str, float], now: float,
|
|
climatology=None, setpoint_fn=None) -> Dict[str, Dict[int, Dict[str, float]]]:
|
|
x = self.scaler.transform(np.atleast_2d(x_raw))[0]
|
|
out: Dict[str, Dict[int, Dict[str, float]]] = {}
|
|
for target in self.targets:
|
|
anchor = float(anchors.get(target, 0.0))
|
|
out[target] = {}
|
|
for h in self.horizons:
|
|
clim_delta = 0.0
|
|
if climatology is not None and climatology.ready:
|
|
clim_delta = float(climatology.predict(target, np.array([now + h]))[0]
|
|
- climatology.predict(target, np.array([now]))[0])
|
|
sp = setpoint_fn(target, h, anchor) if setpoint_fn else 0.0
|
|
out[target][h] = self.heads[(target, h)].predict(x, anchor, clim_delta, sp)
|
|
return out
|
|
|
|
def diagnostics(self) -> List[Dict]:
|
|
rows = []
|
|
for (target, h), head in sorted(self.heads.items()):
|
|
rows.append({
|
|
"target": target,
|
|
"horizon_s": h,
|
|
"n_updates": head.model.n_updates,
|
|
"n_scored": head.n_scored,
|
|
"weights": {m: round(float(w), 3) for m, w in zip(MEMBERS, head.weights)},
|
|
"member_mae": {m: round(float(v), 3) for m, v in zip(MEMBERS, head.member_mae)},
|
|
"conformal_alpha": round(head.conformal.alpha, 4),
|
|
"conformal_halfwidth": round(float(head.conformal.quantile()), 3)
|
|
if np.isfinite(head.conformal.quantile()) else None,
|
|
"coverage": round(head.conformal.empirical_coverage, 3)
|
|
if np.isfinite(head.conformal.empirical_coverage) else None,
|
|
})
|
|
return rows
|
|
|
|
def to_dict(self) -> Dict:
|
|
return {
|
|
"targets": list(self.targets),
|
|
"horizons": list(self.horizons),
|
|
"grid_s": self.grid_s,
|
|
"scaler": self.scaler.to_dict(),
|
|
"heads": [h.to_dict() for h in self.heads.values()],
|
|
"trained_rows": self.trained_rows,
|
|
}
|
|
|
|
def load_dict(self, s: Dict) -> None:
|
|
self.scaler = Standardiser.from_dict(s["scaler"])
|
|
for hs in s["heads"]:
|
|
head = ForecastHead.from_dict(hs)
|
|
self.heads[(head.target, head.horizon_s)] = head
|
|
self.trained_rows = s.get("trained_rows", 0)
|