package bandopen import ( "strings" "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) } } } // A sector crossing north must not be labelled by its opposite. // // Sector() averaged the two bearings arithmetically, so 353–61° — plainly north // — came out as (353+61)/2 = 207°, announced as SW. The worst possible error: // not vague, but exactly reversed, to an operator who may turn a beam on it. func TestSectorAcrossNorth(t *testing.T) { cases := []struct { min, max int want string }{ {353, 61, "NE"}, // the one seen in the field {303, 11, "NW"}, // the other one {35, 75, "NE"}, // no wrap, unchanged {170, 190, "S"}, // due south, no wrap {340, 20, "N"}, // straddling north evenly {260, 300, "W"}, // due west } for _, c := range cases { o := Opening{BearingMin: c.min, BearingMax: c.max} got := o.Sector() if !strings.HasPrefix(got, c.want+" ") { t.Errorf("Sector(%d–%d°) = %q, want it to start with %q", c.min, c.max, got, c.want) } } } // A band open in one direction must be found THROUGH the everyday noise. // // The detector used to demand that every station fit inside one 90° arc. On 10 // and 12 m with F2 open, reports arrive from all round the compass, the arc is // 360°, and the test could never pass: the busier the band, the less likely an // opening was announced. Reported from the field — 70 000 decodes, 10 and 12 m // plainly open, nothing said. func TestOpeningFoundThroughAllRoundNoise(t *testing.T) { d := New(DefaultConfig()) base := time.Date(2026, 8, 11, 14, 0, 0, 0, time.UTC) // Four stations concentrated to the south-west — the opening. south := []struct { call string deg int }{{"PY2AA", 220}, {"LU3BB", 228}, {"PY5CC", 235}, {"CX4DD", 240}} // And the usual Europeans scattered everywhere, which is what used to veto it. noise := []struct { call string deg int }{{"DL1XX", 40}, {"SM2YY", 20}, {"EA3ZZ", 190}, {"UA9QQ", 70}, {"G4WW", 320}} var got *Opening for i, s := range noise { d.Add(Spot{Call: s.call, Band: "10m", DistKm: 1200, Bearing: s.deg, At: base.Add(time.Duration(i) * time.Second)}, 47.0) } for i, s := range south { if op := d.Add(Spot{Call: s.call, Band: "10m", DistKm: 9800, Bearing: s.deg, At: base.Add(time.Duration(10+i) * time.Second)}, 47.0); op != nil { got = op } } if got == nil { t.Fatal("an opening concentrated to the SW was not found among stations all round the compass") } mid := midBearing(got.BearingMin, got.BearingMax) if mid < 200 || mid > 260 { t.Errorf("sector middle %.0f° — the reported sector is not the busy one (%d–%d°)", mid, got.BearingMin, got.BearingMax) } } // Stations evenly all round the compass and nothing concentrated: still nothing. // The point of the change was to stop requiring global agreement, not to start // calling every busy band an opening. func TestScatteredBandIsNotAnOpening(t *testing.T) { d := New(DefaultConfig()) base := time.Date(2026, 8, 11, 14, 0, 0, 0, time.UTC) for i, deg := range []int{0, 60, 120, 180, 240, 300} { if op := d.Add(Spot{Call: string(rune('A'+i)) + "1AAA", Band: "10m", DistKm: 3000, Bearing: deg, At: base.Add(time.Duration(i) * time.Second)}, 47.0); op != nil { t.Fatalf("evenly scattered stations were called an opening: %+v", op) } } }