fix(rotor): wind is not rotation

"I thought we had fixed this" — and we had not, we had only raised the number.
The antenna moves a few degrees in the wind and the Stop button lights up and
goes out, all evening, on a mast that has not turned.

A threshold cannot tell the two apart at any setting. A gust pushes a beam past
four degrees and brings it back — 100°, 105°, 100°, 106° — and every one of those
excursions cleared the threshold. Raising it only raises the wind speed it takes,
and blunts the detection of a rotor genuinely creeping.

What separates a rotation from the weather is not amplitude, it is SIGN. A rotor
under power advances; a gust reverses. So a step is only movement when the
previous step went the same way: wind gives +5, −5, +5 and never two in a row,
while 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.

The threshold stays, doing the job it is actually good at: ignoring the degree
or two of potentiometer noise that is not a step at all.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-09-10 10:16:07 +02:00
co-authored by Claude Opus 5
parent 136c5d5b6b
commit 3af6299f32
2 changed files with 58 additions and 20 deletions
+54 -18
View File
@@ -45,15 +45,17 @@ type RotorPreset = { label: string; azimuth: number };
// 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;
// Four degrees, not one.
// 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 the
// controller's rounding walk the reading a degree or two either side, and at one
// degree that jitter WAS movement — Stop lit for a second and a half, went out,
// and lit again, for an antenna that had not turned all evening. The reference
// is only moved when the threshold is crossed, so a rotor genuinely turning
// accumulates towards it however slowly it goes; noise around a value never
// gets there.
// A rotor at rest does not report a constant heading the potentiometer and
// the controller's rounding walk the reading a degree or two either side. But
// the threshold is only half the answer, and on its own it was not enough: WIND
// moves a beam further than four degrees and back again, and every one of those
// excursions counted, so Stop lit and went out all evening on an antenna that
// had not turned. Raising the number only raises the wind speed it takes.
//
// What actually separates a rotation from the weather is the DIRECTION — see the
// movement effect below.
const MOVEMENT_TRIGGER_DEG = 4;
// How long an order is given to produce movement before the widget stops
// claiming the antenna is turning — the rotor may already have been there.
@@ -517,6 +519,10 @@ export function RotorCompass({
const latestAzimuthRef = useRef<number | null>(displayAzimuth);
const movementReferenceRef = useRef<number | null>(displayAzimuth);
const movementSeenRef = useRef(false);
// Which way the last accepted step went (+1 CW, 1 CCW, 0 none), and how many
// in a row have gone that way. Two make it a rotation; one is weather.
const movementDirRef = useRef(0);
const movementRunRef = useRef(0);
const rememberTarget = (value: number | null) => {
if (value == null) rememberedTargets.delete(rotorKey);
@@ -547,6 +553,8 @@ export function RotorCompass({
setTargetFading(false);
setIsMoving(rememberedTarget != null);
movementSeenRef.current = false;
movementDirRef.current = 0;
movementRunRef.current = 0;
window.clearTimeout(movementTimerRef.current);
window.clearTimeout(commandTimerRef.current);
window.clearTimeout(targetArrivalTimerRef.current);
@@ -564,21 +572,49 @@ export function RotorCompass({
// Movement is inferred from the readout itself, so a rotor turned by its own
// controller — or by another program — reads as moving here too.
//
// The test is the DIRECTION, not the size of the step.
//
// A threshold alone does not work, whatever it is set to. Wind pushes a beam
// off its bearing and back — 100°, 105°, 100°, 106° — and every one of those
// excursions clears a four-degree threshold, so Stop lit and went out all
// evening on an antenna that had not turned. Raising the number only raises
// the wind speed it takes.
//
// What separates the two is not amplitude but sign: a rotor under power
// advances, gust after gust reverses. So a step is only movement when the
// 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.
useEffect(() => {
if (rawAzimuth == null) return;
const reference = movementReferenceRef.current;
if (reference == null) { movementReferenceRef.current = rawAzimuth; return; }
if (angularDistance(rawAzimuth, reference) >= MOVEMENT_TRIGGER_DEG) {
movementSeenRef.current = true;
setIsMoving(true);
window.clearTimeout(commandTimerRef.current);
window.clearTimeout(movementTimerRef.current);
movementTimerRef.current = window.setTimeout(() => {
setIsMoving(false);
movementSeenRef.current = false;
}, MOVEMENT_SETTLE_MS);
movementReferenceRef.current = rawAzimuth;
// Signed, and the short way round: crossing north is a small step, not 350°.
const delta = ((rawAzimuth - reference + 540) % 360) - 180;
if (Math.abs(delta) < MOVEMENT_TRIGGER_DEG) return; // inside the noise band
const sign = delta > 0 ? 1 : -1;
if (movementDirRef.current === sign) {
movementRunRef.current += 1;
} else {
movementDirRef.current = sign;
movementRunRef.current = 1;
}
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);
}, [rawAzimuth, rotorKey]);
// Arrival: confirmed over time, then faded. Every check re-reads the