mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
Heated environment: a thermostat member in the forecast ensemble
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.
This commit is contained in:
@@ -136,6 +136,9 @@ class SettingsIn(BaseModel):
|
||||
altitude_m: Optional[float] = Field(None, ge=-430, le=9000)
|
||||
latitude: Optional[float] = Field(None, ge=-90, le=90)
|
||||
longitude: Optional[float] = Field(None, ge=-180, le=180)
|
||||
heating: Optional[bool] = None
|
||||
heating_setpoint_c: Optional[float] = Field(None, ge=5, le=35)
|
||||
thermal_time_constant_h: Optional[float] = Field(None, ge=0.1, le=24)
|
||||
hum_psychrometric: Optional[bool] = None
|
||||
led_enabled: Optional[bool] = None
|
||||
led_fps: Optional[float] = Field(None, ge=4, le=30)
|
||||
@@ -647,6 +650,9 @@ def get_settings() -> Dict:
|
||||
"latitude": CONFIG.site.latitude,
|
||||
"longitude": CONFIG.site.longitude,
|
||||
"timezone": CONFIG.site.timezone,
|
||||
"heating": CONFIG.site.heating,
|
||||
"heating_setpoint_c": CONFIG.site.heating_setpoint_c,
|
||||
"thermal_time_constant_h": CONFIG.site.thermal_time_constant_h,
|
||||
"name": CONFIG.site.name},
|
||||
"sensor": {"hum_psychrometric": CONFIG.sensor.hum_psychrometric,
|
||||
"cpu_heat_k": round(st.tracker.compensator.k, 4),
|
||||
@@ -693,6 +699,24 @@ def post_settings(body: SettingsIn) -> Dict:
|
||||
# history is now inconsistent with the new value until re-derived.
|
||||
needs_recompute = needs_recompute or name == "altitude_m"
|
||||
|
||||
# Turning the thermostat model on or off changes which process the heads are
|
||||
# fitting, so it is a regime change and gets the same treatment as a door.
|
||||
if body.heating is not None and body.heating != CONFIG.site.heating:
|
||||
CONFIG.site.heating = bool(body.heating)
|
||||
patch.setdefault("site", {})["heating"] = bool(body.heating)
|
||||
applied.append(f"heating {'on' if body.heating else 'off'}")
|
||||
st.store.log_event("discontinuity", "warn",
|
||||
f"heating {'on' if body.heating else 'off'}")
|
||||
st.monitor.retrain_requested = True
|
||||
|
||||
for name, value, label in (
|
||||
("heating_setpoint_c", body.heating_setpoint_c, "setpoint"),
|
||||
("thermal_time_constant_h", body.thermal_time_constant_h, "time constant")):
|
||||
if value is not None and value != getattr(CONFIG.site, name):
|
||||
applied.append(f"{label} {getattr(CONFIG.site, name)} -> {value}")
|
||||
setattr(CONFIG.site, name, float(value))
|
||||
patch.setdefault("site", {})[name] = float(value)
|
||||
|
||||
if body.hum_psychrometric is not None and \
|
||||
body.hum_psychrometric != CONFIG.sensor.hum_psychrometric:
|
||||
CONFIG.sensor.hum_psychrometric = bool(body.hum_psychrometric)
|
||||
|
||||
+19
-1
@@ -57,7 +57,25 @@ class SiteConfig:
|
||||
# keep predicting the old regime for two days unless told. POST
|
||||
# /api/environment marks the moment and asks for a retrain.
|
||||
environment: str = "indoor" # indoor | sheltered | outdoor
|
||||
enclosure: str = "closed" # closed | ventilated | open # honest flag, changes how forecasts are worded
|
||||
enclosure: str = "closed" # closed | ventilated | open
|
||||
|
||||
# Central heating or air conditioning holding the room at a setpoint.
|
||||
#
|
||||
# This is a genuine change of process, not a label. A free-running room
|
||||
# follows outdoor forcing and drifts; a thermostatted one is a closed loop
|
||||
# that pulls back toward heating_setpoint_c whenever it strays. Persistence
|
||||
# ("tomorrow equals today") is the wrong baseline for a controlled system,
|
||||
# because the truth is "it returns to the setpoint".
|
||||
#
|
||||
# thermal_time_constant_h is how fast that pull acts: the time to close
|
||||
# about 63% of a gap. A small well-insulated flat with responsive heating is
|
||||
# under an hour; a large draughty house with slow radiators is several. If
|
||||
# you do not know it, leave it: the ensemble weights this member against the
|
||||
# others from measured error, so a wrong constant costs accuracy, not
|
||||
# correctness.
|
||||
heating: bool = False
|
||||
heating_setpoint_c: float = 21.0
|
||||
thermal_time_constant_h: float = 1.5 # honest flag, changes how forecasts are worded
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -489,6 +489,22 @@ DASHBOARD_HTML = r"""
|
||||
<p class="text-[9px] text-slate-600 leading-snug mt-1.5">Closing a door changes how strongly the sensor couples to outside. The heads carry about 55 hours of memory, so tell them rather than waiting two days.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t border-slate-800">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<div>
|
||||
<span class="text-[11px] font-semibold text-slate-300">Heated or cooled to a setpoint</span>
|
||||
<p class="text-[9px] text-slate-600 leading-snug">A thermostat makes the room a closed loop: it returns to the setpoint instead of drifting. Persistence is the wrong baseline for that, so this adds a fourth ensemble member and lets the Hedge weights decide if it earns its place. Humidity follows at constant vapour pressure, which is why a heated house is dry.</p>
|
||||
</div>
|
||||
<button id="s-heat" class="shrink-0 ml-3 px-2.5 py-1 rounded-lg border text-[10px] font-mono">--</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 font-mono text-[10px]">
|
||||
<label class="block"><span class="text-slate-600 uppercase text-[9px]">setpoint °C</span>
|
||||
<input id="s-setpoint" type="number" step="0.5" class="w-full mt-0.5 bg-slate-950/70 border border-slate-800 rounded-lg px-2 py-1.5 text-white"></label>
|
||||
<label class="block"><span class="text-slate-600 uppercase text-[9px]">time constant h</span>
|
||||
<input id="s-tau" type="number" step="0.1" class="w-full mt-0.5 bg-slate-950/70 border border-slate-800 rounded-lg px-2 py-1.5 text-white"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-3">
|
||||
<input id="s-note" placeholder="what changed, e.g. doors shut, felt chilly"
|
||||
class="flex-1 min-w-0 bg-slate-950/70 border border-slate-800 rounded-lg px-2.5 py-1.5 text-[11px] font-mono text-white">
|
||||
@@ -1111,6 +1127,10 @@ async function loadSettings() {
|
||||
el('s-lon').value = site.longitude;
|
||||
el('s-psy').innerText = sen.hum_psychrometric ? 'on' : 'off';
|
||||
el('s-psy').className = sen.hum_psychrometric ? PILL_ON : PILL_OFF;
|
||||
el('s-heat').innerText = site.heating ? 'on' : 'off';
|
||||
el('s-heat').className = site.heating ? PILL_ON : PILL_OFF;
|
||||
el('s-setpoint').value = site.heating_setpoint_c;
|
||||
el('s-tau').value = site.thermal_time_constant_h;
|
||||
}
|
||||
loaders.settings = loadSettings;
|
||||
|
||||
@@ -1147,6 +1167,13 @@ el('s-led').addEventListener('click', () =>
|
||||
postSettings({led_enabled: !(settingsDoc.server||{}).led_enabled}, 's-msg'));
|
||||
el('s-psy').addEventListener('click', () =>
|
||||
postSettings({hum_psychrometric: !(settingsDoc.sensor||{}).hum_psychrometric}, 's-msg'));
|
||||
el('s-heat').addEventListener('click', () =>
|
||||
postSettings({heating: !(settingsDoc.site||{}).heating}, 's-msg'));
|
||||
for (const id of ['s-setpoint','s-tau']) {
|
||||
el(id).addEventListener('change', () => postSettings({
|
||||
heating_setpoint_c: Number(el('s-setpoint').value),
|
||||
thermal_time_constant_h: Number(el('s-tau').value)}, 's-msg'));
|
||||
}
|
||||
el('s-fps').addEventListener('input', e => el('s-fps-val').innerText = e.target.value + ' fps');
|
||||
el('s-fps').addEventListener('change', e =>
|
||||
postSettings({led_fps: Number(e.target.value)}, 's-msg'));
|
||||
|
||||
@@ -178,6 +178,10 @@ def pipeline(cfg) -> List[Dict[str, Any]]:
|
||||
"the model then detonates at sunrise. The trace is capped. "
|
||||
"This is the most common way a field RLS deployment dies.",
|
||||
"math": [
|
||||
r"dT_{set}(h) = (T_{set} - T_{now})\left(1 - e^{-h/\tau}\right)"
|
||||
r"\qquad\text{(thermostat member, first-order closed loop)}",
|
||||
r"RH(h) = RH_{now}\,\frac{e_s(T_{now})}{e_s(T_{now} + dT_{set}(h))}"
|
||||
r"\qquad\text{(heating adds no moisture, so dew point is conserved)}",
|
||||
r"\hat{\theta} = \arg\min_{\theta}\; \sum_{i=1}^{t}"
|
||||
r"\lambda^{\,t-i}\big(y_i - \theta^{\top}x_i\big)^{2}"
|
||||
r"\qquad\text{(exponentially weighted least squares)}",
|
||||
|
||||
+31
-11
@@ -43,7 +43,7 @@ import numpy as np
|
||||
from ..features import N_FEATURES, Standardiser, supervised_pairs
|
||||
from .rls import AdaptiveConformal, RecursiveLeastSquares
|
||||
|
||||
MEMBERS = ("persistence", "climatology", "learned")
|
||||
MEMBERS = ("persistence", "climatology", "learned", "setpoint")
|
||||
|
||||
|
||||
class ForecastHead:
|
||||
@@ -65,9 +65,11 @@ class ForecastHead:
|
||||
# -------------------------------------------------------- prediction
|
||||
|
||||
def predict(self, x: np.ndarray, anchor: float,
|
||||
climatology_delta: float = 0.0) -> Dict[str, 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)])
|
||||
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)
|
||||
@@ -85,10 +87,12 @@ class ForecastHead:
|
||||
# ---------------------------------------------------------- learning
|
||||
|
||||
def learn(self, x: np.ndarray, anchor: float, truth: float,
|
||||
climatology_delta: float = 0.0) -> 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(self.model.predict(x)),
|
||||
float(setpoint_delta)])
|
||||
member_pred = anchor + deltas
|
||||
losses = np.abs(member_pred - truth)
|
||||
|
||||
@@ -119,9 +123,23 @@ class ForecastHead:
|
||||
h = cls(s["target"], s["horizon_s"])
|
||||
h.model = RecursiveLeastSquares.from_dict(s["model"])
|
||||
h.conformal = AdaptiveConformal.from_dict(s["conformal"])
|
||||
h.weights = np.array(s["weights"], dtype=float)
|
||||
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"]
|
||||
h.member_mae = np.array(s["member_mae"], dtype=float)
|
||||
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
|
||||
|
||||
@@ -149,7 +167,7 @@ class NowcastEnsemble:
|
||||
|
||||
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) -> Dict[str, int]:
|
||||
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.
|
||||
@@ -187,7 +205,8 @@ class NowcastEnsemble:
|
||||
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])
|
||||
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
|
||||
@@ -195,7 +214,7 @@ class NowcastEnsemble:
|
||||
# --------------------------------------------------------- inference
|
||||
|
||||
def forecast(self, x_raw: np.ndarray, anchors: Dict[str, float], now: float,
|
||||
climatology=None) -> Dict[str, Dict[int, Dict[str, 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:
|
||||
@@ -206,7 +225,8 @@ class NowcastEnsemble:
|
||||
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])
|
||||
out[target][h] = self.heads[(target, h)].predict(x, anchor, clim_delta)
|
||||
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]:
|
||||
|
||||
+53
-2
@@ -34,6 +34,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -382,6 +383,55 @@ class Station:
|
||||
return {"rows": written, "seconds": round(secs, 2),
|
||||
"k": comp.k, "hum_offset": hcomp.offset}
|
||||
|
||||
def _setpoint_delta(self, target: str, horizon_s: int, anchor: float) -> float:
|
||||
"""Where a thermostatted room is heading, as a delta from now.
|
||||
|
||||
A controlled room is first order: the heating closes the gap to the
|
||||
setpoint exponentially, so after time h the remaining error is
|
||||
exp(-h/tau) of what it was. The expected change is therefore
|
||||
|
||||
dT(h) = (T_set - T_now) * (1 - exp(-h / tau))
|
||||
|
||||
which is zero at h=0 and asymptotes to the full correction. That is a
|
||||
much better statement about a heated room than persistence, which claims
|
||||
the room stays wherever it happens to be.
|
||||
|
||||
Humidity follows for free and is the part people get wrong. Heating adds
|
||||
no moisture, so vapour pressure is what is conserved, not relative
|
||||
humidity. Warm the air and RH falls even though nothing was dried:
|
||||
|
||||
RH(h) = RH_now * es(T_now) / es(T_now + dT(h))
|
||||
|
||||
This is why a heated house in winter is dry. Pressure is unaffected: a
|
||||
thermostat cannot move the synoptic field, so that member stays at zero
|
||||
and the ensemble will correctly ignore it.
|
||||
|
||||
Returns 0.0 when heating is off, which makes this member identical to
|
||||
persistence and therefore harmless.
|
||||
"""
|
||||
site = self.cfg.site
|
||||
if not site.heating:
|
||||
return 0.0
|
||||
tau_s = max(float(site.thermal_time_constant_h), 0.05) * 3600.0
|
||||
closed = 1.0 - math.exp(-float(horizon_s) / tau_s)
|
||||
|
||||
temp_now = self.live.get("temp_smooth")
|
||||
if temp_now is None:
|
||||
return 0.0
|
||||
d_temp = (float(site.heating_setpoint_c) - float(temp_now)) * closed
|
||||
|
||||
if target == "temperature":
|
||||
return d_temp
|
||||
if target == "humidity":
|
||||
# Constant vapour pressure, so RH moves only because es(T) moved.
|
||||
es_now = float(physics.saturation_vapour_pressure(temp_now))
|
||||
es_fut = float(physics.saturation_vapour_pressure(temp_now + d_temp))
|
||||
if es_fut <= 1e-9:
|
||||
return 0.0
|
||||
rh_now = float(anchor)
|
||||
return float(np.clip(rh_now * es_now / es_fut, 0.0, 100.0)) - rh_now
|
||||
return 0.0
|
||||
|
||||
def set_environment(self, environment: Optional[str] = None,
|
||||
enclosure: Optional[str] = None,
|
||||
note: str = "") -> Dict:
|
||||
@@ -467,7 +517,8 @@ class Station:
|
||||
grid_ts, cols, X, valid = built
|
||||
|
||||
clim_scores = self.climatology.fit(grid_ts, cols, valid)
|
||||
counts = self.nowcast.fit(X, valid, cols, self.climatology, grid_ts)
|
||||
counts = self.nowcast.fit(X, valid, cols, self.climatology, grid_ts,
|
||||
setpoint_fn=self._setpoint_delta)
|
||||
|
||||
self.last_train = time.time()
|
||||
self.monitor.clear_retrain_flag()
|
||||
@@ -503,7 +554,7 @@ class Station:
|
||||
"humidity": float(self.live.get("hum_smooth", cols["humidity"][-1])),
|
||||
"pressure": float(self.live.get("press_slp", cols["pressure"][-1])),
|
||||
}
|
||||
fc = self.nowcast.forecast(x_now, anchors, now, self.climatology)
|
||||
fc = self.nowcast.forecast(x_now, anchors, now, self.climatology, setpoint_fn=self._setpoint_delta)
|
||||
|
||||
bundle: Dict[str, Any] = {"issued_ts": now, "anchors": anchors, "targets": {}}
|
||||
for target, per_h in fc.items():
|
||||
|
||||
@@ -240,6 +240,45 @@ re-weights within about a day when the season turns.
|
||||
15-minute pressure it typically parks most of its weight on persistence. That
|
||||
is correct behaviour surfaced honestly, not a defect to engineer away.
|
||||
|
||||
### The thermostat member
|
||||
|
||||
A room held at a setpoint is not the same process as a room that is free to
|
||||
drift. It is a closed loop, and persistence, the baseline everything here is
|
||||
scored against, is simply the wrong statement about it: the truth is not "it
|
||||
stays where it is", it is "it returns to the setpoint".
|
||||
|
||||
So when `site.heating` is on, the ensemble gains a fourth member:
|
||||
|
||||
```
|
||||
dT_set(h) = (T_set - T_now) * (1 - exp(-h / tau))
|
||||
```
|
||||
|
||||
First order, because that is what a controlled system is: `tau` is the time to
|
||||
close about 63% of the gap. Zero at h = 0, asymptotic to the full correction.
|
||||
|
||||
Humidity follows and is the part that is easy to get wrong. Heating adds no
|
||||
moisture, so what is conserved is vapour pressure, 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. This is why a heated house
|
||||
in winter is dry, and the test asserts the dew point is unchanged to 1e-6.
|
||||
|
||||
Pressure gets zero: a thermostat cannot move the synoptic field.
|
||||
|
||||
**It is offered, not imposed.** The Hedge weights score this member against the
|
||||
others on realised error like any other, so a wrong `tau` or a setpoint you
|
||||
forgot to update costs accuracy and gets down-weighted, rather than quietly
|
||||
biasing every forecast. With heating off the member returns zero, which makes it
|
||||
identical to persistence and therefore harmless.
|
||||
|
||||
Adding it changed the member count from three to four, so `ForecastHead.from_dict`
|
||||
reinitialises `weights` **and** `member_mae` when a saved head has the old
|
||||
length. Missing the second one did not fail on load: it failed later inside
|
||||
`learn()` on a broadcast error, which is a much worse place to find out.
|
||||
|
||||
### Adaptive conformal intervals
|
||||
|
||||
Split conformal is valid only under exchangeability, and weather is emphatically
|
||||
|
||||
@@ -177,3 +177,74 @@ def test_kalman_state_round_trips_through_dict():
|
||||
back = KalmanCV.from_dict(kf.to_dict())
|
||||
assert back.level == pytest.approx(kf.level)
|
||||
assert back.rate == pytest.approx(kf.rate)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- thermostat
|
||||
|
||||
def test_thermostat_reversion_is_first_order_and_preserves_dew_point():
|
||||
"""A heated room is a closed loop, and heating adds no moisture.
|
||||
|
||||
Two properties, both easy to get wrong. The temperature must close the gap
|
||||
to the setpoint exponentially rather than jumping or drifting, and the
|
||||
implied humidity change must leave the dew point exactly where it was: RH
|
||||
falls only because es(T) rose, which is why a heated house in winter is dry.
|
||||
"""
|
||||
import math
|
||||
|
||||
from ashvale.config import load_config
|
||||
from ashvale.physics import dew_point, saturation_vapour_pressure
|
||||
from ashvale.station import Station
|
||||
|
||||
cfg = load_config()
|
||||
cfg.site.heating = True
|
||||
cfg.site.heating_setpoint_c = 23.0
|
||||
cfg.site.thermal_time_constant_h = 1.5
|
||||
st = Station(cfg)
|
||||
st.live = {"temp_smooth": 18.0}
|
||||
|
||||
tau = 1.5 * 3600.0
|
||||
for h in (900, 3600, 10800, 86400):
|
||||
expected = (23.0 - 18.0) * (1.0 - math.exp(-h / tau))
|
||||
assert st._setpoint_delta("temperature", h, 18.0) == pytest.approx(expected, rel=1e-9)
|
||||
|
||||
# monotonic toward the setpoint, never past it
|
||||
deltas = [st._setpoint_delta("temperature", h, 18.0)
|
||||
for h in (900, 3600, 10800, 21600, 86400)]
|
||||
assert all(a < b for a, b in zip(deltas, deltas[1:]))
|
||||
assert deltas[-1] <= 5.0 + 1e-9
|
||||
|
||||
# dew point invariant
|
||||
t0, rh0 = 18.0, 55.0
|
||||
d_t = st._setpoint_delta("temperature", 86400, t0)
|
||||
d_rh = st._setpoint_delta("humidity", 86400, rh0)
|
||||
assert float(dew_point(t0 + d_t, rh0 + d_rh)) == pytest.approx(
|
||||
float(dew_point(t0, rh0)), abs=1e-6)
|
||||
assert d_rh < 0.0, "warming a room at constant moisture must lower RH"
|
||||
assert float(saturation_vapour_pressure(t0 + d_t)) > float(
|
||||
saturation_vapour_pressure(t0))
|
||||
|
||||
# a thermostat cannot move the synoptic field
|
||||
assert st._setpoint_delta("pressure", 86400, 1013.0) == 0.0
|
||||
|
||||
# and off, the member is exactly persistence
|
||||
cfg.site.heating = False
|
||||
assert st._setpoint_delta("temperature", 86400, 18.0) == 0.0
|
||||
assert st._setpoint_delta("humidity", 86400, 55.0) == 0.0
|
||||
|
||||
|
||||
def test_forecast_head_migrates_state_from_before_the_setpoint_member():
|
||||
"""An old save has three weights where there are now four."""
|
||||
from ashvale.models.nowcast import MEMBERS, ForecastHead
|
||||
|
||||
h = ForecastHead(target="temperature", horizon_s=900, n_features=4)
|
||||
state = h.to_dict()
|
||||
state["weights"] = [0.2, 0.3, 0.5] # a pre-setpoint save
|
||||
state["member_mae"] = [0.4, 0.5, 0.6]
|
||||
back = ForecastHead.from_dict(state)
|
||||
assert back.weights.size == len(MEMBERS)
|
||||
assert float(back.weights.sum()) == pytest.approx(1.0)
|
||||
# member_mae must migrate too. Missing it did not fail on load, it failed
|
||||
# later inside learn() on a broadcast error, which is a worse place to
|
||||
# discover a migration bug.
|
||||
assert back.member_mae.size == len(MEMBERS)
|
||||
back.learn(np.zeros(4), 20.0, 20.5, 0.1, 0.2) # must not raise
|
||||
|
||||
Reference in New Issue
Block a user