feat(cluster): grids from the UDP decodes, plus spotter continent and LoTW

Backend groundwork; the columns and filters that consume it come next.

GRIDS. A CQ is the one WSJT-X message that carries a locator, and wsjtSender was
throwing that token away. It is now returned, validated as a real field+square,
and remembered per callsign. This is the ONLY grid source available: a DX-cluster
line carries the spotter's grid at best and never the DX's, and a per-callsign
QRZ lookup under an RBN firehose is not a trade worth making. So grids are known
for the stations this receiver decoded - which is exactly the FT8/FT4 watering
hole an operator is looking at while grid chasing.

RR73 is why the grid is validated rather than pattern-matched. R is inside A-R
and 73 inside 00-99, so a sign-off satisfies the Maidenhead shape exactly and
would have planted a grid that does not exist into the index, silently.

NEW GRID keys on "GRID|MODE" with the mode put through the same normMode as
everything else, so the "group digital modes" option decides whether a grid
worked on FT8 is still new on FT4 - one rule, no branch. Grids are truncated to
four characters: a log holds a mix of JN36 and JN36QU, and without that the same
square is new forever, once per subsquare.

On cost, which was the condition: one more DISTINCT scan when the status
snapshot is rebuilt, then map lookups per spot. The same shape as the county and
POTA sets it sits beside, and the snapshot exists precisely so a spot batch never
touches the logbook.

