Observation, not prediction, and it needs no new data source: the cluster event worker already enriches every spot with the great-circle distance and bearing from the operator's grid, which is exactly what a single-hop Es detection rests on. The signature is four or more DISTINCT stations at 500-2400 km inside a 90 degree bearing sector within twelve minutes. Each constraint earns its place: distinct callsigns because one station spotted by six skimmers is six spots and one station; the lower bound because a 6 m contact under 500 km is ordinary tropo and says nothing about the ionosphere; the upper bound because past one hop the bearing test stops meaning anything; and the sector because a real Es cloud illuminates a direction, which is what separates an opening from a merely busy evening. Fed AFTER the Historical guard in the worker. A SH/DX reply replays a hundred past spots in a second - precisely the shape of a burst - and would announce an opening that ended hours ago. Season LABELS, it never gates. Both hemispheres get a summer peak and a lesser winter one, and an opening outside those is announced with "unusual for the season" attached: the rare one is the one an operator must not hear about last. One announcement per band per opening (45 minute quiet period). An opening runs for hours and produces hundreds of spots; one alert is information, forty is noise.
185 lines
6.5 KiB
Go
185 lines
6.5 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)
|
|
}
|
|
}
|