Two faults, both found from one field log: 70 000 decodes, 10 and 12 m plainly open, nothing announced — and the two openings that DID fire named the wrong direction. evaluate() required EVERY distinct station in the window to fit inside one 90° arc. That is the shape of a sporadic-E cloud and of nothing else. With 10 and 12 m open on F2 the reports arrive from all round the compass, the arc is 360°, and the test can never pass — so the busier the band, the less likely an opening was announced. Exactly backwards. It now finds the DENSEST sector instead, which keeps the Es signature intact (a cloud still makes one direction dense) and lets a real F2 opening be seen through the handful of neighbours who are always there. Scattered-but-busy still reports nothing: the point was to stop demanding global agreement, not to call every open band an opening. Sector() averaged the two bearings arithmetically, so an arc crossing north was labelled by its opposite: 353–61° averaged to 207° and went out as SW when it was NE. Not a vague error — a reversed one, given to an operator who may turn a beam on it. The detector has handled the 0/360 wrap since it was written; only this label had not.
311 lines
11 KiB
Go
311 lines
11 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|