diff --git a/ashvale/api.py b/ashvale/api.py index 7f9ba39..d05a157 100644 --- a/ashvale/api.py +++ b/ashvale/api.py @@ -128,6 +128,12 @@ class CalibrationIn(BaseModel): "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): reference_pct: Optional[float] = Field(None, ge=0, le=100, description="Trusted relative humidity in %") @@ -600,6 +606,24 @@ def recompute() -> Dict: 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") def status() -> Dict: st = _st() @@ -607,6 +631,8 @@ def status() -> Dict: **st.status(), "display_frame": display.frame_name if display 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), }) diff --git a/ashvale/config.py b/ashvale/config.py index a3e9d18..6dd4a3e 100644 --- a/ashvale/config.py +++ b/ashvale/config.py @@ -41,7 +41,22 @@ class SiteConfig: longitude: float = 0.1218 altitude_m: float = 15.0 # for sea-level pressure reduction 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 diff --git a/ashvale/led.py b/ashvale/led.py index 099a544..23ba24f 100644 --- a/ashvale/led.py +++ b/ashvale/led.py @@ -115,6 +115,64 @@ def _smoothstep(edge0: float, edge1: float, x: float) -> float: 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: """An 8x8 linear-light RGB buffer with sub-pixel drawing.""" @@ -712,6 +770,64 @@ class Snowflake(Scene): 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: """Renders scenes at a steady frame rate and dissolves between them. @@ -737,8 +853,9 @@ class LedDisplay: self.glyphs: Dict[str, Scene] = { "sun": SunBurst(), "umbrella": Umbrella(), "snowflake": Snowflake(), } - self.scenes: List[Scene] = [Aurora(), SolarSky(), Precipitation(), - ForecastRibbon(), Barometer()] + self.scenes: List[Scene] = [Readout(), Aurora(), SolarSky(), + Precipitation(), ForecastRibbon(), + Barometer()] self.alert = Alert() self._glyph: str = "sun" self._show_glyph = True @@ -771,6 +888,7 @@ class LedDisplay: "temp": float(live.get("temp_smooth") or live.get("temp_c") or 15.0), "humidity": float(live.get("hum_smooth") or 60.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_azimuth": float(live.get("solar_azimuth") or 180.0), "cloud": float(live.get("cloud_index") or 0.4), diff --git a/ashvale/station.py b/ashvale/station.py index 3b07124..60d06dd 100644 --- a/ashvale/station.py +++ b/ashvale/station.py @@ -343,13 +343,25 @@ class Station: # old contaminated state cannot leak into the re-derivation. 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) + 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_r = np.empty(n) hum_s = np.empty(n) prev = None 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] + # 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) temp_s[i], temp_r[i] = lvl, rate * 3600.0 hum_s[i], _ = kh.update(hum_c[i], dt) @@ -370,6 +382,43 @@ class Station: return {"rows": written, "seconds": round(secs, 2), "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: """Return the self-heating coefficient to its configured prior.