fix(bandopen): drop the distance ceiling
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user