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) + } + } +}