mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 20:52:23 +00:00
Treat the board being moved as a regime change, from the fused IMU attitude
The accelerometer and gyroscope were logged and never used. They measure nothing about weather, but they do measure the one thing about this station that nothing else can see: whether the sensor is still where it was. Measured over four and a half days on the real station, four genuine movements each stepped the temperature by a median of 1.02 C, against an ordinary fifteen minute change of 0.107 C with a 95th percentile of 0.841. A move therefore lands past the 95th percentile of normal variation. The heads carry about 55 hours of memory, so an undeclared move contaminates two days of training with a discontinuity they will try to fit rather than ignore. This now gets the same treatment set_environment gives a window being opened, because it is the same event: the coupling between the sensor and what it is measuring changed, and nothing in the data says so. Three choices in here were made by measurement, and the obvious one was wrong. Raw accelerometer looks like the natural input and is not. Over the same record a gravity-vector detector fires 112 times against this one's 4, because RTIMULib's gyro fusion removes exactly the desk vibration a bare accelerometer picks up. The fused pitch and roll have a p99 sample-to-sample noise of 0.0001 degrees, so a one degree trigger carries four decades of headroom. Yaw and compass are excluded. They are the only attitude outputs that depend on the magnetometer, and indoors the magnetometer is measuring the building. RTIMULib restarts its fusion from a default attitude when SenseHat is reconstructed, which put an 18 degree step in the record on every one of this station's seven service restarts. Without a settle window every deploy would queue a retrain. 300 seconds rather than 180: one artifact appeared three minutes after a restart, still converging. Replayed against the full record the detector finds 4 genuine movements and leaks 0 artifacts. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+55
-1
@@ -37,7 +37,7 @@ import json
|
|||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -70,6 +70,8 @@ class Station:
|
|||||||
# Set by the API layer once the LED display exists, so the joystick
|
# Set by the API layer once the LED display exists, so the joystick
|
||||||
# can acknowledge a press and cycle scenes. None when there is no HAT.
|
# can acknowledge a press and cycle scenes. None when there is no HAT.
|
||||||
self.display = None
|
self.display = None
|
||||||
|
self._last_tilt: Optional[Tuple[float, float]] = None
|
||||||
|
self._started_at = time.time()
|
||||||
self.probe = (OutdoorProbe(cfg.sensor.outdoor_probe_period_s)
|
self.probe = (OutdoorProbe(cfg.sensor.outdoor_probe_period_s)
|
||||||
if cfg.sensor.outdoor_probe else None)
|
if cfg.sensor.outdoor_probe else None)
|
||||||
self.tracker = SignalTracker(cfg)
|
self.tracker = SignalTracker(cfg)
|
||||||
@@ -182,6 +184,7 @@ class Station:
|
|||||||
"dew_c": dew, "cpu_temp": raw.get("cpu_temp"),
|
"dew_c": dew, "cpu_temp": raw.get("cpu_temp"),
|
||||||
})
|
})
|
||||||
self.anomaly_bundle = anomaly
|
self.anomaly_bundle = anomaly
|
||||||
|
self._check_moved(ts, row)
|
||||||
|
|
||||||
self.live = {
|
self.live = {
|
||||||
**row,
|
**row,
|
||||||
@@ -437,6 +440,57 @@ class Station:
|
|||||||
return float(np.clip(rh_now * es_now / es_fut, 0.0, 100.0)) - rh_now
|
return float(np.clip(rh_now * es_now / es_fut, 0.0, 100.0)) - rh_now
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
# Fused tilt change that counts as the board having been picked up. The
|
||||||
|
# noise floor of the fused pitch and roll is 0.0001 degrees at p99, so a
|
||||||
|
# one degree trigger carries four decades of headroom and false positives
|
||||||
|
# are not a concern.
|
||||||
|
#
|
||||||
|
# The raw accelerometer is the obvious input and is the wrong one. Over the
|
||||||
|
# same four and a half days it fires 112 times against this detector's 4,
|
||||||
|
# because RTIMULib's gyro fusion removes exactly the desk vibration that a
|
||||||
|
# bare gravity vector picks up. Yaw and compass are excluded for the
|
||||||
|
# opposite reason: they depend on the magnetometer, which indoors is
|
||||||
|
# measuring the building.
|
||||||
|
TILT_MOVED_DEG = 1.0
|
||||||
|
# RTIMULib restarts its fusion from a default attitude when SenseHat is
|
||||||
|
# reconstructed, which put an 18 degree step in the record on every one of
|
||||||
|
# this station's service restarts. Without this guard every deploy would
|
||||||
|
# look like someone had picked the board up.
|
||||||
|
TILT_SETTLE_S = 300.0
|
||||||
|
|
||||||
|
def _check_moved(self, ts: float, row: Dict[str, Any]) -> None:
|
||||||
|
"""Detect the board being moved, and treat it as a regime change.
|
||||||
|
|
||||||
|
Measured on this station, a move steps the temperature by a median of
|
||||||
|
1.02 C against an ordinary fifteen minute change of 0.107 C, which puts
|
||||||
|
it past the 95th percentile of normal variation. The heads carry about
|
||||||
|
55 hours of memory, so an undeclared move contaminates two days of
|
||||||
|
training with a discontinuity they will try to fit rather than ignore.
|
||||||
|
|
||||||
|
This is the same treatment set_environment gives a window being opened,
|
||||||
|
because it is the same kind of event: the coupling between the sensor
|
||||||
|
and what it is measuring changed, and nothing in the data says so.
|
||||||
|
"""
|
||||||
|
p, r = row.get("pitch"), row.get("roll")
|
||||||
|
if p is None or r is None:
|
||||||
|
return
|
||||||
|
p, r = float(p), float(r)
|
||||||
|
if not (np.isfinite(p) and np.isfinite(r)):
|
||||||
|
return
|
||||||
|
prev, self._last_tilt = self._last_tilt, (p, r)
|
||||||
|
if prev is None or ts - self._started_at < self.TILT_SETTLE_S:
|
||||||
|
return
|
||||||
|
moved = float(np.hypot(p - prev[0], r - prev[1]))
|
||||||
|
if moved < self.TILT_MOVED_DEG:
|
||||||
|
return
|
||||||
|
detail = json.dumps({"tilt_deg": round(moved, 2),
|
||||||
|
"pitch": round(p, 2), "roll": round(r, 2),
|
||||||
|
"temp_c": row.get("temp_smooth")})
|
||||||
|
self.store.log_event("moved", "warn", detail, ts)
|
||||||
|
self.store.log_event("discontinuity", "warn",
|
||||||
|
f"board moved {moved:.1f} degrees, queuing a retrain", ts)
|
||||||
|
self.monitor.retrain_requested = True
|
||||||
|
|
||||||
def set_environment(self, environment: Optional[str] = None,
|
def set_environment(self, environment: Optional[str] = None,
|
||||||
enclosure: Optional[str] = None,
|
enclosure: Optional[str] = None,
|
||||||
note: str = "") -> Dict:
|
note: str = "") -> Dict:
|
||||||
|
|||||||
@@ -230,3 +230,69 @@ def test_joystick_survives_a_board_with_no_hat(tmp_path):
|
|||||||
st = Station(cfg)
|
st = Station(cfg)
|
||||||
assert st.board.stick_events() == []
|
assert st.board.stick_events() == []
|
||||||
assert st.display is None
|
assert st.display is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- movement detection
|
||||||
|
|
||||||
|
def _moved_station(tmp_path, name):
|
||||||
|
from ashvale.config import load_config
|
||||||
|
from ashvale.station import Station
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
cfg.storage.db_path = str(tmp_path / name)
|
||||||
|
st = Station(cfg)
|
||||||
|
st._started_at = 0.0 # long settled
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_moved_board_marks_a_discontinuity_and_queues_a_retrain(tmp_path):
|
||||||
|
"""Measured on a real station: a move steps the temperature by a median of
|
||||||
|
1.02 C against an ordinary fifteen minute change of 0.107 C. The heads
|
||||||
|
carry 55 hours of memory, so an undeclared move contaminates two days.
|
||||||
|
"""
|
||||||
|
st = _moved_station(tmp_path, "moved.db")
|
||||||
|
base = 1.7554e9
|
||||||
|
|
||||||
|
for i in range(20): # sitting still, with realistic jitter
|
||||||
|
st._check_moved(base + i, {"pitch": -64.05 + 1e-5 * i,
|
||||||
|
"roll": 1.36, "temp_smooth": 24.0})
|
||||||
|
assert not st.monitor.retrain_requested, "noise must not trigger a retrain"
|
||||||
|
|
||||||
|
st._check_moved(base + 100, {"pitch": -53.5, "roll": 1.4, "temp_smooth": 24.0})
|
||||||
|
assert st.monitor.retrain_requested, "a 10 degree move must queue a retrain"
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
with sqlite3.connect(st.cfg.storage.db_path) as c:
|
||||||
|
kinds = [r[0] for r in c.execute("SELECT kind FROM events")]
|
||||||
|
assert "moved" in kinds and "discontinuity" in kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_attitude_jump_is_not_mistaken_for_a_move(tmp_path):
|
||||||
|
"""RTIMULib restarts its fusion from a default attitude when SenseHat is
|
||||||
|
reconstructed, which put an 18 degree step in the record on every service
|
||||||
|
restart. Without the settle window every deploy looks like a move.
|
||||||
|
"""
|
||||||
|
st = _moved_station(tmp_path, "restart.db")
|
||||||
|
now = 1.7554e9
|
||||||
|
st._started_at = now # just booted
|
||||||
|
|
||||||
|
st._check_moved(now + 1, {"pitch": -46.0, "roll": 1.4, "temp_smooth": 24.0})
|
||||||
|
st._check_moved(now + 60, {"pitch": -64.1, "roll": 1.4, "temp_smooth": 24.0})
|
||||||
|
st._check_moved(now + 180, {"pitch": -46.0, "roll": 1.4, "temp_smooth": 24.0})
|
||||||
|
assert not st.monitor.retrain_requested, "startup convergence must be ignored"
|
||||||
|
|
||||||
|
# past the settle window, the same step is a real move
|
||||||
|
st._check_moved(now + st.TILT_SETTLE_S + 10, {"pitch": -64.1, "roll": 1.4,
|
||||||
|
"temp_smooth": 24.0})
|
||||||
|
assert st.monitor.retrain_requested
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaw_and_compass_are_not_used_for_movement(tmp_path):
|
||||||
|
"""They depend on the magnetometer, which indoors measures the building."""
|
||||||
|
st = _moved_station(tmp_path, "yaw.db")
|
||||||
|
base = 1.7554e9
|
||||||
|
st._check_moved(base, {"pitch": -64.0, "roll": 1.4, "yaw": 10.0,
|
||||||
|
"compass": 10.0, "temp_smooth": 24.0})
|
||||||
|
st._check_moved(base + 30, {"pitch": -64.0, "roll": 1.4, "yaw": 300.0,
|
||||||
|
"compass": 300.0, "temp_smooth": 24.0})
|
||||||
|
assert not st.monitor.retrain_requested, "a 290 degree yaw swing is not a move"
|
||||||
|
|||||||
Reference in New Issue
Block a user