From 2d3bfa704e6a1f1f502cc1ef48691710d60c8fa6 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Tue, 11 Aug 2026 12:30:51 +0200 Subject: [PATCH] fix(bandopen): drop the distance ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detector refused any path over 2400 km, reasoning that past a single hop the bearing test stops meaning anything. That was wrong, and it discarded exactly the openings worth announcing: Nexus flagged a 6 m opening at 5477 km that OpsLog never saw, because the spots were thrown away before any test ran. Multi-hop Es is ordinary on 6 m — 5000 km paths are common, 10000 km happens — and it stays directional: a second hop leaves the sector the first one entered. So the sector test, which is what does the real work here, holds perfectly well at any distance. The ceiling was standing in for a judgement it could not make. The floor stays at 500 km: a short 6 m contact is tropo or ground wave and says nothing about the ionosphere. Both are now pinned by a test. --- changelog.json | 6 +++-- internal/bandopen/bandopen.go | 25 ++++++++++------- internal/bandopen/bandopen_test.go | 43 ++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/changelog.json b/changelog.json index 4ba65f7..951e331 100644 --- a/changelog.json +++ b/changelog.json @@ -6,13 +6,15 @@ "Digital decodes: a station's grid square could be logged as its callsign when the message text was unusual. A grid in the callsign position is now refused.", "CW over CAT now works on a Kenwood. The KY command was sent in Elecraft's variable-length form; a Kenwood needs exactly 24 characters, so every message was refused.", "Shared CAT is steadier and now diagnoses itself. It survives a rig answering \"busy\" just after transmit instead of dropping the link, stops repeating a PTT state the client never changed, and logs a plain explanation when another program has taken its port or when a client is set to a rig model instead of Hamlib NET rigctl. It also answers the lock-mode and stop-morse commands some clients send around every transmit, instead of refusing them.", - "Web publishing now offers every field a QSO carries, awards included — 123 instead of 23 — from a searchable dropdown, with the chosen columns listed above it in publication order. Choose carefully: the page is public and the list includes addresses and e-mail." + "Web publishing now offers every field a QSO carries, awards included — 123 instead of 23 — from a searchable dropdown, with the chosen columns listed above it in publication order. Choose carefully: the page is public and the list includes addresses and e-mail.", + "Band-opening detection no longer ignores long paths. It capped them at 2400 km on the assumption that anything further was not a single hop; multi-hop sporadic E is ordinary on 6 m, so the openings most worth hearing about were the ones being discarded. Direction still decides — a second hop leaves the sector the first one entered." ], "fr": [ "Décodes digitaux : le carré locator d une station pouvait être enregistré comme son indicatif quand le texte du message sortait de l ordinaire. Un grid à la place de l indicatif est maintenant refusé.", "Le CW par CAT fonctionne sur Kenwood. La commande KY partait sous la forme Elecraft à longueur libre ; un Kenwood exige exactement 24 caractères, donc chaque message était refusé.", "Le CAT partagé est plus solide et se diagnostique tout seul. Il survit à un rig qui répond « occupé » juste après une émission au lieu de lâcher le lien, cesse de répéter un état PTT que le client n a pas changé, et écrit une explication claire quand un autre programme lui a pris son port ou qu un client est réglé sur un modèle de rig au lieu de Hamlib NET rigctl. Il répond aussi aux commandes de verrouillage et d arrêt du morse que certains logiciels envoient à chaque émission, au lieu de les refuser.", - "La publication web propose désormais tous les champs d un QSO, awards compris — 123 au lieu de 23 — depuis une liste déroulante cherchable, les colonnes choisies étant listées au-dessus dans l ordre de publication. À choisir avec soin : la page est publique et la liste contient adresses et e-mails." + "La publication web propose désormais tous les champs d un QSO, awards compris — 123 au lieu de 23 — depuis une liste déroulante cherchable, les colonnes choisies étant listées au-dessus dans l ordre de publication. À choisir avec soin : la page est publique et la liste contient adresses et e-mails.", + "La détection d ouverture n ignore plus les longues distances. Elle plafonnait à 2400 km en supposant qu au-delà ce n était plus un saut simple ; l Es à sauts multiples est ordinaire sur 6 m, donc les ouvertures les plus intéressantes étaient précisément celles qu on jetait. C est toujours la direction qui tranche — un second saut repart dans le secteur où le premier est arrivé." ] }, { diff --git a/internal/bandopen/bandopen.go b/internal/bandopen/bandopen.go index a124d49..76ae44c 100644 --- a/internal/bandopen/bandopen.go +++ b/internal/bandopen/bandopen.go @@ -36,24 +36,31 @@ type Spot struct { type Config struct { Window time.Duration // how far back a burst may span MinCalls int // distinct DX calls before it counts as an opening - MinKm, MaxKm int // single-hop Es range + MinKm, MaxKm int // path length accepted; MaxKm 0 = no ceiling BearingSpread int // widest arc (degrees) the spots may cover Requiet time.Duration // silence after announcing a band, so it is announced once } -// DefaultConfig is the single-hop Es envelope. +// DefaultConfig is the Es envelope. // -// 500–2400 km: below ~500 km a 6 m contact is ordinary tropo or ground wave and -// says nothing about the ionosphere; beyond ~2400 km it is no longer one hop, so -// the bearing test stops meaning anything. 90° of spread because a genuine Es -// cloud illuminates a sector, not the whole horizon — the constraint that -// separates an opening from a merely busy evening. +// Below ~500 km a 6 m contact is ordinary tropo or ground wave and says nothing +// about the ionosphere, so that floor stays. +// +// There is NO ceiling. There used to be one at 2400 km, on the reasoning that +// past a single hop the bearing test stops meaning anything. That was wrong, and +// it silently threw away exactly the openings worth hearing about: multi-hop Es +// is ordinary on 6 m, 5000 km paths are common and 10000 km happens. Those are +// still directional — a double hop leaves the same sector it entered — so the +// bearing test holds perfectly well, and it is the test doing the real work here. +// +// 90° of spread because a genuine Es cloud illuminates a sector, not the whole +// horizon: the constraint that separates an opening from a merely busy evening. func DefaultConfig() Config { return Config{ Window: 12 * time.Minute, MinCalls: 4, MinKm: 500, - MaxKm: 2400, + MaxKm: 0, // no ceiling — see above BearingSpread: 90, Requiet: 45 * time.Minute, } @@ -107,7 +114,7 @@ func (d *Detector) Add(s Spot, lat float64) *Opening { } // Out-of-range spots are dropped rather than stored: they can never be part // of a single-hop detection, and keeping them only grows the window. - if s.DistKm < d.cfg.MinKm || s.DistKm > d.cfg.MaxKm { + if s.DistKm < d.cfg.MinKm || (d.cfg.MaxKm > 0 && s.DistKm > d.cfg.MaxKm) { return nil } d.recent = append(d.recent, s) diff --git a/internal/bandopen/bandopen_test.go b/internal/bandopen/bandopen_test.go index b7a51a7..8a6c07e 100644 --- a/internal/bandopen/bandopen_test.go +++ b/internal/bandopen/bandopen_test.go @@ -182,3 +182,46 @@ func TestBearingArcWrapsAtNorth(t *testing.T) { t.Errorf("bearings on all four quadrants should span nearly the circle, got %d", s) } } + +// Multi-hop must be detected, not thrown away. +// +// The detector used to cap paths at 2400 km, on the reasoning that past one hop +// the bearing test stops meaning anything. Nexus flagged a 6 m opening at +// 5477 km that OpsLog never saw: the spots were discarded before any test ran. +// Multi-hop Es is directional — a second hop leaves the sector the first one +// entered — so distance is not what tells an opening from noise. The sector is. +func TestMultiHopOpeningIsDetected(t *testing.T) { + d := New(DefaultConfig()) + base := time.Date(2026, 6, 15, 18, 0, 0, 0, time.UTC) + var got *Opening + for i, call := range []string{"LU1AA", "PY2BB", "LU3CC", "PY4DD"} { + if op := d.Add(Spot{ + Call: call, Band: "6m", + DistKm: 5400 + i*40, // double hop, far past the old ceiling + Bearing: +i * 5, // one sector, as a real cloud illuminates + At: base.Add(time.Duration(i) * time.Minute), + }, 47.0); op != nil { + got = op + } + } + if got == nil { + t.Fatal("a four-station 5400 km burst in one sector was not reported as an opening") + } + if got.Band != "6m" { + t.Errorf("band = %q, want 6m", got.Band) + } +} + +// The floor stays: a short path says nothing about the ionosphere. +func TestGroundWaveIsStillIgnored(t *testing.T) { + d := New(DefaultConfig()) + base := time.Date(2026, 6, 15, 18, 0, 0, 0, time.UTC) + for i, call := range []string{"F1AA", "F2BB", "F3CC", "F4DD"} { + if op := d.Add(Spot{ + Call: call, Band: "6m", DistKm: 120, Bearing: 90, + At: base.Add(time.Duration(i) * time.Minute), + }, 47.0); op != nil { + t.Fatalf("a 120 km burst was reported as an opening: %+v", op) + } + } +}