package bandopen import ( "testing" "time" ) func at(min int) time.Time { return time.Date(2026, time.June, 15, 12, min, 0, 0, time.UTC) } // feed pushes spots and returns the last Opening produced, if any. func feed(d *Detector, lat float64, spots ...Spot) *Opening { var last *Opening for _, s := range spots { if o := d.Add(s, lat); o != nil { last = o } } return last } // The signature that must fire: several distinct stations, single-hop range, // one bearing sector, within the window. func TestDetectsSingleHopEs(t *testing.T) { d := New(DefaultConfig()) o := feed(d, 46, Spot{Call: "I0ABC", Band: "6m", DistKm: 1100, Bearing: 140, At: at(0)}, Spot{Call: "IK1DEF", Band: "6m", DistKm: 900, Bearing: 150, At: at(1)}, Spot{Call: "9A2GHI", Band: "6m", DistKm: 1200, Bearing: 120, At: at(2)}, Spot{Call: "S51JKL", Band: "6m", DistKm: 1000, Bearing: 130, At: at(3)}, ) if o == nil { t.Fatal("four stations at single-hop range in one sector must read as an opening") } if o.Band != "6m" || o.Calls != 4 { t.Errorf("got band=%s calls=%d, want 6m/4", o.Band, o.Calls) } if o.MedianKm < 900 || o.MedianKm > 1200 { t.Errorf("median %d km outside the fed range", o.MedianKm) } if !o.InSeason { t.Error("June in the northern hemisphere is Es season") } } // Spots all round the compass are a busy band, not an opening — the bearing // test is what separates the two. func TestScatteredBearingsAreNotAnOpening(t *testing.T) { d := New(DefaultConfig()) if o := feed(d, 46, Spot{Call: "A", Band: "6m", DistKm: 1100, Bearing: 10, At: at(0)}, Spot{Call: "B", Band: "6m", DistKm: 900, Bearing: 110, At: at(1)}, Spot{Call: "C", Band: "6m", DistKm: 1200, Bearing: 210, At: at(2)}, Spot{Call: "D", Band: "6m", DistKm: 1000, Bearing: 300, At: at(3)}, ); o != nil { t.Errorf("bearings spread round the compass must not fire (got %+v)", o) } } // One station spotted by six skimmers is six spots and one station. func TestRepeatedSpotsOfOneStationDoNotFire(t *testing.T) { d := New(DefaultConfig()) var spots []Spot for i := 0; i < 6; i++ { spots = append(spots, Spot{Call: "I0ABC", Band: "6m", DistKm: 1100, Bearing: 140, At: at(i)}) } if o := feed(d, 46, spots...); o != nil { t.Error("one distinct callsign is not an opening however often it is spotted") } } // Out of the single-hop window there is nothing to conclude: under ~500 km a // 6 m contact is ordinary tropo. func TestTropoRangeIsIgnored(t *testing.T) { d := New(DefaultConfig()) if o := feed(d, 46, Spot{Call: "A", Band: "6m", DistKm: 120, Bearing: 140, At: at(0)}, Spot{Call: "B", Band: "6m", DistKm: 200, Bearing: 145, At: at(1)}, Spot{Call: "C", Band: "6m", DistKm: 90, Bearing: 150, At: at(2)}, Spot{Call: "D", Band: "6m", DistKm: 150, Bearing: 135, At: at(3)}, ); o != nil { t.Error("short-range spots must not read as sporadic E") } } // Spread over more than the window is not one burst. func TestSpotsOutsideTheWindowDoNotAccumulate(t *testing.T) { d := New(DefaultConfig()) if o := feed(d, 46, Spot{Call: "A", Band: "6m", DistKm: 1100, Bearing: 140, At: at(0)}, Spot{Call: "B", Band: "6m", DistKm: 900, Bearing: 150, At: at(20)}, Spot{Call: "C", Band: "6m", DistKm: 1200, Bearing: 120, At: at(40)}, Spot{Call: "D", Band: "6m", DistKm: 1000, Bearing: 130, At: at(60)}, ); o != nil { t.Error("spots an hour apart are not one opening") } } // HF is never announced: an "opening" on 20 m is the band's normal state. func TestHFIsNotWatched(t *testing.T) { if Watched("20m") || Watched("40m") { t.Error("HF must not be watched") } if !Watched("6m") || !Watched("2m") || !Watched("4m") { t.Error("6/4/2 m must be watched") } } // Announced once, then quiet — an opening lasts hours and produces hundreds of // spots; one alert is information, forty is noise. func TestOneAnnouncementPerOpening(t *testing.T) { d := New(DefaultConfig()) base := []Spot{ {Call: "A", Band: "6m", DistKm: 1100, Bearing: 140, At: at(0)}, {Call: "B", Band: "6m", DistKm: 900, Bearing: 150, At: at(1)}, {Call: "C", Band: "6m", DistKm: 1200, Bearing: 120, At: at(2)}, {Call: "D", Band: "6m", DistKm: 1000, Bearing: 130, At: at(3)}, } if o := feed(d, 46, base...); o == nil { t.Fatal("expected the first opening") } if o := d.Add(Spot{Call: "E", Band: "6m", DistKm: 1050, Bearing: 135, At: at(4)}, 46); o != nil { t.Error("a second alert inside the quiet period is noise") } } // Out-of-season openings still fire — they are the ones worth knowing about — // and are simply labelled as unusual. func TestOutOfSeasonStillFiresButIsLabelled(t *testing.T) { d := New(DefaultConfig()) nov := func(m int) time.Time { return time.Date(2026, time.November, 3, 9, m, 0, 0, time.UTC) } o := feed(d, 46, Spot{Call: "A", Band: "6m", DistKm: 1100, Bearing: 140, At: nov(0)}, Spot{Call: "B", Band: "6m", DistKm: 900, Bearing: 150, At: nov(1)}, Spot{Call: "C", Band: "6m", DistKm: 1200, Bearing: 120, At: nov(2)}, Spot{Call: "D", Band: "6m", DistKm: 1000, Bearing: 130, At: nov(3)}, ) if o == nil { t.Fatal("an out-of-season opening must still be announced") } if o.InSeason { t.Error("November in the north is not Es season — it must be flagged unusual") } } // Both hemispheres have a summer peak and a lesser winter one, and both read as // expected. What must come out as UNUSUAL is an equinox month. func TestSeasonFollowsTheHemisphere(t *testing.T) { on := func(m time.Month) time.Time { return time.Date(2026, m, 15, 0, 0, 0, 0, time.UTC) } const north, south = 46.0, -33.0 if !InSeason("6m", on(time.June), north) { t.Error("June is the northern main season") } if !InSeason("6m", on(time.December), north) { t.Error("December is the northern winter peak — not a surprise") } if !InSeason("6m", on(time.December), south) { t.Error("December is the southern main season") } if !InSeason("6m", on(time.June), south) { t.Error("June is the southern winter peak") } // The equinoxes are the quiet months in both hemispheres. for _, lat := range []float64{north, south} { for _, m := range []time.Month{time.March, time.April, time.September, time.October} { if InSeason("6m", on(m), lat) { t.Errorf("%v at lat %.0f should read as unusual", m, lat) } } } } // The sector must cope with the wrap at north: 350° and 10° are 20° apart. func TestBearingArcWrapsAtNorth(t *testing.T) { lo, hi, spread := arc([]int{350, 10, 0, 355}) if spread > 30 { t.Errorf("spread %d° across north should be small (lo=%d hi=%d)", spread, lo, hi) } if _, _, s := arc([]int{0, 90, 180, 270}); s < 270 { 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) } } }