fix(rotor): Stop no longer blinks through a rotation

The direction test that kept the wind out was applied to staying in as
well, and there it was wrong. Its unit is a four-degree step, which is
four seconds of travel at a degree a second — far longer than the 1600 ms
settle window — so between two accepted steps nothing said the mast was
still turning, the timer expired, and Stop went dark and lit again the
whole way round.

Entry stays strict: two consecutive steps of four degrees the same way,
which is what weather cannot produce. Staying in is now sensitive
instead — any continued progress in the direction already established
re-arms the timer, down to a single degree, because one degree the same
way is not a gust when the rotor is demonstrably under power. That
demonstration is exactly what the strict entry provides, so the loose
exit costs nothing: wind still cannot start it, and once stopped the
sensitive path is disarmed with the direction.

The settle window goes to 3000 ms so it outlasts one degree of progress
on a slow mast rather than four degrees on a fast one. Measuring
progress needs the PREVIOUS reading, which movementReferenceRef is not:
it deliberately holds still through sub-threshold steps so they can
accumulate into one.
This commit is contained in:
2026-09-10 13:47:59 +02:00
parent 8359caab16
commit d2e62debe8
2 changed files with 55 additions and 17 deletions
+51 -15
View File
@@ -44,7 +44,15 @@ type RotorPreset = { label: string; azimuth: number };
// A rotor is slow and its readout is coarse, so "is it moving" is inferred
// rather than reported: a heading that changes by more than a degree means it
// is, and it is considered stopped once the readout has been still for a while.
const MOVEMENT_SETTLE_MS = 1600;
// Long enough to outlast the gap between two readings of a turning rotor.
//
// It was 1600 ms, shorter than the time a rotor takes to move far enough to be
// noticed at all: at a degree a second, four-degree steps are four seconds
// apart, so Stop went dark between every one of them and lit again on the
// next — a rotation crossing a pass blinked the whole way round. The window
// now only has to outlast ONE degree of progress, which even a slow mast
// delivers about every two seconds while the poller is running fast.
const MOVEMENT_SETTLE_MS = 3000;
// Four degrees: the band inside which a reading is not a step at all.
//
// A rotor at rest does not report a constant heading — the potentiometer and
@@ -523,6 +531,10 @@ export function RotorCompass({
// in a row have gone that way. Two make it a rotation; one is weather.
const movementDirRef = useRef(0);
const movementRunRef = useRef(0);
// The previous reading, whatever it was. movementReferenceRef deliberately
// holds still through sub-threshold steps so they can accumulate, which
// makes it useless for measuring progress poll to poll.
const lastRawRef = useRef<number | null>(null);
const rememberTarget = (value: number | null) => {
if (value == null) rememberedTargets.delete(rotorKey);
@@ -555,6 +567,7 @@ export function RotorCompass({
movementSeenRef.current = false;
movementDirRef.current = 0;
movementRunRef.current = 0;
lastRawRef.current = nextAzimuth;
window.clearTimeout(movementTimerRef.current);
window.clearTimeout(commandTimerRef.current);
window.clearTimeout(targetArrivalTimerRef.current);
@@ -570,6 +583,25 @@ export function RotorCompass({
window.clearTimeout(targetFadeTimerRef.current);
}, []);
// Declare the antenna moving and start the clock on it stopping. Called from
// both halves of the test below, and re-arming the timer is the whole point:
// the antenna counts as stopped only once nothing has said otherwise for
// MOVEMENT_SETTLE_MS.
function armMovement() {
movementSeenRef.current = true;
setIsMoving(true);
window.clearTimeout(commandTimerRef.current);
window.clearTimeout(movementTimerRef.current);
movementTimerRef.current = window.setTimeout(() => {
setIsMoving(false);
movementSeenRef.current = false;
// Forget the direction too: the next real move starts its own run rather
// than inheriting one from a rotation that finished minutes ago.
movementDirRef.current = 0;
movementRunRef.current = 0;
}, MOVEMENT_SETTLE_MS);
}
// Movement is inferred from the readout itself, so a rotor turned by its own
// controller — or by another program — reads as moving here too.
//
@@ -586,12 +618,27 @@ export function RotorCompass({
// PREVIOUS step went the same way. Wind gives +5, 5, +5 and never two in a
// row; a rotor gives +4, +4, +4 and is announced on the second — one poll,
// about a second, on a mast that takes half a minute to cross a pass.
//
// Getting IN is strict; staying in is not, and must not be. The same four
// degrees that keep the wind out are four seconds of travel on a real rotor,
// so waiting for the next four-degree step before believing it is still
// turning left Stop dark for most of the rotation. Once movement is
// established, ANY continued progress the way it was going keeps it alive —
// one degree the same way is not weather when the mast is already under
// power, and the strict test is what guarantees that it is.
useEffect(() => {
if (rawAzimuth == null) return;
// Signed and the short way round: crossing north is a small step, not 350°.
const short = (from: number, to: number) => ((to - from + 540) % 360) - 180;
const previous = lastRawRef.current;
lastRawRef.current = rawAzimuth;
if (movementSeenRef.current && previous != null && movementDirRef.current !== 0) {
const step = short(previous, rawAzimuth);
if (step !== 0 && Math.sign(step) === movementDirRef.current) armMovement();
}
const reference = movementReferenceRef.current;
if (reference == null) { movementReferenceRef.current = rawAzimuth; return; }
// Signed, and the short way round: crossing north is a small step, not 350°.
const delta = ((rawAzimuth - reference + 540) % 360) - 180;
const delta = short(reference, rawAzimuth);
if (Math.abs(delta) < MOVEMENT_TRIGGER_DEG) return; // inside the noise band
const sign = delta > 0 ? 1 : -1;
if (movementDirRef.current === sign) {
@@ -603,18 +650,7 @@ export function RotorCompass({
movementReferenceRef.current = rawAzimuth;
if (movementRunRef.current < 2) return; // one step either way is weather
movementSeenRef.current = true;
setIsMoving(true);
window.clearTimeout(commandTimerRef.current);
window.clearTimeout(movementTimerRef.current);
movementTimerRef.current = window.setTimeout(() => {
setIsMoving(false);
movementSeenRef.current = false;
// Forget the direction too: the next real move starts its own run rather
// than inheriting one from a rotation that finished minutes ago.
movementDirRef.current = 0;
movementRunRef.current = 0;
}, MOVEMENT_SETTLE_MS);
armMovement();
}, [rawAzimuth, rotorKey]);
// Arrival: confirmed over time, then faded. Every check re-reads the