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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user