Spotter continent and the LoTW flag come from tables already in memory - the
DXCC prefix table and ARRL's user list - so they cost a lookup each. The spotter
continent answers a different question from the DX's: whether anyone near you is
hearing this at all.
This commit is contained in:
2026-08-10 16:52:36 +02:00
parent 0a8c1ac45f
commit 9b2115be8f
6 changed files with 204 additions and 41 deletions
+80
View File
@@ -595,6 +595,18 @@ type App struct {
// or when a setting that shapes the maps flips.
clusterStatusIdx *clusterStatusCache
clusterStatusMu sync.Mutex
// decodeGrids maps a callsign to the 4-character grid it announced in a CQ
// heard over the WSJT-X UDP link. It is the ONLY source of grids we have for
// a spot: a DX-cluster line carries the spotter's grid at best, never the
// DX's, and a per-callsign QRZ lookup under an RBN firehose is out of the
// question. So grids are known for the stations this station's own receiver
// decoded — which is exactly the FT8/FT4 watering hole an operator is looking
// at when grid chasing.
//
// In memory only, and bounded: it is a session-local view of who is on the
// air now, not a database.
decodeGrids map[string]string
decodeGridsMu sync.RWMutex
// Self-spot throttle: when and on what frequency we last announced ourselves.
// Held in memory only — a restart legitimately re-announces the station.
selfSpotMu sync.Mutex
@@ -11769,6 +11781,19 @@ func (a *App) consumeUDPEvents() {
}
switch {
case ev.DecodeCall != "":
// Remember the grid before anything else: a CQ is the one message that
// carries it, and the station may never send another.
if ev.DecodeGrid != "" {
a.decodeGridsMu.Lock()
if a.decodeGrids == nil {
a.decodeGrids = make(map[string]string, 512)
}
if len(a.decodeGrids) > 20000 {
a.decodeGrids = make(map[string]string, 512) // bound a long session
}
a.decodeGrids[strings.ToUpper(ev.DecodeCall)] = ev.DecodeGrid
a.decodeGridsMu.Unlock()
}
// A WSJT-X decode (heard station). Render it on the FlexRadio
// panadapter when the option is on; green + SNR comment, auto-expiring
// after the configured duration. De-duped per call in the Flex backend.
@@ -16368,6 +16393,10 @@ type SpotQuery struct {
Band string `json:"band"`
Mode string `json:"mode"`
POTARef string `json:"pota_ref,omitempty"` // park id if the spot is a POTA activation
// Spotter is the station that sent the spot. Only its continent is wanted, and
// resolving it here rather than in the frontend keeps the one DXCC prefix
// table as the single authority on what continent a callsign is in.
Spotter string `json:"spotter,omitempty"`
}
// SpotStatus is the per-tuple result. Status is one of:
@@ -16400,6 +16429,21 @@ type SpotStatus struct {
County string `json:"county,omitempty"`
State string `json:"state,omitempty"`
NewPOTA bool `json:"new_pota"`
// Grid is the 4-character square this station announced in a CQ on the UDP
// link, and NewGrid says that square has never been worked. Both are empty /
// false for any station this receiver has not decoded — a DX-cluster line
// carries the SPOTTER's grid at best, never the DX's.
Grid string `json:"grid,omitempty"`
NewGrid bool `json:"new_grid"`
// SpotterContinent is the continent of the station that SENT the spot, not of
// the DX. It answers a different question — "is anyone near me hearing this?"
// — which is what makes it worth filtering on: a JA spot on 20 m tells a
// European very little about their own path.
SpotterContinent string `json:"spotter_continent,omitempty"`
// LoTW is true when the DX callsign appears in ARRL's user-activity list, so
// an operator chasing confirmations can skip the stations that will never
// upload. Inert until that list has been downloaded.
LoTW bool `json:"lotw"`
// NewPfx flags a CQ WPX prefix never worked before, and Pfx is that prefix.
// Also orthogonal: a common entity on a worked band can still carry a prefix
// that has never been in the log, which is exactly what a WPX chaser is
@@ -16427,6 +16471,7 @@ type clusterStatusCache struct {
workedCounties map[string]struct{}
workedPOTA map[string]struct{}
workedPfx map[string]struct{}
workedGrids map[string]struct{} // "GRID|MODE", mode normalised like the rest
normMode func(string) string // nil unless digital-mode grouping is on
groupDigital bool // settings the maps were built under —
sameSlot bool // a change rebuilds the snapshot
@@ -16493,6 +16538,10 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
// lookup) and worked POTA parks.
c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx)
// One more DISTINCT scan when the snapshot is rebuilt, then pure map lookups
// per spot — the same shape as the county and POTA sets beside it, which is
// why grids cost nothing under an RBN firehose.
c.workedGrids, _ = a.qso.WorkedGridKeys(a.ctx, c.normMode)
// Worked WPX prefixes, derived from the callsigns we already loaded — no
// extra query. Derived rather than read from the stored PFX column: that
// column is only filled when an import supplied it, and deriving keeps this
@@ -16581,6 +16630,37 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
out[i].NewPOTA = true
}
}
// The spotter's continent, and whether the DX uploads to LoTW. Both are
// in-memory lookups on tables already loaded, so they add nothing per spot.
if a.dxcc != nil && q.Spotter != "" {
if m, ok := a.dxcc.Lookup(q.Spotter); ok {
out[i].SpotterContinent = m.Continent
}
}
if a.lotwUsers != nil {
out[i].LoTW = a.lotwUsers.Lookup(q.Call).IsUser
}
// NEW GRID: the square this station announced in a CQ we decoded. The mode
// is part of the key, so the "group digital modes" option decides whether a
// grid worked on FT8 still counts as new on FT4 — one rule, no branch here.
{
// The length check has to be INSIDE the lock: the decode goroutine
// replaces this map wholesale when it grows too large, so reading len()
// unguarded is a race on the map header, not a cheap fast path.
a.decodeGridsMu.RLock()
g := a.decodeGrids[strings.ToUpper(q.Call)]
a.decodeGridsMu.RUnlock()
if g != "" {
out[i].Grid = g
cm := out[i].Mode
if normMode != nil && cm != "" {
cm = normMode(cm)
}
if _, done := idx.workedGrids[g+"|"+cm]; !done {
out[i].NewGrid = true
}
}
}
// NEW COUNTY: resolve the callsign's home county from the offline ULS
// store (US only; inert until downloaded) and flag if never worked.
if a.uls != nil {