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.
This commit is contained in:
2026-08-20 07:23:08 +01:00
parent 3dd45f7ebf
commit 9a1033973d
2 changed files with 121 additions and 1 deletions
+66
View File
@@ -230,3 +230,69 @@ def test_joystick_survives_a_board_with_no_hat(tmp_path):
st = Station(cfg)
assert st.board.stick_events() == []
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"