feat(cluster): feed the locator store from PSK Reporter, filtered at the broker
The store shipped without its main source. Locators came only from this station's own WSJT-X decodes, which is what the whole MQTT discussion was about. PSK Reporter now feeds it through a new OnGrid callback, fired before any geographic filtering: what the store wants is "which square is this callsign in", and that is true whoever happened to hear the report. The subscription filters on the RECEIVER's square, a level the v2 topic exposes. Measured on the live feed: the four opening bands unfiltered are 83 messages a second, of which roughly one in a hundred survived the NearKm test that already existed here — the rest was received, TLS-decrypted, JSON-parsed and discarded. One ring of squares is 0.2 to 1.2 a second. By square rather than by DXCC, which was the obvious alternative: one country measured 1.2 messages a second (OH) against 72.5 (K) on a single band, because a DXCC can be a continent. By square the same measurement is 0.2 to 1.2, so the load follows distance — what the feed is actually about — and is the same for every operator. Grid chasing subscribes with the "+" band wildcard, so one subscription per square covers every band instead of one per band per square. The store gains a source column (decode | mqtt), migrated in place on an existing file.
This commit is contained in:
+44
-5
@@ -81,7 +81,16 @@ type Config struct {
|
||||
// OnSpot receives every accepted decode. Called from the MQTT goroutine, so
|
||||
// it must not block: the broker's buffer is what pays for it if it does.
|
||||
OnSpot func(Spot)
|
||||
Logf func(string, ...any)
|
||||
// OnGrid receives the transmitter of EVERY message, before any geographic
|
||||
// filtering, for the callsign-to-locator store. Same goroutine as OnSpot and
|
||||
// the same rule: do not block.
|
||||
OnGrid func(call, grid string)
|
||||
// RxGrids filters at the BROKER: only reports collected by a receiver in one
|
||||
// of these squares are sent at all. Empty keeps the old behaviour, which was
|
||||
// to receive the world and discard it here — measured at 83 messages a second
|
||||
// for the four opening bands, of which about one in a hundred survived.
|
||||
RxGrids []string
|
||||
Logf func(string, ...any)
|
||||
}
|
||||
|
||||
// Watcher owns the MQTT connection and its subscriptions.
|
||||
@@ -115,6 +124,32 @@ func New(cfg Config) *Watcher {
|
||||
return &Watcher{cfg: cfg}
|
||||
}
|
||||
|
||||
// topics builds the subscription list.
|
||||
//
|
||||
// The v2 topic is
|
||||
//
|
||||
// pskr/filter/v2/<band>/<mode>/<tx call>/<rx call>/<tx grid>/<rx grid>/<tx dxcc>/<rx dxcc>
|
||||
//
|
||||
// so the receiver's square is a level the broker can filter on, and a band of
|
||||
// "+" means every band. Filtering by RECEIVER square rather than by receiver
|
||||
// DXCC is deliberate: measured on 20 m, one country ranged from 1.2 messages a
|
||||
// second (OH) to 72.5 (K), because a DXCC can be a continent. By square the
|
||||
// same measurement is 0.2 to 1.2 — the load follows distance, which is what the
|
||||
// feed is actually about, and it is the same for every operator.
|
||||
func (w *Watcher) topics() []string {
|
||||
out := []string{}
|
||||
for _, b := range w.cfg.Bands {
|
||||
if len(w.cfg.RxGrids) == 0 {
|
||||
out = append(out, "pskr/filter/v2/"+b+"/#")
|
||||
continue
|
||||
}
|
||||
for _, g := range w.cfg.RxGrids {
|
||||
out = append(out, "pskr/filter/v2/"+b+"/+/+/+/+/"+strings.ToUpper(g)+"/+/+")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Start connects and subscribes. Safe to call when already running.
|
||||
func (w *Watcher) Start() error {
|
||||
w.mu.Lock()
|
||||
@@ -143,10 +178,7 @@ func (w *Watcher) Start() error {
|
||||
|
||||
opts.OnConnect = func(c mqtt.Client) {
|
||||
w.cfg.Logf("pskr: connected to %s", w.cfg.Broker)
|
||||
for _, b := range w.cfg.Bands {
|
||||
// Every mode, every pair of stations, on this band. That firehose IS
|
||||
// the point: the detector's job is to find the shape in it.
|
||||
topic := "pskr/filter/v2/" + b + "/#"
|
||||
for _, topic := range w.topics() {
|
||||
if tok := c.Subscribe(topic, 0, w.handle); tok.Wait() && tok.Error() != nil {
|
||||
w.cfg.Logf("pskr: subscribe %s failed: %v", topic, tok.Error())
|
||||
continue
|
||||
@@ -206,6 +238,13 @@ func (w *Watcher) handle(_ mqtt.Client, m mqtt.Message) {
|
||||
return
|
||||
}
|
||||
|
||||
// The locator store takes every transmitter, before any of the geography
|
||||
// below. What it wants is "which square is this callsign in", and that is
|
||||
// true whoever happened to hear the report.
|
||||
if w.cfg.OnGrid != nil {
|
||||
w.cfg.OnGrid(call, grid[:4])
|
||||
}
|
||||
|
||||
// THE RECEIVER HAS TO BE NEAR THE OPERATOR. This is the whole difference
|
||||
// between a useful feed and a world map.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package pskr
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The receiver square is a level the BROKER can filter on, which is the whole
|
||||
// point: measured on the live feed, the four opening bands unfiltered are 83
|
||||
// messages a second of which about one in a hundred survives the NearKm test.
|
||||
// One ring of squares is under two a second, and the same for every operator —
|
||||
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K) on one band.
|
||||
func TestTopicsFilterOnTheReceiverSquare(t *testing.T) {
|
||||
w := New(Config{Bands: []string{"6m"}, RxGrids: []string{"jn36", "JN37"}})
|
||||
got := w.topics()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want one subscription per band × square, got %v", got)
|
||||
}
|
||||
// Level order: band/mode/txcall/rxcall/txgrid/RXGRID/txdxcc/rxdxcc
|
||||
want := "pskr/filter/v2/6m/+/+/+/+/JN36/+/+"
|
||||
if got[0] != want {
|
||||
t.Errorf("topic = %q, want %q", got[0], want)
|
||||
}
|
||||
if !strings.Contains(got[1], "/JN37/") {
|
||||
t.Errorf("square not upper-cased into the topic: %q", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// With no squares the old behaviour stands: receive the band and decide here.
|
||||
func TestTopicsWithoutSquares(t *testing.T) {
|
||||
w := New(Config{Bands: []string{"10m", "2m"}})
|
||||
got := w.topics()
|
||||
if len(got) != 2 || got[0] != "pskr/filter/v2/10m/#" {
|
||||
t.Errorf("unfiltered topics = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Grid chasing wants every band. "+" is the MQTT single-level wildcard, so one
|
||||
// subscription per square covers the lot instead of one per band per square.
|
||||
func TestTopicsAllBands(t *testing.T) {
|
||||
w := New(Config{Bands: []string{"+"}, RxGrids: []string{"JN36"}})
|
||||
got := w.topics()
|
||||
if len(got) != 1 || got[0] != "pskr/filter/v2/+/+/+/+/+/JN36/+/+" {
|
||||
t.Errorf("all-band topic = %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user