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.
159 lines
5.5 KiB
Go
159 lines
5.5 KiB
Go
package pskrtgt
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// feed builds a watcher with a window already populated, so the analysis can be
|
|
// pinned without a broker.
|
|
func feed(target string, spots ...spot) *Watcher {
|
|
w := New(Config{MyCall: "F4BPO", MyGrid: "JN36BQ"})
|
|
w.target, w.mode, w.dialHz = target, "FT8", 14_074_000
|
|
w.spots = spots
|
|
return w
|
|
}
|
|
|
|
func rep(tx, txGrid, rx, rxGrid string, snr int, offset int, ago time.Duration) spot {
|
|
return spot{
|
|
TxCall: tx, TxGrid: txGrid, RxCall: rx, RxGrid: rxGrid,
|
|
SNR: snr, Freq: 14_074_000 + int64(offset), at: time.Now().Add(-ago),
|
|
}
|
|
}
|
|
|
|
func TestHeardYouIsTheOperatorsOwnCallOnly(t *testing.T) {
|
|
w := feed("YI5RLS",
|
|
rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 20*time.Second),
|
|
rep("F4XYZ", "JN36", "YI5RLS", "LM43", -8, 900, 30*time.Second),
|
|
)
|
|
a := w.Snapshot()
|
|
if !a.HeMe {
|
|
t.Fatal("the DX decoded the operator and the panel says he did not")
|
|
}
|
|
if a.HeMeSNR != -14 || a.HeMeOffset != 1200 {
|
|
t.Errorf("he_me = %d dB @ %d Hz, want -14 dB @ 1200 Hz", a.HeMeSNR, a.HeMeOffset)
|
|
}
|
|
// The operator is not part of the pileup he is calling into: counting
|
|
// yourself as competition is how a "1 caller" band looks contested.
|
|
if a.PileupCount != 1 {
|
|
t.Errorf("pileup = %d, want 1 (the other station only)", a.PileupCount)
|
|
}
|
|
// F4XYZ shares the operator's Maidenhead field, so the path from this
|
|
// region is demonstrably open.
|
|
if !a.PathOpen || a.FromMyAreaCount != 1 {
|
|
t.Errorf("from my area = %d (open=%v), want 1 open", a.FromMyAreaCount, a.PathOpen)
|
|
}
|
|
}
|
|
|
|
func TestNearHimNeedsHisSquareAndTheOperatorsCall(t *testing.T) {
|
|
// He transmits (so his square is known), and a station in that square hears
|
|
// the operator. He himself has decoded nobody.
|
|
w := feed("YI5RLS",
|
|
rep("YI5RLS", "LM43", "OH5CX", "KP30", -3, 0, 40*time.Second),
|
|
rep("F4BPO", "JN36", "YI9XY", "LM43CC", -19, 1500, 25*time.Second),
|
|
)
|
|
a := w.Snapshot()
|
|
if a.TargetGrid != "LM43" {
|
|
t.Fatalf("his square = %q, want LM43", a.TargetGrid)
|
|
}
|
|
if a.NearHimCount != 1 || len(a.NearHimTop) != 1 || a.NearHimTop[0].Call != "YI9XY" {
|
|
t.Errorf("near him = %d %v, want the one receiver in his square", a.NearHimCount, a.NearHimTop)
|
|
}
|
|
// He uploads nothing: the panel must be able to say so, or an operator
|
|
// reads an empty panel as a closed band.
|
|
if a.TargetUploads {
|
|
t.Error("he received nothing in the window, yet the panel claims he uploads")
|
|
}
|
|
if a.HeMe {
|
|
t.Error("nobody reported HIM decoding the operator")
|
|
}
|
|
}
|
|
|
|
func TestWindowDropsWhatIsTooOld(t *testing.T) {
|
|
// Inside the window: a report from six minutes ago is still evidence. Five
|
|
// minutes was too short — measured against DXHunter on the same station at
|
|
// the same moment, it hid a third of the decodes and a co-area station.
|
|
w := feed("YI5RLS",
|
|
rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 6*time.Minute),
|
|
)
|
|
if a := w.Snapshot(); !a.HeMe {
|
|
t.Errorf("a six-minute-old report was dropped from a ten-minute window: %+v", a)
|
|
}
|
|
// Past it, it goes.
|
|
w = feed("YI5RLS",
|
|
rep("F4BPO", "JN36", "YI5RLS", "LM43", -14, 1200, 11*time.Minute),
|
|
)
|
|
if a := w.Snapshot(); a.HeMe || a.Spots != 0 {
|
|
t.Errorf("an eleven-minute-old report survived: %+v", a)
|
|
}
|
|
}
|
|
|
|
func TestSuggestOffsetAvoidsTheOccupiedBinsAndTheCeiling(t *testing.T) {
|
|
// Busy from 1000 to 1500 Hz, empty from 1560 to 2400, ceiling 2400.
|
|
bins := []Bin{}
|
|
for hz := 1020; hz <= 1500; hz += binHz {
|
|
bins = append(bins, Bin{OffsetHz: hz, Count: 3})
|
|
}
|
|
bins = append(bins, Bin{OffsetHz: 2400, Count: 1})
|
|
got := suggestOffset(bins, 2400)
|
|
if got < 1620 || got > 2340 {
|
|
t.Errorf("suggested %d Hz, want somewhere in the empty 1560-2400 run", 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)
|
|
}
|
|
}
|
|
|
|
func TestTopicsFollowTheScope(t *testing.T) {
|
|
w := New(Config{MyCall: "F4BPO", Scope: ScopeTarget})
|
|
w.target, w.mode, w.band = "YI5RLS", "FT8", "20m"
|
|
got := w.topicsLocked()
|
|
want := []string{
|
|
"pskr/filter/v2/+/FT8/YI5RLS/#",
|
|
"pskr/filter/v2/+/FT8/+/YI5RLS/#",
|
|
"pskr/filter/v2/+/FT8/F4BPO/#",
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("narrow scope = %v, want %v", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Errorf("filter %d = %q, want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
|
|
w.cfg.Scope = ScopeBand
|
|
if got := w.topicsLocked(); len(got) != 1 || got[0] != "pskr/filter/v2/20m/FT8/#" {
|
|
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)
|
|
}
|
|
}
|