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.
261 lines
8.3 KiB
Go
261 lines
8.3 KiB
Go
// Package bandopen spots a band OPENING in the cluster stream — sporadic-E on
|
||
// 6 m, 4 m and 2 m above all.
|
||
//
|
||
// This is observation, not prediction. Every spot OpsLog receives is already
|
||
// enriched with the great-circle distance and bearing from the operator's own
|
||
// grid, so the signature of a single-hop Es opening is directly measurable:
|
||
// several distinct stations appearing on a VHF band, all at single-hop range,
|
||
// all in the same bearing sector, within a few minutes. That combination does
|
||
// not happen by chance — scattered spots at random distances and bearings are
|
||
// just a busy band.
|
||
//
|
||
// The season is REPORTED, never used to suppress. Es peaks in late spring and
|
||
// summer, so an opening in November is unusual — and an unusual opening is
|
||
// precisely the one an operator must not be told about last. InSeason only
|
||
// labels the announcement.
|
||
package bandopen
|
||
|
||
import (
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Spot is the little a detection needs, taken from an enriched cluster spot.
|
||
type Spot struct {
|
||
Call string
|
||
Band string
|
||
DistKm int
|
||
Bearing int // degrees from the operator, short path
|
||
At time.Time
|
||
}
|
||
|
||
// Config tunes the detector. The defaults describe single-hop sporadic E.
|
||
type Config struct {
|
||
Window time.Duration // how far back a burst may span
|
||
MinCalls int // distinct DX calls before it counts as an opening
|
||
MinKm, MaxKm int // path length accepted; MaxKm 0 = no ceiling
|
||
BearingSpread int // widest arc (degrees) the spots may cover
|
||
Requiet time.Duration // silence after announcing a band, so it is announced once
|
||
}
|
||
|
||
// DefaultConfig is the Es envelope.
|
||
//
|
||
// Below ~500 km a 6 m contact is ordinary tropo or ground wave and says nothing
|
||
// about the ionosphere, so that floor stays.
|
||
//
|
||
// There is NO ceiling. There used to be one at 2400 km, on the reasoning that
|
||
// past a single hop the bearing test stops meaning anything. That was wrong, and
|
||
// it silently threw away exactly the openings worth hearing about: multi-hop Es
|
||
// is ordinary on 6 m, 5000 km paths are common and 10000 km happens. Those are
|
||
// still directional — a double hop leaves the same sector it entered — so the
|
||
// bearing test holds perfectly well, and it is the test doing the real work here.
|
||
//
|
||
// 90° of spread because a genuine Es cloud illuminates a sector, not the whole
|
||
// horizon: the constraint that separates an opening from a merely busy evening.
|
||
func DefaultConfig() Config {
|
||
return Config{
|
||
Window: 12 * time.Minute,
|
||
MinCalls: 4,
|
||
MinKm: 500,
|
||
MaxKm: 0, // no ceiling — see above
|
||
BearingSpread: 90,
|
||
Requiet: 45 * time.Minute,
|
||
}
|
||
}
|
||
|
||
// Bands watched. HF is deliberately absent: an "opening" on 20 m is the normal
|
||
// state of the band and announcing it would be noise.
|
||
var watched = map[string]bool{"6m": true, "4m": true, "2m": true}
|
||
|
||
// Watched reports whether a band is one the detector looks at.
|
||
func Watched(band string) bool { return watched[strings.ToLower(strings.TrimSpace(band))] }
|
||
|
||
// Opening is a detected opening, ready to be announced.
|
||
type Opening struct {
|
||
Band string `json:"band"`
|
||
Calls int `json:"calls"` // distinct DX stations seen
|
||
MedianKm int `json:"median_km"` // typical hop length
|
||
BearingMin int `json:"bearing_min"` // sector, degrees
|
||
BearingMax int `json:"bearing_max"`
|
||
InSeason bool `json:"in_season"` // false = unusual for the time of year
|
||
At time.Time `json:"at"`
|
||
Examples []string `json:"examples"` // a few callsigns, for the announcement
|
||
}
|
||
|
||
// Detector keeps the rolling window and the per-band quiet period.
|
||
type Detector struct {
|
||
cfg Config
|
||
recent []Spot
|
||
lastFire map[string]time.Time
|
||
}
|
||
|
||
func New(cfg Config) *Detector {
|
||
if cfg.Window <= 0 {
|
||
cfg = DefaultConfig()
|
||
}
|
||
return &Detector{cfg: cfg, lastFire: map[string]time.Time{}}
|
||
}
|
||
|
||
// Add records a spot and returns an Opening when this spot completes one.
|
||
//
|
||
// Returns nil far more often than not; that is the point. lat is the operator's
|
||
// latitude, for the hemisphere the season depends on.
|
||
func (d *Detector) Add(s Spot, lat float64) *Opening {
|
||
if !Watched(s.Band) {
|
||
return nil
|
||
}
|
||
band := strings.ToLower(strings.TrimSpace(s.Band))
|
||
s.Band = band
|
||
if s.At.IsZero() {
|
||
s.At = time.Now()
|
||
}
|
||
// Out-of-range spots are dropped rather than stored: they can never be part
|
||
// of a single-hop detection, and keeping them only grows the window.
|
||
if s.DistKm < d.cfg.MinKm || (d.cfg.MaxKm > 0 && s.DistKm > d.cfg.MaxKm) {
|
||
return nil
|
||
}
|
||
d.recent = append(d.recent, s)
|
||
d.prune(s.At)
|
||
|
||
if last, ok := d.lastFire[band]; ok && s.At.Sub(last) < d.cfg.Requiet {
|
||
return nil // already announced this band recently
|
||
}
|
||
|
||
inBand := make([]Spot, 0, len(d.recent))
|
||
for _, r := range d.recent {
|
||
if r.Band == band {
|
||
inBand = append(inBand, r)
|
||
}
|
||
}
|
||
op := evaluate(band, inBand, d.cfg)
|
||
if op == nil {
|
||
return nil
|
||
}
|
||
op.At = s.At
|
||
op.InSeason = InSeason(band, s.At, lat)
|
||
d.lastFire[band] = s.At
|
||
return op
|
||
}
|
||
|
||
func (d *Detector) prune(now time.Time) {
|
||
cut := now.Add(-d.cfg.Window)
|
||
keep := d.recent[:0]
|
||
for _, r := range d.recent {
|
||
if r.At.After(cut) {
|
||
keep = append(keep, r)
|
||
}
|
||
}
|
||
d.recent = keep
|
||
}
|
||
|
||
// evaluate decides whether a band's recent spots look like one opening.
|
||
func evaluate(band string, spots []Spot, cfg Config) *Opening {
|
||
// Distinct callsigns, not spot count: one station spotted by six skimmers is
|
||
// six spots and one station, and it is not an opening.
|
||
seen := map[string]Spot{}
|
||
for _, s := range spots {
|
||
c := strings.ToUpper(strings.TrimSpace(s.Call))
|
||
if c == "" {
|
||
continue
|
||
}
|
||
if _, dup := seen[c]; !dup {
|
||
seen[c] = s
|
||
}
|
||
}
|
||
if len(seen) < cfg.MinCalls {
|
||
return nil
|
||
}
|
||
bearings := make([]int, 0, len(seen))
|
||
dists := make([]int, 0, len(seen))
|
||
calls := make([]string, 0, len(seen))
|
||
for c, s := range seen {
|
||
bearings = append(bearings, ((s.Bearing%360)+360)%360)
|
||
dists = append(dists, s.DistKm)
|
||
calls = append(calls, c)
|
||
}
|
||
lo, hi, spread := arc(bearings)
|
||
if spread > cfg.BearingSpread {
|
||
return nil // spots all round the compass — a busy band, not an opening
|
||
}
|
||
sort.Ints(dists)
|
||
sort.Strings(calls)
|
||
if len(calls) > 5 {
|
||
calls = calls[:5]
|
||
}
|
||
return &Opening{
|
||
Band: band, Calls: len(seen), MedianKm: dists[len(dists)/2],
|
||
BearingMin: lo, BearingMax: hi, Examples: calls,
|
||
}
|
||
}
|
||
|
||
// arc returns the smallest compass sector containing every bearing, coping with
|
||
// the wrap at north: 350° and 10° are 20° apart, not 340°.
|
||
func arc(b []int) (lo, hi, spread int) {
|
||
if len(b) == 0 {
|
||
return 0, 0, 0
|
||
}
|
||
s := append([]int(nil), b...)
|
||
sort.Ints(s)
|
||
// The widest GAP between consecutive bearings (round the circle) is the part
|
||
// NOT covered; the sector is everything else.
|
||
worst, at := -1, 0
|
||
for i := range s {
|
||
next := s[(i+1)%len(s)]
|
||
gap := next - s[i]
|
||
if i == len(s)-1 {
|
||
gap = next + 360 - s[i]
|
||
}
|
||
if gap > worst {
|
||
worst, at = gap, i
|
||
}
|
||
}
|
||
lo = s[(at+1)%len(s)]
|
||
hi = s[at]
|
||
spread = 360 - worst
|
||
return lo, hi, spread
|
||
}
|
||
|
||
// InSeason reports whether the time of year is one where sporadic E is common
|
||
// at the operator's latitude.
|
||
//
|
||
// Each hemisphere has a strong summer peak AND a smaller winter one, and both
|
||
// count as expected: a December opening in Europe surprises nobody. What the
|
||
// label marks is the genuinely odd month — an equinox opening.
|
||
//
|
||
// This LABELS a detection, it never gates one. Out-of-season Es exists, and it
|
||
// is precisely the opening an operator must not be told about last.
|
||
func InSeason(band string, t time.Time, lat float64) bool {
|
||
m := t.UTC().Month()
|
||
var months map[time.Month]bool
|
||
if lat >= 0 {
|
||
months = map[time.Month]bool{
|
||
time.May: true, time.June: true, time.July: true, time.August: true, // main
|
||
time.December: true, time.January: true, // lesser winter peak
|
||
}
|
||
} else {
|
||
months = map[time.Month]bool{
|
||
time.November: true, time.December: true, time.January: true, time.February: true,
|
||
time.June: true, time.July: true,
|
||
}
|
||
}
|
||
return months[m]
|
||
}
|
||
|
||
// Sector renders the bearing range for a human, e.g. "NE (35–75°)".
|
||
func (o *Opening) Sector() string {
|
||
return compass(float64(o.BearingMin+o.BearingMax)/2) +
|
||
" (" + strconv.Itoa(o.BearingMin) + "–" + strconv.Itoa(o.BearingMax) + "°)"
|
||
}
|
||
|
||
func compass(deg float64) string {
|
||
names := []string{"N", "NE", "E", "SE", "S", "SW", "W", "NW"}
|
||
i := int(math.Round(deg/45)) % 8
|
||
if i < 0 {
|
||
i += 8
|
||
}
|
||
return names[i]
|
||
}
|