mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
Readout scene, environment regime tracking, and a Kalman cadence bug in recompute
recompute replayed the Kalman over stored rows at their own spacing while q stays tuned for the live 2 s cadence. Q scales with dt^3, so at the 30 s persist interval the process noise was 3375x too large and the filter tracked noise instead of smoothing: it wrote indoor temperature rates of +/-20 C/h into the history. This is the exact trap DESIGN.md section 2 documents for simulate.py, which does scale q, and I walked into it anyway. Now rescaled per step, because tiering means the stored cadence is not constant. Mean |rate| on the real board dropped to 2.73 C/h; what remains above 10 is the filter's warm-up transient in the first four samples, which is honest. Readout scene puts the actual numbers between the animations: temperature, humidity, sea-level pressure and the signed three hour forecast, each in its channel colour, scrolling. Text is drawn whole-pixel on purpose. Everything else here is sub-pixel and that is what makes it look good, but splitting a 3 px glyph across two columns halves its peak and smears it illegible. Crisp beats smooth when the thing has to be read. site.environment and site.enclosure record where the sensor lives and what has changed around it, with POST /api/environment to change them at runtime. This is not cosmetic: closing a door changes how strongly the sensor couples to outside, which is a regime change in the process the heads are fitting, and at lambda 0.9985 they carry about 55 hours of memory. Left alone they keep predicting the old room for two days. Page-Hinkley would notice eventually but needs matured forecasts to do it, which at the long horizons is the same two days. So the endpoint marks a discontinuity and queues a retrain.
This commit is contained in:
@@ -128,6 +128,12 @@ class CalibrationIn(BaseModel):
|
|||||||
"covariance, returning to the configured prior")
|
"covariance, returning to the configured prior")
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentIn(BaseModel):
|
||||||
|
environment: Optional[str] = Field(None, description="indoor | sheltered | outdoor")
|
||||||
|
enclosure: Optional[str] = Field(None, description="closed | ventilated | open")
|
||||||
|
note: str = Field("", description="what changed, for the log")
|
||||||
|
|
||||||
|
|
||||||
class HumidityCalibrationIn(BaseModel):
|
class HumidityCalibrationIn(BaseModel):
|
||||||
reference_pct: Optional[float] = Field(None, ge=0, le=100,
|
reference_pct: Optional[float] = Field(None, ge=0, le=100,
|
||||||
description="Trusted relative humidity in %")
|
description="Trusted relative humidity in %")
|
||||||
@@ -600,6 +606,24 @@ def recompute() -> Dict:
|
|||||||
return _clean(result)
|
return _clean(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/environment")
|
||||||
|
def environment(body: EnvironmentIn) -> Dict:
|
||||||
|
"""Tell the station its surroundings changed, and have it react.
|
||||||
|
|
||||||
|
Marks a discontinuity and queues a retrain, because the learners' 55 hour
|
||||||
|
memory would otherwise keep predicting the old regime for two days.
|
||||||
|
"""
|
||||||
|
valid_env = {"indoor", "sheltered", "outdoor"}
|
||||||
|
valid_enc = {"closed", "ventilated", "open"}
|
||||||
|
if body.environment and body.environment not in valid_env:
|
||||||
|
raise HTTPException(422, f"environment must be one of {sorted(valid_env)}")
|
||||||
|
if body.enclosure and body.enclosure not in valid_enc:
|
||||||
|
raise HTTPException(422, f"enclosure must be one of {sorted(valid_enc)}")
|
||||||
|
if not body.environment and not body.enclosure:
|
||||||
|
raise HTTPException(422, "provide environment, enclosure, or both")
|
||||||
|
return _clean(_st().set_environment(body.environment, body.enclosure, body.note))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
def status() -> Dict:
|
def status() -> Dict:
|
||||||
st = _st()
|
st = _st()
|
||||||
@@ -607,6 +631,8 @@ def status() -> Dict:
|
|||||||
**st.status(),
|
**st.status(),
|
||||||
"display_frame": display.frame_name if display else None,
|
"display_frame": display.frame_name if display else None,
|
||||||
"outdoor_probe": (st.probe.status() if st.probe is not None else None),
|
"outdoor_probe": (st.probe.status() if st.probe is not None else None),
|
||||||
|
"environment": CONFIG.site.environment,
|
||||||
|
"enclosure": CONFIG.site.enclosure,
|
||||||
"events": st.store.recent_events(15),
|
"events": st.store.recent_events(15),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -41,7 +41,22 @@ class SiteConfig:
|
|||||||
longitude: float = 0.1218
|
longitude: float = 0.1218
|
||||||
altitude_m: float = 15.0 # for sea-level pressure reduction
|
altitude_m: float = 15.0 # for sea-level pressure reduction
|
||||||
timezone: str = "Europe/London"
|
timezone: str = "Europe/London"
|
||||||
indoors: bool = True # honest flag, changes how forecasts are worded
|
indoors: bool = True
|
||||||
|
# Where the sensor actually lives, and what has changed around it.
|
||||||
|
#
|
||||||
|
# This matters more than it looks. Indoors, temperature and humidity are
|
||||||
|
# governed by the building, not the sky: the diurnal swing is damped and
|
||||||
|
# lagged, and the solar features the model is given correlate weakly with
|
||||||
|
# what the thermometer does. Pressure is the exception, which is why the
|
||||||
|
# precipitation model runs on tendency rather than indoor humidity.
|
||||||
|
#
|
||||||
|
# "enclosure" is the part worth changing at runtime. Closing a door or
|
||||||
|
# opening a window is a step change in how strongly the sensor is coupled to
|
||||||
|
# outside, and the learners carry roughly 55 hours of memory, so they will
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
+120
-2
@@ -115,6 +115,64 @@ def _smoothstep(edge0: float, edge1: float, x: float) -> float:
|
|||||||
return t * t * (3.0 - 2.0 * t)
|
return t * t * (3.0 - 2.0 * t)
|
||||||
|
|
||||||
|
|
||||||
|
# A 3x5 glyph set. Three pixels wide is the narrowest a digit can be and stay
|
||||||
|
# legible, which on an 8x8 leaves room for two digits and a unit mark, or a
|
||||||
|
# smoothly scrolling strip of any length.
|
||||||
|
_FONT = {
|
||||||
|
"0": ("111", "101", "101", "101", "111"),
|
||||||
|
"1": ("010", "110", "010", "010", "111"),
|
||||||
|
"2": ("111", "001", "111", "100", "111"),
|
||||||
|
"3": ("111", "001", "111", "001", "111"),
|
||||||
|
"4": ("101", "101", "111", "001", "001"),
|
||||||
|
"5": ("111", "100", "111", "001", "111"),
|
||||||
|
"6": ("111", "100", "111", "101", "111"),
|
||||||
|
"7": ("111", "001", "010", "010", "010"),
|
||||||
|
"8": ("111", "101", "111", "101", "111"),
|
||||||
|
"9": ("111", "101", "111", "001", "111"),
|
||||||
|
"-": ("000", "000", "111", "000", "000"),
|
||||||
|
"+": ("000", "010", "111", "010", "000"),
|
||||||
|
".": ("000", "000", "000", "000", "010"),
|
||||||
|
"%": ("101", "001", "010", "100", "101"),
|
||||||
|
"C": ("111", "100", "100", "100", "111"),
|
||||||
|
"h": ("100", "100", "110", "101", "101"),
|
||||||
|
"P": ("111", "101", "111", "100", "100"),
|
||||||
|
"a": ("000", "110", "011", "101", "111"),
|
||||||
|
" ": ("000", "000", "000", "000", "000"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _text_width(text: str) -> int:
|
||||||
|
return sum(4 for _ in text)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_text(cv: Canvas, text: str, x: float, y: float, colour,
|
||||||
|
alpha: float = 1.0) -> None:
|
||||||
|
"""Whole-pixel text, deliberately.
|
||||||
|
|
||||||
|
Everything else on this panel is sub-pixel rendered, and for particles and
|
||||||
|
discs that is what makes it look good. For a 3 px wide glyph it is ruinous:
|
||||||
|
splitting each stroke across two columns halves its peak brightness and
|
||||||
|
smears the letterform until it is unreadable. Text snaps to the grid and
|
||||||
|
scrolls in whole steps. Crisp beats smooth when the thing has to be read.
|
||||||
|
"""
|
||||||
|
x = round(x)
|
||||||
|
y = round(y)
|
||||||
|
for ch in text:
|
||||||
|
g = _FONT.get(ch)
|
||||||
|
if g is not None and -4 < x < N + 1:
|
||||||
|
for r, row in enumerate(g):
|
||||||
|
yy = y + r
|
||||||
|
if yy < 0 or yy >= N:
|
||||||
|
continue
|
||||||
|
for c, on in enumerate(row):
|
||||||
|
xx = x + c
|
||||||
|
if on == "1" and 0 <= xx < N:
|
||||||
|
cv.buf[yy, xx, 0] += colour[0] * alpha
|
||||||
|
cv.buf[yy, xx, 1] += colour[1] * alpha
|
||||||
|
cv.buf[yy, xx, 2] += colour[2] * alpha
|
||||||
|
x += 4
|
||||||
|
|
||||||
|
|
||||||
class Canvas:
|
class Canvas:
|
||||||
"""An 8x8 linear-light RGB buffer with sub-pixel drawing."""
|
"""An 8x8 linear-light RGB buffer with sub-pixel drawing."""
|
||||||
|
|
||||||
@@ -712,6 +770,64 @@ class Snowflake(Scene):
|
|||||||
cv.plot(cx, cy, white, 0.62)
|
cv.plot(cx, cy, white, 0.62)
|
||||||
|
|
||||||
|
|
||||||
|
class Readout(Scene):
|
||||||
|
"""The actual numbers, scrolling between the animations.
|
||||||
|
|
||||||
|
Everything else on this panel is an impression: a hue, a drift direction, a
|
||||||
|
ray length. This is the one that tells you it is 24.2 degrees. The strip runs
|
||||||
|
measurement first, then the three hour forecast with its sign, each segment
|
||||||
|
in its channel's colour so you can tell temperature from humidity without
|
||||||
|
reading the unit.
|
||||||
|
|
||||||
|
Scrolls at a fractional pixel offset, so at 8 pixels tall the glyphs glide
|
||||||
|
rather than stepping, which is the difference between readable and a
|
||||||
|
flickering mess.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "readout"
|
||||||
|
duration = 15.0
|
||||||
|
|
||||||
|
AMBER = (1.00, 0.62, 0.06)
|
||||||
|
CYAN = (0.10, 0.78, 0.95)
|
||||||
|
VIOLET = (0.66, 0.52, 1.00)
|
||||||
|
GREEN = (0.30, 0.95, 0.55)
|
||||||
|
ROSE = (1.00, 0.35, 0.45)
|
||||||
|
|
||||||
|
def _segments(self, s: Dict) -> List[Tuple[str, Tuple[float, float, float]]]:
|
||||||
|
t = s.get("temp")
|
||||||
|
rh = s.get("humidity")
|
||||||
|
slp = s.get("press")
|
||||||
|
segs: List[Tuple[str, Tuple[float, float, float]]] = []
|
||||||
|
if t is not None:
|
||||||
|
segs.append((f"{t:.1f}C", self.AMBER))
|
||||||
|
if rh is not None:
|
||||||
|
segs.append((f"{rh:.0f}%", self.CYAN))
|
||||||
|
if slp is not None:
|
||||||
|
segs.append((f"{slp:.0f}Pa", self.VIOLET))
|
||||||
|
fc = s.get("forecast") or []
|
||||||
|
if len(fc) >= 3:
|
||||||
|
d = float(fc[2].get("delta", 0.0)) # the three hour head
|
||||||
|
segs.append((f"{d:+.1f}", self.GREEN if d >= 0 else self.ROSE))
|
||||||
|
return segs or [("--", self.AMBER)]
|
||||||
|
|
||||||
|
def render(self, cv: Canvas, t: float, s: Dict) -> None:
|
||||||
|
segs = self._segments(s)
|
||||||
|
gap = 3.0
|
||||||
|
widths = [_text_width(txt) + gap for txt, _ in segs]
|
||||||
|
total = sum(widths)
|
||||||
|
|
||||||
|
# A faint moving ground so the text is not floating in black.
|
||||||
|
cv.wash(np.exp(-((Y - 3.5) ** 2) / 14.0) * 0.05, (0.20, 0.24, 0.40))
|
||||||
|
|
||||||
|
x = N - (t * 5.0) % total
|
||||||
|
for _ in range(2): # wrap for a seamless loop
|
||||||
|
cursor = x
|
||||||
|
for (txt, colour), w in zip(segs, widths):
|
||||||
|
_draw_text(cv, txt, cursor, 1.5, colour, 1.0)
|
||||||
|
cursor += w
|
||||||
|
x += total
|
||||||
|
|
||||||
|
|
||||||
class LedDisplay:
|
class LedDisplay:
|
||||||
"""Renders scenes at a steady frame rate and dissolves between them.
|
"""Renders scenes at a steady frame rate and dissolves between them.
|
||||||
|
|
||||||
@@ -737,8 +853,9 @@ class LedDisplay:
|
|||||||
self.glyphs: Dict[str, Scene] = {
|
self.glyphs: Dict[str, Scene] = {
|
||||||
"sun": SunBurst(), "umbrella": Umbrella(), "snowflake": Snowflake(),
|
"sun": SunBurst(), "umbrella": Umbrella(), "snowflake": Snowflake(),
|
||||||
}
|
}
|
||||||
self.scenes: List[Scene] = [Aurora(), SolarSky(), Precipitation(),
|
self.scenes: List[Scene] = [Readout(), Aurora(), SolarSky(),
|
||||||
ForecastRibbon(), Barometer()]
|
Precipitation(), ForecastRibbon(),
|
||||||
|
Barometer()]
|
||||||
self.alert = Alert()
|
self.alert = Alert()
|
||||||
self._glyph: str = "sun"
|
self._glyph: str = "sun"
|
||||||
self._show_glyph = True
|
self._show_glyph = True
|
||||||
@@ -771,6 +888,7 @@ class LedDisplay:
|
|||||||
"temp": float(live.get("temp_smooth") or live.get("temp_c") or 15.0),
|
"temp": float(live.get("temp_smooth") or live.get("temp_c") or 15.0),
|
||||||
"humidity": float(live.get("hum_smooth") or 60.0),
|
"humidity": float(live.get("hum_smooth") or 60.0),
|
||||||
"press_rate": float(live.get("press_rate") or 0.0),
|
"press_rate": float(live.get("press_rate") or 0.0),
|
||||||
|
"press": live.get("press_slp"),
|
||||||
"solar_elevation": float(live.get("solar_elevation") or -20.0),
|
"solar_elevation": float(live.get("solar_elevation") or -20.0),
|
||||||
"solar_azimuth": float(live.get("solar_azimuth") or 180.0),
|
"solar_azimuth": float(live.get("solar_azimuth") or 180.0),
|
||||||
"cloud": float(live.get("cloud_index") or 0.4),
|
"cloud": float(live.get("cloud_index") or 0.4),
|
||||||
|
|||||||
+50
-1
@@ -343,13 +343,25 @@ class Station:
|
|||||||
# old contaminated state cannot leak into the re-derivation.
|
# old contaminated state cannot leak into the re-derivation.
|
||||||
kt = KalmanCV(self.cfg.sensor.kalman_q_temp, self.cfg.sensor.kalman_r_temp)
|
kt = KalmanCV(self.cfg.sensor.kalman_q_temp, self.cfg.sensor.kalman_r_temp)
|
||||||
kh = KalmanCV(self.cfg.sensor.kalman_q_hum, self.cfg.sensor.kalman_r_hum)
|
kh = KalmanCV(self.cfg.sensor.kalman_q_hum, self.cfg.sensor.kalman_r_hum)
|
||||||
|
q_temp = float(self.cfg.sensor.kalman_q_temp)
|
||||||
|
q_hum = float(self.cfg.sensor.kalman_q_hum)
|
||||||
|
live_dt = float(self.cfg.sensor.sample_period_s)
|
||||||
temp_s = np.empty(n)
|
temp_s = np.empty(n)
|
||||||
temp_r = np.empty(n)
|
temp_r = np.empty(n)
|
||||||
hum_s = np.empty(n)
|
hum_s = np.empty(n)
|
||||||
prev = None
|
prev = None
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
dt = 1.0 if prev is None else max(ts[i] - prev, 1e-3)
|
dt = live_dt if prev is None else max(ts[i] - prev, 1e-3)
|
||||||
prev = ts[i]
|
prev = ts[i]
|
||||||
|
# q is tuned for the live 2 s cadence and Q scales with dt^3, so
|
||||||
|
# replaying stored rows at their own spacing (30 s raw, 300 s and
|
||||||
|
# 3600 s once tiered) inflates the process noise by up to seven
|
||||||
|
# orders of magnitude. The filter then abandons smoothing and tracks
|
||||||
|
# measurement noise, which showed up as indoor rates of +/-20 C/h.
|
||||||
|
# Rescaled per step because tiers mean the cadence is not constant.
|
||||||
|
scale = (live_dt / dt) ** 3
|
||||||
|
kt.q = q_temp * scale
|
||||||
|
kh.q = q_hum * scale
|
||||||
lvl, rate = kt.update(temp_c[i], dt)
|
lvl, rate = kt.update(temp_c[i], dt)
|
||||||
temp_s[i], temp_r[i] = lvl, rate * 3600.0
|
temp_s[i], temp_r[i] = lvl, rate * 3600.0
|
||||||
hum_s[i], _ = kh.update(hum_c[i], dt)
|
hum_s[i], _ = kh.update(hum_c[i], dt)
|
||||||
@@ -370,6 +382,43 @@ class Station:
|
|||||||
return {"rows": written, "seconds": round(secs, 2),
|
return {"rows": written, "seconds": round(secs, 2),
|
||||||
"k": comp.k, "hum_offset": hcomp.offset}
|
"k": comp.k, "hum_offset": hcomp.offset}
|
||||||
|
|
||||||
|
def set_environment(self, environment: Optional[str] = None,
|
||||||
|
enclosure: Optional[str] = None,
|
||||||
|
note: str = "") -> Dict:
|
||||||
|
"""Record a change in the sensor's surroundings and act on it.
|
||||||
|
|
||||||
|
Not cosmetic. A door closing changes how strongly the sensor couples to
|
||||||
|
outside, which is a regime change in the very process the heads are
|
||||||
|
fitting. Their forgetting factor is 0.9985 on a five minute grid, about
|
||||||
|
55 hours of memory, so left alone they keep predicting the old room for
|
||||||
|
two days. Page-Hinkley would eventually notice from forecast error, but
|
||||||
|
it needs matured forecasts to do it, which at the longer horizons is
|
||||||
|
exactly the two days you were trying to skip.
|
||||||
|
|
||||||
|
So this does three things: writes a discontinuity marker so the record
|
||||||
|
shows where the regime changed, requests a retrain so the fit is redone
|
||||||
|
against recent data rather than drifting, and stores the new state for
|
||||||
|
the API and the Methods page to report honestly.
|
||||||
|
"""
|
||||||
|
changed = []
|
||||||
|
if environment and environment != self.cfg.site.environment:
|
||||||
|
changed.append(f"environment {self.cfg.site.environment} -> {environment}")
|
||||||
|
self.cfg.site.environment = environment
|
||||||
|
if enclosure and enclosure != self.cfg.site.enclosure:
|
||||||
|
changed.append(f"enclosure {self.cfg.site.enclosure} -> {enclosure}")
|
||||||
|
self.cfg.site.enclosure = enclosure
|
||||||
|
if not changed:
|
||||||
|
return {"changed": False, "environment": self.cfg.site.environment,
|
||||||
|
"enclosure": self.cfg.site.enclosure}
|
||||||
|
|
||||||
|
detail = "; ".join(changed) + (f" ({note})" if note else "")
|
||||||
|
self.store.log_event("environment", "info", detail)
|
||||||
|
self.store.log_event("discontinuity", "warn", detail)
|
||||||
|
self.monitor.retrain_requested = True
|
||||||
|
return {"changed": True, "environment": self.cfg.site.environment,
|
||||||
|
"enclosure": self.cfg.site.enclosure,
|
||||||
|
"retrain_requested": True, "detail": detail}
|
||||||
|
|
||||||
def reset_calibration(self) -> Dict:
|
def reset_calibration(self) -> Dict:
|
||||||
"""Return the self-heating coefficient to its configured prior.
|
"""Return the self-heating coefficient to its configured prior.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user