fix(pskr): a ten-minute window, both directions, and always an answer

Measured against DXHunter on the same station at the same second: 18
decodes here against 27 there.

- The window was five minutes. PSK Reporter's uploaders batch their
  reports, most of them every five, so a five-minute window catches
  about one upload cycle per station. Ten, as DXHunter has always had
  behind a label that says four.

- The history query ran once per target and only on the narrow feed.
  It now runs in both scopes and again every five minutes while a
  station is watched, which is the cadence the uploaders keep.

- It asked only what the target RECEIVED. Both directions now, so
  "who is hearing him" starts full too.

- The suggested call offset looked for a run of empty slots and said
  nothing when there was none — exactly the case it exists for: a
  hundred decodes across a 2800 Hz passband leave no gap. Failing a
  gap it names the quietest slot, ties to the higher offset, never
  above the ceiling.

Chase new: the receiver squares now follow the radius that was asked
for (it was one fixed ring whatever the setting said, so raising it
bought nothing), and the panel says what the feed is doing rather than
leaving an empty list to speak for itself. A change of hunt empties it:
its rows are verdicts reached under the old rule and nothing re-judged
them. Its own band selection, because what a station CAN work and what
is worth watching tonight are different questions.
This commit is contained in:
2026-09-05 21:45:39 +02:00
parent a41626955e
commit 470eaf5d80
4 changed files with 206 additions and 31 deletions
+81 -26
View File
@@ -62,6 +62,12 @@ const (
// whether it is worth calling at all.
pileupWindow = 2 * time.Minute
// backfillEvery is how often the history query is asked again for a target
// that is still being watched. Five minutes is PSK Reporter's own courtesy
// interval for repeating a query, and it happens to be the period most
// uploaders batch on.
backfillEvery = 5 * time.Minute
// The passband histogram: 60 Hz bins from 200 Hz to 4000 Hz. Above 4 kHz
// there is essentially no FT8, and drawing the empty space made the strip
// look broken rather than empty.
@@ -202,10 +208,12 @@ type Watcher struct {
subs []string
spots []spot
// backfilled remembers the target the REST history was fetched for, so the
// panel's polling cannot re-fetch it every second. PSK Reporter's query API
// answers that with a rate limit, and rightly.
backfilled string
// backfilled remembers the target the REST history was fetched for and when,
// so the panel's polling cannot re-fetch it every second PSK Reporter's
// query API answers that with a rate limit, and rightly — while a target
// held for a while still gets a fresh look every few minutes.
backfilled string
backfilledAt time.Time
}
func New(cfg Config) *Watcher {
@@ -282,11 +290,12 @@ func (w *Watcher) Watch(target, mode string, dialHz int64) error {
if err := w.resubscribe(client); err != nil {
return err
}
if changed && w.cfg.Scope == ScopeTarget {
// Under the band-wide subscription the window is already full of the new
// target's reports; under the narrow one it is empty, and the REST query
// is what makes the panel useful in the first fifteen seconds instead of
// after five minutes.
if changed {
// In BOTH scopes. The band-wide subscription was assumed to arrive with
// the target's reports already in the window — true only once it has been
// running a while, and false in the case that matters: the operator picks
// a station a minute after opening the panel and sees one decode where
// another program, running for an hour, shows four.
go w.backfill(target, mode)
}
return nil
@@ -377,7 +386,7 @@ func (w *Watcher) connect() (mqtt.Client, error) {
if err := w.resubscribe(c); err != nil {
w.cfg.Logf("pskr target: %v", err)
}
if target != "" && w.cfg.Scope == ScopeTarget {
if target != "" {
go w.backfill(target, mode)
}
}
@@ -449,7 +458,15 @@ func (w *Watcher) SetOperator(call, grid string) {
// Snapshot recomputes the analysis from the window.
func (w *Watcher) Snapshot() Analysis {
// Due another look at the history? Checked here because this is what the
// panel calls every second; the query itself is rate-limited inside
// backfill, so this cannot turn into a request per poll.
w.mu.Lock()
if t, m := w.target, w.mode; t != "" && time.Since(w.backfilledAt) >= backfillEvery {
w.mu.Unlock()
go w.backfill(t, m)
w.mu.Lock()
}
defer w.mu.Unlock()
now := time.Now()
@@ -633,34 +650,42 @@ func top(m map[string]Entry, limit int) []Entry {
return out
}
// suggestOffset picks an audio slot to call on: the middle of the widest run of
// empty bins below the ceiling.
// suggestOffset picks an audio slot to call on.
//
// Below the CEILING, not below 4000 Hz. The ceiling is the highest offset he
// has actually decoded, and it is the only evidence available about how wide
// his receiver is set — plenty of stations run 2500 Hz. Suggesting 3400 Hz to
// somebody whose passband stops at 2700 is advice to transmit into a filter.
//
// Two passes, and the second is the one that matters on a busy DX. Looking for
// an empty run alone answered "nowhere" exactly when the answer was most
// wanted: a hundred decodes across a 2800 Hz passband leave no run of clear
// bins at all, and the panel drew a full histogram with no advice under it.
// Failing a real gap, the quietest slot is still better than the one the
// operator would have picked by eye.
func suggestOffset(bins []Bin, ceiling int) int {
if ceiling < 1000 {
return 0
}
used := map[int]bool{}
count := make(map[int]int, len(bins))
busy := make(map[int]bool, len(bins)*3)
for _, b := range bins {
count[b.OffsetHz] = b.Count
if b.Count > 0 {
used[b.OffsetHz] = true
// The neighbours too: FT8 is 50 Hz wide and the bins are 60, so a
// signal on a bin edge covers the next one as surely as its own.
used[b.OffsetHz-binHz] = true
used[b.OffsetHz+binHz] = true
busy[b.OffsetHz], busy[b.OffsetHz-binHz], busy[b.OffsetHz+binHz] = true, true, true
}
}
bestStart, bestLen := -1, 0
start, run := -1, 0
// From 1000 Hz up: below that is where every default transmit offset sits,
// so it is the most crowded part of the passband and the least useful
// advice.
for edge := 1020; edge+binHz <= ceiling; edge += binHz {
if used[edge] {
// so it is the most crowded part of the passband and the least useful advice.
const low = 1020
high := (ceiling / binHz) * binHz
// Pass 1 — the widest clear run, and call from its middle.
bestStart, bestLen, start, run := -1, 0, -1, 0
for edge := low; edge <= high; edge += binHz {
if busy[edge] {
start, run = -1, 0
continue
}
@@ -672,10 +697,32 @@ func suggestOffset(bins []Bin, ceiling int) int {
bestStart, bestLen = start, run
}
}
if bestStart < 0 || bestLen < 2 {
if bestStart >= 0 && bestLen >= 2 {
return bestStart + bestLen*binHz/2
}
// Pass 2 — no clear run: the least busy slot. Walked from the top down, so
// a tie goes to the higher offset, which is the less crowded half of any
// passband and the half a pile-up leaves alone.
quietest, fewest := -1, 1<<30
for edge := high; edge >= low; edge -= binHz {
if c := count[edge]; c < fewest {
quietest, fewest = edge, c
}
}
if quietest < 0 {
return 0
}
return bestStart + bestLen*binHz/2
// Never past the ceiling: the top bin CONTAINS it, so its middle can sit
// beyond the highest offset he has been shown to decode — which is the one
// thing this function exists to avoid.
if quietest+binHz/2 > ceiling {
quietest -= binHz
}
if quietest < low {
return 0
}
return quietest + binHz/2
}
// bandTag names the band a dial frequency is on, in PSK Reporter's own
@@ -755,11 +802,19 @@ type pskrReports struct {
// is what gets an application rate-limited off the service for everyone.
func (w *Watcher) backfill(target, mode string) {
w.mu.Lock()
if w.backfilled == target {
// Once per target, then no more often than the refresh interval.
//
// The live feed alone lags by design: PSK Reporter's uploaders batch their
// reports, most of them every five minutes, so between two batches the
// window only holds what happened to have been sent. Asking the history
// again at that same cadence keeps it as full as a program that has been
// subscribed for an hour — which is the whole of the difference an operator
// sees when comparing the two side by side.
if w.backfilled == target && time.Since(w.backfilledAt) < backfillEvery {
w.mu.Unlock()
return
}
w.backfilled = target
w.backfilled, w.backfilledAt = target, time.Now()
w.mu.Unlock()
got := 0
+28 -4
View File
@@ -99,10 +99,12 @@ func TestSuggestOffsetAvoidsTheOccupiedBinsAndTheCeiling(t *testing.T) {
if got < 1620 || got > 2340 {
t.Errorf("suggested %d Hz, want somewhere in the empty 1560-2400 run", got)
}
// A passband that stops low must not produce advice above it: transmitting
// past the DX's filter is the one outcome worse than picking a busy slot.
if got := suggestOffset(bins, 1500); got != 0 {
t.Errorf("suggested %d Hz with a 1500 Hz ceiling and no room, want none", got)
// A passband with no gap at all still gets an answer — the quietest slot,
// which is what an operator would look for by eye. What it must never do is
// advise ABOVE the ceiling: transmitting past the DX's filter is the one
// outcome worse than picking a busy slot.
if got := suggestOffset(bins, 1500); got <= 1000 || got > 1500 {
t.Errorf("suggested %d Hz with a full 1500 Hz passband, want a slot inside it", got)
}
if got := suggestOffset(nil, 0); got != 0 {
t.Errorf("suggested %d Hz with no data at all, want none", got)
@@ -132,3 +134,25 @@ func TestTopicsFollowTheScope(t *testing.T) {
t.Errorf("band scope = %v, want the one band-wide filter", got)
}
}
// The case reported from the air: a busy DX, 103 decodes across a 2805 Hz
// passband, and the panel drew the whole histogram with no advice under it.
func TestACrowdedPassbandStillGetsAnAnswer(t *testing.T) {
// Every bin from 1020 to 2800 occupied — no clear run anywhere, and the
// quietest slot is the answer.
bins := []Bin{}
for hz := 1020; hz <= 2760; hz += binHz {
n := 5
if hz == 2400 { // one slot noticeably quieter than the rest
n = 1
}
bins = append(bins, Bin{OffsetHz: hz, Count: n})
}
got := suggestOffset(bins, 2805)
if got == 0 {
t.Fatal("no advice on a full passband — this is exactly when it is wanted")
}
if got < 2400 || got > 2460 {
t.Errorf("suggested %d Hz, want the quietest slot around 2400", got)
}
}