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.
228 lines
8.1 KiB
Go
228 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|