From 9c7983b13eff1bb57ae7f22c6983ee0fbc4248a0 Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 26 Aug 2026 21:09:52 +0200 Subject: [PATCH] fix(alerts): one band plan, not two that disagree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spot at 10131.5 showed as DATA in the cluster list and raised an alert announcing SSB. Two tables answered the same question: the frontend knew 10.130-10.150 is the 30 m data segment, while the alert engine knew a CW range that stopped at 10.130 and ended with a bare 'return SSB' for everything else. That fall-through was the fault. A band plan that answers 'SSB' for anything it does not recognise is not a band plan, it is a default wearing one — and it fed the alert text, the FlexRadio spot colours and the mode the radio is told to switch to. The Go table is now transcribed from the frontend's, segment for segment, and returns nothing where it knows nothing. The order matters and is now load-bearing — the FT8 and FT4 watering holes are listed BEFORE the wide data blocks they sit inside — so there is a test that fails if anybody sorts the table by frequency and quietly turns every FT8 hole into DATA. WPM counts as CW while we are here: nothing else is reported in words per minute, and RBN puts it on every line. --- changelog.json | 6 ++- internal/alerts/alerts.go | 44 ++++++++---------- internal/alerts/bandplan.go | 76 ++++++++++++++++++++++++++++++++ internal/alerts/bandplan_test.go | 71 +++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 28 deletions(-) create mode 100644 internal/alerts/bandplan.go create mode 100644 internal/alerts/bandplan_test.go diff --git a/changelog.json b/changelog.json index c696087..6237bb7 100644 --- a/changelog.json +++ b/changelog.json @@ -9,7 +9,8 @@ "The Light and Voyager maps came up stamped 'API KEY REQUIRED' across the middle. Carto now watermarks its key-free tiles, so both views move to Esri, the provider already behind Street and Satellite. Light looks the same; Voyager is now a topographic map and is named Topo — anyone who had it selected keeps it.", "Ultrabeam / SteppIR over USB: the COM port can be picked from the list again. It used a field you could also type into, and the list could not be selected from at all.", "A control console for SunSDR / ExpertSDR3, as a tab and as a docked pane: drive and tune drive, mic gain, TUNE, volume, mute, squelch, NB, NR, ANF, APF, AGC, the passband, RIT and XIT, the VFO lock, and an S-meter reading real dBm. It follows the radio's own window — TCI announces every change, whoever made it.", - "The Elecraft console can be docked as a main-view pane. It always could; the setting simply never listed it." + "The Elecraft console can be docked as a main-view pane. It always could; the setting simply never listed it.", + "Spot alerts name the right mode. A 30 m FT8 spot raised an alert saying SSB while the cluster list beside it said DATA: the alert engine had its own, coarser band plan, which called everything above 10.130 phone. Both now read the same table, and a frequency the plan does not cover is left unnamed rather than guessed at." ], "fr": [ "Diplômes : la matrice des bandes affiche les bandes sur lesquelles on a des contacts — pas davantage, et sans s'arrêter au 70cm. Elle listait toutes les bandes autorisées par le diplôme, si bien que le DDFM restait avec des colonnes 6m et 70cm vides, et ne pouvait pas afficher le 23cm quoi que dise le diplôme.", @@ -18,7 +19,8 @@ "Les cartes Light et Voyager s'affichaient barrées d'un « API KEY REQUIRED ». Carto marque désormais ses tuiles sans clé, donc les deux vues passent chez Esri, le fournisseur déjà derrière Street et Satellite. Light garde son allure ; Voyager devient une carte topographique et s'appelle Topo — ceux qui l'avaient choisie la conservent.", "Ultrabeam / SteppIR en USB : le port COM se choisit à nouveau dans la liste. Le champ était de ceux où l'on peut aussi taper, et la liste ne se laissait pas sélectionner.", "Une console de contrôle pour SunSDR / ExpertSDR3, en onglet et en volet ancré : puissance et puissance d'accord, gain micro, ACCORD, volume, muet, squelch, NB, NR, ANF, APF, AGC, la bande passante, RIT et XIT, le verrouillage du VFO, et un S-mètre en vrais dBm. Elle suit la fenêtre de la radio — TCI annonce chaque changement, quel qu'en soit l'auteur.", - "La console Elecraft peut être ancrée comme volet de la vue principale. Elle le pouvait depuis toujours ; le réglage ne la proposait tout simplement pas." + "La console Elecraft peut être ancrée comme volet de la vue principale. Elle le pouvait depuis toujours ; le réglage ne la proposait tout simplement pas.", + "Les alertes de spot nomment le bon mode. Un spot FT8 sur 30 m déclenchait une alerte annonçant SSB alors que la liste du cluster juste à côté affichait DATA : le moteur d'alertes avait son propre plan de bandes, plus grossier, qui appelait « phonie » tout ce qui dépassait 10,130. Les deux lisent désormais la même table, et une fréquence hors plan reste sans mode plutôt que devinée." ] }, { diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index 9180f30..bf66272 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -264,9 +264,18 @@ func matchWildcard(pattern, v string) bool { return re.MatchString(v) } -// InferMode guesses a spot's mode from its comment and frequency. Cluster spots -// don't carry a mode field, so we read common tags (FT8/FT4/CW/RTTY/…) then fall -// back to the digital watering holes and the band-plan CW/phone split. +// InferMode works out a spot's mode from its comment, then from the band plan. +// +// Cluster lines carry no mode field, so this is all there is — and it feeds the +// alert rules, the FlexRadio panadapter colours and the spot the radio is told +// about. It used to end with a bare "return SSB" for anything its handful of CW +// ranges did not cover, which is how a 30 m FT8 spot at 10131.5 raised an alert +// announcing SSB while the cluster list beside it said DATA. +// +// The comment still wins when it names a mode: a spotter who wrote FT8 knows +// better than any table. Below that is the shared band plan (bandplan.go), and +// below THAT is nothing — an empty mode, which callers read as "unknown" rather +// than as a claim. func InferMode(comment string, freqHz int64) string { c := strings.ToUpper(comment) switch { @@ -280,30 +289,13 @@ func InferMode(comment string, freqHz int64) string { return "PSK" case strings.Contains(c, "JS8"): return "JS8" - case strings.Contains(c, "CW"): + // WPM is as good as the word CW: nothing else is reported in words per + // minute, and RBN spots carry it on every line. + case strings.Contains(c, "CW"), strings.Contains(c, "WPM"): return "CW" - case strings.Contains(c, "SSB") || strings.Contains(c, "USB") || strings.Contains(c, "LSB") || strings.Contains(c, "PH"): + case strings.Contains(c, "SSB"), strings.Contains(c, "USB"), + strings.Contains(c, "LSB"), strings.Contains(c, "PH"): return "SSB" } - khz := float64(freqHz) / 1000 - // FT8 watering holes (…074) and FT4 (…080/…140) as a fallback. - for _, f := range []float64{1840, 3573, 7074, 10136, 14074, 18100, 21074, 24915, 28074, 50313} { - if khz >= f-1 && khz <= f+3 { - return "FT8" - } - } - // Band-plan CW segments (bottom of each band). - switch { - case khz >= 1810 && khz <= 1840, - khz >= 3500 && khz <= 3570, - khz >= 7000 && khz <= 7040, - khz >= 10100 && khz <= 10130, - khz >= 14000 && khz <= 14070, - khz >= 18068 && khz <= 18095, - khz >= 21000 && khz <= 21070, - khz >= 24890 && khz <= 24910, - khz >= 28000 && khz <= 28070: - return "CW" - } - return "SSB" + return modeFromFrequency(freqHz) } diff --git a/internal/alerts/bandplan.go b/internal/alerts/bandplan.go new file mode 100644 index 0000000..90d4197 --- /dev/null +++ b/internal/alerts/bandplan.go @@ -0,0 +1,76 @@ +package alerts + +// The band plan behind InferMode, and why it is written out segment by segment. +// +// A cluster line carries no mode, so it has to be inferred — and OpsLog was +// inferring it TWICE, in two places, from two different tables. The frontend +// knew that 10.130-10.150 is the 30 m data segment; this side only knew a CW +// range that stopped at 10.130 and called everything after it SSB. So a spot at +// 10131.5 showed as DATA in the cluster list and raised an alert saying SSB. +// Reported with a screenshot of exactly that, on RI1FJL. +// +// The two tables are now the same table, transcribed from the frontend's +// (frontend/src/lib/spot.ts) with its segment boundaries kept intact. They stay +// two files because they are two languages, and the test next door is what +// keeps them one answer. + +// modeSeg is one stretch of a band, in Hz, and what is worked there. +type modeSeg struct { + loHz, hiHz int64 + mode string +} + +// bandPlan is scanned IN ORDER, so a narrow watering hole listed before the +// wide segment it sits inside wins — FT8 at 14.074 before the 14.070-14.100 +// data block. Order is the whole mechanism here; sorting this table by +// frequency would quietly turn every FT8 hole into "DATA". +var bandPlan = []modeSeg{ + {1_800_000, 1_838_000, "CW"}, {1_838_000, 1_840_000, "FT8"}, {1_840_000, 2_000_000, "SSB"}, + + {3_573_000, 3_576_000, "FT8"}, {3_500_000, 3_580_000, "CW"}, + {3_580_000, 3_600_000, "DATA"}, {3_600_000, 4_000_000, "SSB"}, + + {5_300_000, 5_500_000, "SSB"}, + + {7_074_000, 7_077_000, "FT8"}, {7_047_500, 7_048_500, "FT4"}, + {7_000_000, 7_040_000, "CW"}, {7_040_000, 7_100_000, "DATA"}, {7_100_000, 7_300_000, "SSB"}, + + // 30 m: CW to 10.130, data above it — and nothing else. No SSB on this band. + {10_100_000, 10_130_000, "CW"}, {10_130_000, 10_150_000, "DATA"}, + + {14_074_000, 14_077_000, "FT8"}, {14_080_000, 14_081_500, "FT4"}, + {14_000_000, 14_070_000, "CW"}, {14_070_000, 14_100_000, "DATA"}, {14_100_000, 14_350_000, "SSB"}, + + {18_100_000, 18_103_000, "FT8"}, + {18_068_000, 18_095_000, "CW"}, {18_095_000, 18_110_000, "DATA"}, {18_110_000, 18_168_000, "SSB"}, + + {21_074_000, 21_077_000, "FT8"}, {21_140_000, 21_143_000, "FT4"}, + {21_000_000, 21_070_000, "CW"}, {21_070_000, 21_150_000, "DATA"}, {21_150_000, 21_450_000, "SSB"}, + + {24_915_000, 24_917_000, "FT8"}, + {24_890_000, 24_915_000, "CW"}, {24_915_000, 24_940_000, "DATA"}, {24_940_000, 24_990_000, "SSB"}, + + {28_074_000, 28_077_000, "FT8"}, {28_180_000, 28_183_000, "FT4"}, + {28_000_000, 28_070_000, "CW"}, {28_070_000, 28_300_000, "DATA"}, {28_300_000, 29_700_000, "SSB"}, + + {50_313_000, 50_316_000, "FT8"}, {50_318_000, 50_321_000, "FT4"}, + {50_000_000, 50_100_000, "CW"}, {50_100_000, 50_500_000, "SSB"}, + + {144_174_000, 144_177_000, "FT8"}, + {144_000_000, 144_150_000, "CW"}, {144_150_000, 144_500_000, "SSB"}, +} + +// modeFromFrequency returns what the band plan says is worked at freqHz, or "" +// where it says nothing. +// +// Empty rather than a guess: a spot outside every listed segment is on a band +// this table does not cover, and answering "SSB" for it is how a 30 m data spot +// came to raise an SSB alert. +func modeFromFrequency(freqHz int64) string { + for _, s := range bandPlan { + if freqHz >= s.loHz && freqHz < s.hiHz { + return s.mode + } + } + return "" +} diff --git a/internal/alerts/bandplan_test.go b/internal/alerts/bandplan_test.go new file mode 100644 index 0000000..6c82134 --- /dev/null +++ b/internal/alerts/bandplan_test.go @@ -0,0 +1,71 @@ +package alerts + +import "testing" + +// The spot that started it: RI1FJL on 10131.5, an FT8 station in the 30 m data +// segment. The cluster list showed DATA and the alert announced SSB, because +// this side's table stopped its CW range at 10.130 and called everything above +// it phone. +func TestInferModeDoesNotCallThirtyMetresSSB(t *testing.T) { + if got := InferMode("", 10_131_500); got != "DATA" { + t.Errorf("10131.5 kHz → %q, want DATA (there is no SSB on 30 m)", got) + } + // And the CW half of the same band still reads as CW. + if got := InferMode("", 10_110_000); got != "CW" { + t.Errorf("10110 kHz → %q, want CW", got) + } +} + +// The comment beats the band plan. A spotter who names the mode knows something +// no table does — an FT8 station calling outside the usual watering hole, a CW +// operation in a data segment during a contest. +func TestInferModeTrustsTheCommentFirst(t *testing.T) { + cases := map[string]struct { + comment string + freqHz int64 + want string + }{ + "FT8 said outright": {"-14 dB FT8", 14_200_000, "FT8"}, + "CW in a data segment": {"CW 599", 14_080_000, "CW"}, + "RBN reports WPM not CW": {"18 dB 25 WPM CQ", 14_080_000, "CW"}, + "phone below the split": {"SSB net", 7_010_000, "SSB"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + if got := InferMode(c.comment, c.freqHz); got != c.want { + t.Errorf("InferMode(%q, %d) = %q, want %q", c.comment, c.freqHz, got, c.want) + } + }) + } +} + +// The watering holes are listed before the wide data blocks they sit inside, and +// the table is scanned in order — so sorting it by frequency would quietly turn +// every FT8 hole into "DATA". This is what would fail if somebody did. +func TestBandPlanKeepsTheWateringHolesAheadOfTheDataBlocks(t *testing.T) { + holes := map[int64]string{ + 3_573_500: "FT8", + 7_074_500: "FT8", + 7_048_000: "FT4", + 14_074_500: "FT8", + 14_080_500: "FT4", + 21_074_500: "FT8", + 28_074_500: "FT8", + 50_313_500: "FT8", + } + for hz, want := range holes { + if got := modeFromFrequency(hz); got != want { + t.Errorf("%d Hz → %q, want %q", hz, got, want) + } + } +} + +// Nothing is claimed for a frequency the plan does not cover. Answering "SSB" +// for whatever fell through is the exact fault this file exists to fix. +func TestModeFromFrequencyIsSilentWhenItDoesNotKnow(t *testing.T) { + for _, hz := range []int64{500_000, 6_000_000, 70_200_000, 432_100_000} { + if got := modeFromFrequency(hz); got != "" { + t.Errorf("%d Hz → %q, want \"\" (outside the plan)", hz, got) + } + } +}