A 2 m opening was announced at 9650 km towards Japan. The callsigns gave it away — 7M4RRM, JA7RPC, JF1AWC — all working EME, which is routine on 2 m and reported to PSK Reporter like anything else. Real contacts, real grids, and no evidence whatsoever about the band: an operator turning a beam on that bearing would hear nothing. Removing the flat 2400 km ceiling was right; it threw away genuine multi-hop Es on 6 m. "No limit anywhere" was the overcorrection. The limit is per band now, and it is physics rather than a threshold: 2 m reaches a few thousand kilometres by tropospheric duct or a chain of Es clouds and no further, so 3500 km, and 4 m 4000. Six and ten metres keep no ceiling — multi-hop Es and F2 genuinely do go round the world, which is the case that started all this. Pinned from both sides: the 9650 km Japanese burst produces nothing, and a 2000 km 2 m burst in one sector still counts.
384 lines
13 KiB
Go
384 lines
13 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))] }
|
||
|
||
// maxTerrestrialKm is the longest path a band can carry through the atmosphere.
|
||
// Zero means no limit.
|
||
//
|
||
// The flat 2400 km ceiling was removed because it threw away real multi-hop Es
|
||
// on 6 m, and that was right — but "no limit anywhere" then let something else
|
||
// through. A 2 m opening was announced at 9650 km towards Japan, on stations
|
||
// that were unmistakably working EME: the moon is not an opening, and pointing
|
||
// an antenna at that bearing would find nothing.
|
||
//
|
||
// So the limit is per band, and it is physics rather than a threshold. Two
|
||
// metres reaches a few thousand kilometres by tropospheric duct or a chain of Es
|
||
// clouds and no further; beyond that the path went via the moon or a satellite,
|
||
// neither of which says anything about the band. Six and ten metres have no
|
||
// ceiling at all — multi-hop Es and F2 genuinely go round the world.
|
||
var maxTerrestrialKm = map[string]int{
|
||
"2m": 3500,
|
||
"4m": 4000,
|
||
}
|
||
|
||
// MaxKmFor returns the plausibility ceiling for a band, 0 for none.
|
||
func MaxKmFor(band string) int { return maxTerrestrialKm[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"`
|
||
// Compass is the sector as a point of the compass ("NE"), computed here so
|
||
// the UI never has to redo the wrap-round-north arithmetic that Sector()
|
||
// already gets right — two answers to that question is how a badge ends up
|
||
// naming the opposite direction.
|
||
Compass string `json:"compass"`
|
||
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
|
||
}
|
||
// Past what the atmosphere can carry on this band, the path went via the moon
|
||
// or a satellite. Those are real contacts and real reports; they are simply
|
||
// not evidence about the band.
|
||
if m := MaxKmFor(band); m > 0 && s.DistKm > m {
|
||
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]
|
||
}
|
||
op := &Opening{
|
||
Band: band, Calls: len(seen), MedianKm: dists[len(dists)/2],
|
||
BearingMin: lo, BearingMax: hi, Examples: calls,
|
||
}
|
||
op.Compass = compass(midBearing(lo, hi))
|
||
return op
|
||
}
|
||
|
||
// 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]
|
||
}
|