Files
OpsLog/internal/bandopen/bandopen.go
T
rouggy 0550ecdac3 fix: a test button that cannot fail is worse than no button
TestClublog checked that three fields were non-empty and returned "Ready — CALL
via EMAIL". Nothing was ever sent to Club Log, so a wrong password produced the
identical green message. It now signs in, through getadif.php because that is
the one authenticated endpoint that cannot change anything: a test must never
put a record into someone's log. A future start year keeps it from downloading
130 000 QSOs to prove a password, and a rejected login answers 403 before any
body arrives.

LoTW is two credentials doing two jobs and the button reported only the first.
Uploads go through TQSL signed by the certificate — the website password is
never involved — so a wrong one breaks nothing until the day confirmations are
downloaded, by which time nobody connects the two events. Both are now checked
and, more importantly, reported separately.

Also: the detector still refused 12 and 10 m while the PSK Reporter feed was
already subscribed to them, so those decodes were fetched and thrown away. The
rule was never "HF is out", it is "is an opening here an event" — 20 m being
open is the normal state of the band, 10 m opening is not.
2026-08-11 13:16:09 +02:00

268 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 and 12 m are in, and they are 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 and 12 m spend most of a solar cycle
// shut and open sharply when they go — which is exactly what an operator wants
// interrupting them for. They also carry the Es that reaches 6 m, often first.
//
// Anything below 12 m stays out for the reason above.
var watched = map[string]bool{"12m": true, "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)
}
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 (3575°)".
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]
}