A PSK Reporter message says "X was heard BY Y". The watcher measured the distance and bearing from the operator to X and stopped there, never asking who had actually heard it — so a station 1400 km away, decoded by somebody in Japan, counted as evidence. That proves the path from X to JAPAN and says nothing about whether anything reaches this station. It is how a "2 m opening" came to be announced out of a KX9X in the United States, and the operator was right to find the callsign list absurd. Reports are now kept only when the RECEIVING station is within 300 km. Far enough to borrow the ears of a whole region — an opening reaches an area, not a postcode, and waiting for a decode at one's own antenna is just working the band — and close enough that the ionosphere doing something there is it doing the same thing here. The transmitter still supplies the direction and the path length, which is what was always wanted from it. Also drops 12 m from the watched bands, at the operator's request: at this point in the cycle it is open often enough that announcing it is a notification rather than news, and a band that cries wolf costs the ones that do not. One fewer subscription is also less traffic on a PC that pays for every message.
349 lines
11 KiB
Go
349 lines
11 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.
|
||
//
|
||
// 10 m is in, and it is HF. The line is not "HF versus VHF" but "is an opening
|
||
// here an event": 20 m being open is the normal state of the band and saying so
|
||
// is noise, while 10 m spends most of a solar cycle shut and opens sharply when
|
||
// it goes — which is what an operator wants interrupting them for.
|
||
//
|
||
// 12 m was tried and dropped. It behaves too much like 20 m at this point in the
|
||
// cycle: open often enough that announcing it is a notification rather than
|
||
// news, and every band that cries wolf costs the ones that do not.
|
||
var watched = map[string]bool{"10m": true, "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)
|
||
}
|
||
// The DENSEST sector, not the sector covering everything.
|
||
//
|
||
// This used to demand that EVERY station fit inside one 90° arc, which is the
|
||
// right shape for a sporadic-E cloud and hopeless for anything else. With 10
|
||
// and 12 m open on F2 the reports come 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. Backwards.
|
||
//
|
||
// Finding the busiest 90° instead keeps the Es signature intact — a cloud
|
||
// still makes one direction dense — and lets an F2 opening toward South
|
||
// America be seen through the handful of Europeans that are always there.
|
||
lo, hi, n := densestSector(bearings, cfg.BearingSpread)
|
||
if n < cfg.MinCalls {
|
||
return nil // nothing concentrated anywhere: 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°.
|
||
// densestSector returns the width-degree arc containing the most bearings, as
|
||
// its start, end and count.
|
||
//
|
||
// Brute force over each bearing as a starting edge. n is at most a few hundred
|
||
// distinct callsigns in a twelve-minute window, so n² is nothing, and it runs
|
||
// once per accepted spot rather than once per decode.
|
||
//
|
||
// The arc STARTS on a real bearing rather than sweeping every degree: the
|
||
// densest window can always be slid until its leading edge sits on a station, so
|
||
// nothing is missed and there are 360 fewer positions to try.
|
||
func densestSector(b []int, width int) (lo, hi, count int) {
|
||
if len(b) == 0 {
|
||
return 0, 0, 0
|
||
}
|
||
s := append([]int(nil), b...)
|
||
sort.Ints(s)
|
||
best, bestAt := 0, 0
|
||
for i, start := range s {
|
||
n := 0
|
||
for _, x := range s {
|
||
// Distance clockwise from start to x, so the wrap through 0° needs no
|
||
// special case — which is where the old arc() logic earned its keep and
|
||
// this one has to match it.
|
||
d := x - start
|
||
if d < 0 {
|
||
d += 360
|
||
}
|
||
if d <= width {
|
||
n++
|
||
}
|
||
}
|
||
if n > best {
|
||
best, bestAt = n, i
|
||
}
|
||
}
|
||
lo = s[bestAt]
|
||
// The end is the furthest station actually inside the window, not lo+width:
|
||
// reporting an empty 90° when every station sits in the first 20° would
|
||
// overstate the opening's width by four times.
|
||
hi = lo
|
||
for _, x := range s {
|
||
d := x - lo
|
||
if d < 0 {
|
||
d += 360
|
||
}
|
||
if d <= width {
|
||
if e := (lo + d) % 360; d >= ((hi-lo)+360)%360 {
|
||
hi = e
|
||
}
|
||
}
|
||
}
|
||
return lo, hi, best
|
||
}
|
||
|
||
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(midBearing(o.BearingMin, o.BearingMax)) +
|
||
" (" + strconv.Itoa(o.BearingMin) + "–" + strconv.Itoa(o.BearingMax) + "°)"
|
||
}
|
||
|
||
// midBearing is the middle of the arc running CLOCKWISE from min to max.
|
||
//
|
||
// Not the arithmetic mean, which is wrong for every sector crossing north and
|
||
// wrong by the worst possible amount: an opening reported as 353–61° averaged to
|
||
// 207° and was announced as SW when it was NE — the exact opposite direction, to
|
||
// an operator who might turn a beam on it. The detector itself has handled the
|
||
// 0/360 wrap since it was written; only this label did not.
|
||
func midBearing(min, max int) float64 {
|
||
span := max - min
|
||
if span < 0 {
|
||
span += 360
|
||
}
|
||
return math.Mod(float64(min)+float64(span)/2, 360)
|
||
}
|
||
|
||
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]
|
||
}
|