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
+4 -2
View File
@@ -4,11 +4,13 @@
"date": "",
"en": [
"SteppIR: a Calibrate button, beside Retract. It drives every element to its end stop so the controller re-learns where zero is — the cure for an antenna that tunes to the wrong length after a power cut mid-move, after the elements were pushed by hand, or after a motor slipped. It takes minutes and the antenna is unusable until it finishes, so it asks first. Ultrabeam controllers have no such command and say so rather than pretending. Retracting the elements was already there and now explains itself: it is the storage position, and the next tune brings them back out on its own.",
"On the rotor dial, the beam no longer whips a full turn round the compass when the antenna crosses north. A rotator at 020° turned anticlockwise reports 020, 010, 000, 359, 358 … and the animation travelled from 20° to 359° the long way — a complete revolution on screen while the mast moved forty degrees the other way. The beam now follows an accumulated angle, so what is drawn is the way the antenna is actually turning. Both lobes of a bidirectional antenna are handled separately, since they cross north at different moments."
"On the rotor dial, the beam no longer whips a full turn round the compass when the antenna crosses north. A rotator at 020° turned anticlockwise reports 020, 010, 000, 359, 358 … and the animation travelled from 20° to 359° the long way — a complete revolution on screen while the mast moved forty degrees the other way. The beam now follows an accumulated angle, so what is drawn is the way the antenna is actually turning. Both lobes of a bidirectional antenna are handled separately, since they cross north at different moments.",
"The rotor widget stops claiming the antenna is turning every time the wind moves it. A threshold alone could not tell the difference — a gust pushes a beam well past four degrees and back, and each excursion counted, so Stop lit and went out all evening on an antenna that had not turned. What separates a rotation from the weather is not how far the reading moves but which way: a rotor under power advances, gust after gust reverses. A step now counts only when the previous one went the same way, so movement is announced one poll later on a mast that takes half a minute to cross a pass, and never at all on a windy afternoon."
],
"fr": [
"SteppIR : un bouton Calibrer, à côté de Rétracter. Il amène chaque élément en butée pour que le contrôleur retrouve son zéro — le remède à une antenne qui saccorde à la mauvaise longueur après une coupure en pleine course, après avoir poussé les éléments à la main, ou après un moteur qui a glissé. Cela prend plusieurs minutes et lantenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam nont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et sexplique maintenant : cest la position de rangement, et le prochain accord les fait ressortir tout seuls.",
"Sur le cadran du rotor, le faisceau ne fait plus un tour complet de la boussole quand lantenne franchit le nord. Un rotor à 020° tourné dans le sens antihoraire annonce 020, 010, 000, 359, 358… et lanimation allait de 20° à 359° par le chemin long — une révolution complète à l’écran pendant que le pylône bougeait de quarante degrés dans lautre sens. Le faisceau suit désormais un angle cumulé : ce qui est dessiné est le mouvement réel de lantenne. Les deux lobes dune antenne bidirectionnelle sont traités séparément, puisquils ne franchissent pas le nord au même moment."
"Sur le cadran du rotor, le faisceau ne fait plus un tour complet de la boussole quand lantenne franchit le nord. Un rotor à 020° tourné dans le sens antihoraire annonce 020, 010, 000, 359, 358… et lanimation allait de 20° à 359° par le chemin long — une révolution complète à l’écran pendant que le pylône bougeait de quarante degrés dans lautre sens. Le faisceau suit désormais un angle cumulé : ce qui est dessiné est le mouvement réel de lantenne. Les deux lobes dune antenne bidirectionnelle sont traités séparément, puisquils ne franchissent pas le nord au même moment.",
"Le widget rotor nannonce plus une antenne en rotation chaque fois que le vent la bouge. Un simple seuil ne pouvait pas faire la différence — une rafale pousse une beam bien au-delà de quatre degrés puis la ramène, et chaque écart comptait : Stop sallumait et s’éteignait toute la soirée sur une antenne qui navait pas tourné. Ce qui distingue une rotation de la météo nest pas lamplitude mais le sens : un rotor sous tension avance, une rafale revient. Un écart ne compte donc que si le précédent allait dans le même sens — la rotation est annoncée un relevé plus tard sur un pylône qui met trente secondes à traverser, et plus du tout par vent fort."
]
},
{
+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