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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user