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.
254 lines
7.8 KiB
Go
254 lines
7.8 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 // single-hop Es range
|
||
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 single-hop Es envelope.
|
||
//
|
||
// 500–2400 km: below ~500 km a 6 m contact is ordinary tropo or ground wave and
|
||
// says nothing about the ionosphere; beyond ~2400 km it is no longer one hop, so
|
||
// the bearing test stops meaning anything. 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: 2400,
|
||
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 || 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]
|
||
}
|