feat(bandopen): announce sporadic-E openings on 6, 4 and 2 m
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.
This commit is contained in:
@@ -695,7 +695,8 @@ type App struct {
|
||||
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
||||
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
||||
awardSnapUsed time.Time // last read — the snapshot is dropped once it goes cold (see awardSnapshotJanitor)
|
||||
webpub webPublisher // log-to-website publishing: debounce timer, periodic ticker, last result
|
||||
webpub webPublisher // log-to-website publishing: debounce timer, periodic ticker, last result
|
||||
bandOpen bandOpenState // sporadic-E / band-opening detector over the spot stream
|
||||
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
|
||||
|
||||
// shuttingDown gates beforeClose re-entry: the first user attempt to
|
||||
@@ -7824,6 +7825,10 @@ func (a *App) clusterEventWorker() {
|
||||
if s.Historical {
|
||||
continue
|
||||
}
|
||||
// Band-opening detector. Deliberately AFTER the Historical guard above: a
|
||||
// SH/DX reply replays a hundred past spots in a second, which is exactly
|
||||
// the shape of a burst and would announce an opening that ended hours ago.
|
||||
a.detectBandOpening(s)
|
||||
// Fire any matching alert rules (sound / visual / e-mail).
|
||||
a.evaluateAlerts(s)
|
||||
// Mirror the spot onto the FlexRadio panadapter when enabled. Infer the
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
// Band-opening announcements — the app-side glue for internal/bandopen.
|
||||
//
|
||||
// The detector needs nothing OpsLog does not already compute: the cluster event
|
||||
// worker enriches every spot with the great-circle distance and bearing from
|
||||
// the operator's grid before this is called. So watching for sporadic E costs
|
||||
// one function call per spot and no new data source.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/bandopen"
|
||||
"hamlog/internal/cluster"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
type bandOpenState struct {
|
||||
mu sync.Mutex
|
||||
det *bandopen.Detector
|
||||
last []bandopen.Opening // most recent first, for the UI
|
||||
}
|
||||
|
||||
const maxRememberedOpenings = 20
|
||||
|
||||
// detectBandOpening feeds one spot to the detector and announces a hit.
|
||||
func (a *App) detectBandOpening(s cluster.Spot) {
|
||||
// No operator grid = no distance and no bearing on the spot, and the whole
|
||||
// detection rests on those two. Say nothing rather than guess.
|
||||
if !a.opSet || s.DistanceKm <= 0 || !bandopen.Watched(s.Band) {
|
||||
return
|
||||
}
|
||||
a.bandOpen.mu.Lock()
|
||||
if a.bandOpen.det == nil {
|
||||
a.bandOpen.det = bandopen.New(bandopen.DefaultConfig())
|
||||
}
|
||||
op := a.bandOpen.det.Add(bandopen.Spot{
|
||||
Call: s.DXCall, Band: s.Band, DistKm: s.DistanceKm,
|
||||
Bearing: s.ShortPath, At: s.ReceivedAt,
|
||||
}, a.opLat)
|
||||
if op != nil {
|
||||
a.bandOpen.last = append([]bandopen.Opening{*op}, a.bandOpen.last...)
|
||||
if len(a.bandOpen.last) > maxRememberedOpenings {
|
||||
a.bandOpen.last = a.bandOpen.last[:maxRememberedOpenings]
|
||||
}
|
||||
}
|
||||
a.bandOpen.mu.Unlock()
|
||||
if op == nil {
|
||||
return
|
||||
}
|
||||
|
||||
applog.Printf("bandopen: %s opening — %d stations, ~%d km, %s%s (%s)",
|
||||
op.Band, op.Calls, op.MedianKm, op.Sector(),
|
||||
map[bool]string{true: "", false: " — UNUSUAL for the season"}[op.InSeason],
|
||||
strings.Join(op.Examples, " "))
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "bandopen:detected", op)
|
||||
}
|
||||
}
|
||||
|
||||
// GetBandOpenings returns the openings seen this session, newest first. The UI
|
||||
// polls this so a detection is still visible after its toast has gone.
|
||||
func (a *App) GetBandOpenings() []bandopen.Opening {
|
||||
a.bandOpen.mu.Lock()
|
||||
defer a.bandOpen.mu.Unlock()
|
||||
out := make([]bandopen.Opening, len(a.bandOpen.last))
|
||||
copy(out, a.bandOpen.last)
|
||||
return out
|
||||
}
|
||||
|
||||
// BandOpeningSummary is the one-line form used in the toast and the log.
|
||||
func BandOpeningSummary(o bandopen.Opening) string {
|
||||
s := fmt.Sprintf("%s open — %d stations ~%d km, %s", strings.ToUpper(o.Band), o.Calls, o.MedianKm, o.Sector())
|
||||
if !o.InSeason {
|
||||
s += " (unusual for the season)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
+6
-2
@@ -2,8 +2,12 @@
|
||||
{
|
||||
"version": "0.24.3",
|
||||
"date": "",
|
||||
"en": [],
|
||||
"fr": []
|
||||
"en": [
|
||||
"Band openings: OpsLog now tells you when 6, 4 or 2 m opens. It watches the spots already arriving from your clusters and RBN — several different stations appearing at single-hop range (500–2400 km) in the same bearing sector within a few minutes is the signature of sporadic E, and nothing else looks like it. You get one message per band per opening, naming the sector and the typical distance. An opening outside the usual season is still announced, and flagged as unusual: those are the ones worth knowing about. Nothing to configure — but the quality depends on having a cluster or RBN feed carrying VHF spots, and 2 m openings are often worked without ever being spotted."
|
||||
],
|
||||
"fr": [
|
||||
"Ouvertures de bande : OpsLog te signale désormais l ouverture du 6, du 4 ou du 2 m. Il surveille les spots qui arrivent déjà de tes clusters et du RBN — plusieurs stations différentes apparaissant à distance de saut simple (500–2400 km) dans le même secteur d azimut en quelques minutes, c est la signature de l Es, et rien d autre n y ressemble. Un message par bande et par ouverture, avec le secteur et la distance typique. Une ouverture hors saison est annoncée quand même, et signalée comme inhabituelle : ce sont celles qu il ne faut surtout pas manquer. Rien à configurer — mais la qualité dépend d avoir un flux cluster ou RBN qui porte des spots VHF, et les ouvertures 2 m sont souvent travaillées sans jamais être spottées."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.24.2",
|
||||
|
||||
@@ -2077,6 +2077,23 @@ export default function App() {
|
||||
return () => { off(); };
|
||||
}, [showToast]);
|
||||
|
||||
// Band openings — sporadic E on 6/4/2 m, detected from the spot stream.
|
||||
//
|
||||
// A toast rather than a rule-based alert: this is not "a station you wanted
|
||||
// appeared", it is "the band itself just changed", and it fires a handful of
|
||||
// times a season. The detector already announces each band once per opening,
|
||||
// so there is nothing to throttle here.
|
||||
useEffect(() => {
|
||||
const off = EventsOn('bandopen:detected', (o: any) => {
|
||||
if (!o?.band) return;
|
||||
const season = o.in_season ? '' : ` — ${t('bmp.openUnusual')}`;
|
||||
showToast(`📡 ${t('bmp.openToast', {
|
||||
band: String(o.band).toUpperCase(), n: o.calls, km: o.median_km,
|
||||
})}${season}`);
|
||||
});
|
||||
return () => { off(); };
|
||||
}, [showToast, t]);
|
||||
|
||||
// DX-cluster spot alerts: a matched rule fires here. Play a beep (WebAudio, no
|
||||
// asset needed — CSP-safe) and/or show a toast, per the rule's chosen actions.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -343,7 +343,7 @@ const en: Dict = {
|
||||
'bmp.statusWorked': 'Worked (this band + mode already in log)', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.',
|
||||
'bmp.map': 'Map', 'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
|
||||
'bmp.bandsLabel': 'Bands:', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Hide FTx', 'bmp.hideFtTitle': 'Hide all digital (FT8/FT4/JS8/…) spots on every band map', 'bmp.fitBand': 'Fit to band', 'bmp.widthTip': 'Drag to resize — double-click to reset', 'bmp.fitTitle': 'Size each band map to show the whole band edge-to-edge',
|
||||
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.legendWorked': 'Worked', 'bmp.legendNewPota': 'New POTA', 'bmp.legendNewCounty': 'New county', 'bmp.legendWorkedCall': 'Callsign already worked', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Data', 'bmp.legendPhone': 'Phone', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
|
||||
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.openToast': '{band} is open — {n} stations around {km} km', 'bmp.openUnusual': 'unusual for the season', 'bmp.legendWorked': 'Worked', 'bmp.legendNewPota': 'New POTA', 'bmp.legendNewCounty': 'New county', 'bmp.legendWorkedCall': 'Callsign already worked', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Data', 'bmp.legendPhone': 'Phone', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
|
||||
'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).',
|
||||
'frm.callsign': 'Callsign', 'frm.locator': 'Locator', 'frm.operator': 'Operator', 'frm.operatorPh': 'same as callsign', 'frm.owner': 'Owner', 'frm.ownerPh': 'station owner callsign', 'frm.name': 'Name', 'frm.namePh': 'your first name',
|
||||
'frm.awardRefs': 'Award reference lists', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).', 'frm.downloading': 'Downloading…', 'frm.reDownload': 'Re-download', 'frm.download': 'Download', 'frm.required': 'Callsign and locator are required.', 'frm.saving': 'Saving…', 'frm.startLogging': 'Start logging',
|
||||
@@ -751,7 +751,7 @@ const fr: Dict = {
|
||||
'bmp.statusWorked': 'Contacté (cette bande + mode déjà au log)', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.',
|
||||
'bmp.map': 'Carte', 'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
|
||||
'bmp.bandsLabel': 'Bandes :', 'bmp.fit': 'FIT', 'bmp.hideFt': 'Masquer FTx', 'bmp.hideFtTitle': 'Masquer tous les spots numériques (FT8/FT4/JS8/…) sur toutes les cartes', 'bmp.fitBand': 'Ajuster à la bande', 'bmp.widthTip': 'Glisser pour redimensionner — double-clic pour réinitialiser', 'bmp.fitTitle': 'Dimensionner chaque carte pour afficher toute la bande',
|
||||
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.legendWorked': 'Contacté', 'bmp.legendNewPota': 'Nouveau POTA', 'bmp.legendNewCounty': 'Nouveau comté', 'bmp.legendWorkedCall': 'Indicatif déjà contacté', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Numérique', 'bmp.legendPhone': 'Phonie', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
|
||||
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.openToast': 'Ouverture {band} — {n} stations vers {km} km', 'bmp.openUnusual': 'inhabituel pour la saison', 'bmp.legendWorked': 'Contacté', 'bmp.legendNewPota': 'Nouveau POTA', 'bmp.legendNewCounty': 'Nouveau comté', 'bmp.legendWorkedCall': 'Indicatif déjà contacté', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Numérique', 'bmp.legendPhone': 'Phonie', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
|
||||
'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).',
|
||||
'frm.callsign': 'Indicatif', 'frm.locator': 'Locator', 'frm.operator': 'Opérateur', 'frm.operatorPh': "identique à l'indicatif", 'frm.owner': 'Propriétaire', 'frm.ownerPh': 'indicatif du propriétaire de la station', 'frm.name': 'Nom', 'frm.namePh': 'ton prénom',
|
||||
'frm.awardRefs': 'Listes de références des diplômes', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — noms et totaux pour ces diplômes (optionnel, peut prendre une minute).', 'frm.downloading': 'Téléchargement…', 'frm.reDownload': 'Retélécharger', 'frm.download': 'Télécharger', 'frm.required': "L'indicatif et le locator sont obligatoires.", 'frm.saving': 'Enregistrement…', 'frm.startLogging': 'Commencer à logger',
|
||||
|
||||
Vendored
+3
@@ -10,6 +10,7 @@ import {catemu} from '../models';
|
||||
import {antgenius} from '../models';
|
||||
import {award} from '../models';
|
||||
import {awardref} from '../models';
|
||||
import {bandopen} from '../models';
|
||||
import {cluster} from '../models';
|
||||
import {extsvc} from '../models';
|
||||
import {powergenius} from '../models';
|
||||
@@ -386,6 +387,8 @@ export function GetAwards():Promise<Array<award.Result>>;
|
||||
|
||||
export function GetBackupSettings():Promise<main.BackupSettings>;
|
||||
|
||||
export function GetBandOpenings():Promise<Array<bandopen.Opening>>;
|
||||
|
||||
export function GetCATSettings():Promise<main.CATSettings>;
|
||||
|
||||
export function GetCATState():Promise<cat.RigState>;
|
||||
|
||||
@@ -718,6 +718,10 @@ export function GetBackupSettings() {
|
||||
return window['go']['main']['App']['GetBackupSettings']();
|
||||
}
|
||||
|
||||
export function GetBandOpenings() {
|
||||
return window['go']['main']['App']['GetBandOpenings']();
|
||||
}
|
||||
|
||||
export function GetCATSettings() {
|
||||
return window['go']['main']['App']['GetCATSettings']();
|
||||
}
|
||||
|
||||
@@ -677,6 +677,56 @@ export namespace awardref {
|
||||
|
||||
}
|
||||
|
||||
export namespace bandopen {
|
||||
|
||||
export class Opening {
|
||||
band: string;
|
||||
calls: number;
|
||||
median_km: number;
|
||||
bearing_min: number;
|
||||
bearing_max: number;
|
||||
in_season: boolean;
|
||||
// Go type: time
|
||||
at: any;
|
||||
examples: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Opening(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.band = source["band"];
|
||||
this.calls = source["calls"];
|
||||
this.median_km = source["median_km"];
|
||||
this.bearing_min = source["bearing_min"];
|
||||
this.bearing_max = source["bearing_max"];
|
||||
this.in_season = source["in_season"];
|
||||
this.at = this.convertValues(source["at"], null);
|
||||
this.examples = source["examples"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace cat {
|
||||
|
||||
export class FlexMeter {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// 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]
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package bandopen
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func at(min int) time.Time {
|
||||
return time.Date(2026, time.June, 15, 12, min, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// feed pushes spots and returns the last Opening produced, if any.
|
||||
func feed(d *Detector, lat float64, spots ...Spot) *Opening {
|
||||
var last *Opening
|
||||
for _, s := range spots {
|
||||
if o := d.Add(s, lat); o != nil {
|
||||
last = o
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// The signature that must fire: several distinct stations, single-hop range,
|
||||
// one bearing sector, within the window.
|
||||
func TestDetectsSingleHopEs(t *testing.T) {
|
||||
d := New(DefaultConfig())
|
||||
o := feed(d, 46,
|
||||
Spot{Call: "I0ABC", Band: "6m", DistKm: 1100, Bearing: 140, At: at(0)},
|
||||
Spot{Call: "IK1DEF", Band: "6m", DistKm: 900, Bearing: 150, At: at(1)},
|
||||
Spot{Call: "9A2GHI", Band: "6m", DistKm: 1200, Bearing: 120, At: at(2)},
|
||||
Spot{Call: "S51JKL", Band: "6m", DistKm: 1000, Bearing: 130, At: at(3)},
|
||||
)
|
||||
if o == nil {
|
||||
t.Fatal("four stations at single-hop range in one sector must read as an opening")
|
||||
}
|
||||
if o.Band != "6m" || o.Calls != 4 {
|
||||
t.Errorf("got band=%s calls=%d, want 6m/4", o.Band, o.Calls)
|
||||
}
|
||||
if o.MedianKm < 900 || o.MedianKm > 1200 {
|
||||
t.Errorf("median %d km outside the fed range", o.MedianKm)
|
||||
}
|
||||
if !o.InSeason {
|
||||
t.Error("June in the northern hemisphere is Es season")
|
||||
}
|
||||
}
|
||||
|
||||
// Spots all round the compass are a busy band, not an opening — the bearing
|
||||
// test is what separates the two.
|
||||
func TestScatteredBearingsAreNotAnOpening(t *testing.T) {
|
||||
d := New(DefaultConfig())
|
||||
if o := feed(d, 46,
|
||||
Spot{Call: "A", Band: "6m", DistKm: 1100, Bearing: 10, At: at(0)},
|
||||
Spot{Call: "B", Band: "6m", DistKm: 900, Bearing: 110, At: at(1)},
|
||||
Spot{Call: "C", Band: "6m", DistKm: 1200, Bearing: 210, At: at(2)},
|
||||
Spot{Call: "D", Band: "6m", DistKm: 1000, Bearing: 300, At: at(3)},
|
||||
); o != nil {
|
||||
t.Errorf("bearings spread round the compass must not fire (got %+v)", o)
|
||||
}
|
||||
}
|
||||
|
||||
// One station spotted by six skimmers is six spots and one station.
|
||||
func TestRepeatedSpotsOfOneStationDoNotFire(t *testing.T) {
|
||||
d := New(DefaultConfig())
|
||||
var spots []Spot
|
||||
for i := 0; i < 6; i++ {
|
||||
spots = append(spots, Spot{Call: "I0ABC", Band: "6m", DistKm: 1100, Bearing: 140, At: at(i)})
|
||||
}
|
||||
if o := feed(d, 46, spots...); o != nil {
|
||||
t.Error("one distinct callsign is not an opening however often it is spotted")
|
||||
}
|
||||
}
|
||||
|
||||
// Out of the single-hop window there is nothing to conclude: under ~500 km a
|
||||
// 6 m contact is ordinary tropo.
|
||||
func TestTropoRangeIsIgnored(t *testing.T) {
|
||||
d := New(DefaultConfig())
|
||||
if o := feed(d, 46,
|
||||
Spot{Call: "A", Band: "6m", DistKm: 120, Bearing: 140, At: at(0)},
|
||||
Spot{Call: "B", Band: "6m", DistKm: 200, Bearing: 145, At: at(1)},
|
||||
Spot{Call: "C", Band: "6m", DistKm: 90, Bearing: 150, At: at(2)},
|
||||
Spot{Call: "D", Band: "6m", DistKm: 150, Bearing: 135, At: at(3)},
|
||||
); o != nil {
|
||||
t.Error("short-range spots must not read as sporadic E")
|
||||
}
|
||||
}
|
||||
|
||||
// Spread over more than the window is not one burst.
|
||||
func TestSpotsOutsideTheWindowDoNotAccumulate(t *testing.T) {
|
||||
d := New(DefaultConfig())
|
||||
if o := feed(d, 46,
|
||||
Spot{Call: "A", Band: "6m", DistKm: 1100, Bearing: 140, At: at(0)},
|
||||
Spot{Call: "B", Band: "6m", DistKm: 900, Bearing: 150, At: at(20)},
|
||||
Spot{Call: "C", Band: "6m", DistKm: 1200, Bearing: 120, At: at(40)},
|
||||
Spot{Call: "D", Band: "6m", DistKm: 1000, Bearing: 130, At: at(60)},
|
||||
); o != nil {
|
||||
t.Error("spots an hour apart are not one opening")
|
||||
}
|
||||
}
|
||||
|
||||
// HF is never announced: an "opening" on 20 m is the band's normal state.
|
||||
func TestHFIsNotWatched(t *testing.T) {
|
||||
if Watched("20m") || Watched("40m") {
|
||||
t.Error("HF must not be watched")
|
||||
}
|
||||
if !Watched("6m") || !Watched("2m") || !Watched("4m") {
|
||||
t.Error("6/4/2 m must be watched")
|
||||
}
|
||||
}
|
||||
|
||||
// Announced once, then quiet — an opening lasts hours and produces hundreds of
|
||||
// spots; one alert is information, forty is noise.
|
||||
func TestOneAnnouncementPerOpening(t *testing.T) {
|
||||
d := New(DefaultConfig())
|
||||
base := []Spot{
|
||||
{Call: "A", Band: "6m", DistKm: 1100, Bearing: 140, At: at(0)},
|
||||
{Call: "B", Band: "6m", DistKm: 900, Bearing: 150, At: at(1)},
|
||||
{Call: "C", Band: "6m", DistKm: 1200, Bearing: 120, At: at(2)},
|
||||
{Call: "D", Band: "6m", DistKm: 1000, Bearing: 130, At: at(3)},
|
||||
}
|
||||
if o := feed(d, 46, base...); o == nil {
|
||||
t.Fatal("expected the first opening")
|
||||
}
|
||||
if o := d.Add(Spot{Call: "E", Band: "6m", DistKm: 1050, Bearing: 135, At: at(4)}, 46); o != nil {
|
||||
t.Error("a second alert inside the quiet period is noise")
|
||||
}
|
||||
}
|
||||
|
||||
// Out-of-season openings still fire — they are the ones worth knowing about —
|
||||
// and are simply labelled as unusual.
|
||||
func TestOutOfSeasonStillFiresButIsLabelled(t *testing.T) {
|
||||
d := New(DefaultConfig())
|
||||
nov := func(m int) time.Time { return time.Date(2026, time.November, 3, 9, m, 0, 0, time.UTC) }
|
||||
o := feed(d, 46,
|
||||
Spot{Call: "A", Band: "6m", DistKm: 1100, Bearing: 140, At: nov(0)},
|
||||
Spot{Call: "B", Band: "6m", DistKm: 900, Bearing: 150, At: nov(1)},
|
||||
Spot{Call: "C", Band: "6m", DistKm: 1200, Bearing: 120, At: nov(2)},
|
||||
Spot{Call: "D", Band: "6m", DistKm: 1000, Bearing: 130, At: nov(3)},
|
||||
)
|
||||
if o == nil {
|
||||
t.Fatal("an out-of-season opening must still be announced")
|
||||
}
|
||||
if o.InSeason {
|
||||
t.Error("November in the north is not Es season — it must be flagged unusual")
|
||||
}
|
||||
}
|
||||
|
||||
// Both hemispheres have a summer peak and a lesser winter one, and both read as
|
||||
// expected. What must come out as UNUSUAL is an equinox month.
|
||||
func TestSeasonFollowsTheHemisphere(t *testing.T) {
|
||||
on := func(m time.Month) time.Time { return time.Date(2026, m, 15, 0, 0, 0, 0, time.UTC) }
|
||||
const north, south = 46.0, -33.0
|
||||
|
||||
if !InSeason("6m", on(time.June), north) {
|
||||
t.Error("June is the northern main season")
|
||||
}
|
||||
if !InSeason("6m", on(time.December), north) {
|
||||
t.Error("December is the northern winter peak — not a surprise")
|
||||
}
|
||||
if !InSeason("6m", on(time.December), south) {
|
||||
t.Error("December is the southern main season")
|
||||
}
|
||||
if !InSeason("6m", on(time.June), south) {
|
||||
t.Error("June is the southern winter peak")
|
||||
}
|
||||
// The equinoxes are the quiet months in both hemispheres.
|
||||
for _, lat := range []float64{north, south} {
|
||||
for _, m := range []time.Month{time.March, time.April, time.September, time.October} {
|
||||
if InSeason("6m", on(m), lat) {
|
||||
t.Errorf("%v at lat %.0f should read as unusual", m, lat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The sector must cope with the wrap at north: 350° and 10° are 20° apart.
|
||||
func TestBearingArcWrapsAtNorth(t *testing.T) {
|
||||
lo, hi, spread := arc([]int{350, 10, 0, 355})
|
||||
if spread > 30 {
|
||||
t.Errorf("spread %d° across north should be small (lo=%d hi=%d)", spread, lo, hi)
|
||||
}
|
||||
if _, _, s := arc([]int{0, 90, 180, 270}); s < 270 {
|
||||
t.Errorf("bearings on all four quadrants should span nearly the circle, got %d", s)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user