feat: docked watch-list panel, and auto-call learns the orthogonal markers
The watch list was a tab, and an operator working FT8 lives on the decodes one: a station they had asked to be told about turned up on a screen they were not looking at. The same answer is now docked in the widget strip, above the tabs, reduced to what is worth acting on — on the air and still needed, one row per band and mode, with the cluster's own NEW DXCC / NEW BAND / NEW SLOT badge and a click that tunes. Off by default. The "active and needed" answer costs a debounced query per visible slot, so it is written once (lib/watchlistSpots) and the tab uses it too. Auto-call: - It answers a new prefix, county, state, square or park. Those markers are orthogonal to the entity, they ranked as nothing-needed, and the engine sat through a never-worked WPX prefix calling CQ. New rung at the foot of the ladder, gated by the chase switches the badges use — which meant making those switches portable, since the backend cannot read localStorage. - It calls THROUGH a pileup. Giving up the moment the DX answered somebody else is precisely how a queue is not worked; the call and miss counters already bound the effort, and a station in mid-exchange is still never chosen as a new target. The PSK Reporter panel now follows the station auto-call is waiting for: the analysis takes a history query and a period or two to fill, so starting it when the DX comes free is starting it too late. Callbook lookup: a compound callsign with a page of its OWN keeps that page's location. QRZ files HP/WE9G under exactly that form, with the Panama square the station is operating from, and the rule that drops a home address from a portable call was throwing it away. The record's own country tells an operation's page from a home page. Changelog: entries may open with [NEW], drawn as a pill in the What's new dialog — a release is mostly fixes and the two or three genuinely new things should not have to be found by reading all of it.
This commit is contained in:
@@ -58,10 +58,20 @@ type Need int
|
||||
|
||||
const (
|
||||
NeedNone Need = iota
|
||||
NeedSlot // entity worked on this band and in this mode, never together
|
||||
NeedMode // entity never worked in this mode
|
||||
NeedBand // entity never worked on this band
|
||||
NeedDXCC // entity never worked at all
|
||||
// NeedExtra: the ENTITY has nothing left to give on this band and mode, but
|
||||
// the station carries something else that has never been worked — a WPX
|
||||
// prefix, a square, a US county or state, a park. Those are what the cluster
|
||||
// calls the orthogonal markers, and an operator who ticked them in the chase
|
||||
// settings is hunting them: leaving them at nothing-needed meant auto-call
|
||||
// watched a never-worked prefix call CQ and did nothing.
|
||||
//
|
||||
// The lowest rung, deliberately. It is worth calling when nothing better is
|
||||
// on the air, and never worth leaving a new band for.
|
||||
NeedExtra
|
||||
NeedSlot // entity worked on this band and in this mode, never together
|
||||
NeedMode // entity never worked in this mode
|
||||
NeedBand // entity never worked on this band
|
||||
NeedDXCC // entity never worked at all
|
||||
)
|
||||
|
||||
func (n Need) String() string {
|
||||
@@ -74,6 +84,8 @@ func (n Need) String() string {
|
||||
return "mode"
|
||||
case NeedSlot:
|
||||
return "slot"
|
||||
case NeedExtra:
|
||||
return "extra"
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
@@ -110,6 +122,9 @@ type Candidate struct {
|
||||
Decode
|
||||
Need Need
|
||||
Watched bool
|
||||
// Extra names what NeedExtra is about — "prefix", "county", "square" — so the
|
||||
// decision line says why the station was worth a call. Empty otherwise.
|
||||
Extra string
|
||||
// Unconfirmed says the need above exists ONLY because a contact was never
|
||||
// confirmed — the operator hunts "new + unconfirmed", the band is in the log
|
||||
// and the QSL is not. A real need outranks it; see rank.
|
||||
@@ -427,6 +442,16 @@ func (e *Engine) maxAttempts(c Candidate) int {
|
||||
// 10 mode 8 mode unconf
|
||||
// 6 slot 4 slot unconf
|
||||
// 0 nothing to call for
|
||||
//
|
||||
// needLabel is what the candidate is worth, in words: the rung, or the name of
|
||||
// the orthogonal marker when that is the whole reason for the call.
|
||||
func needLabel(c Candidate) string {
|
||||
if c.Need == NeedExtra && c.Extra != "" {
|
||||
return "new " + c.Extra
|
||||
}
|
||||
return c.Need.String()
|
||||
}
|
||||
|
||||
func rank(c Candidate) int {
|
||||
r := int(c.Need) * 4 // slot 4, mode 8, band 12, DXCC 16
|
||||
if c.Need != NeedNone && !c.Unconfirmed {
|
||||
@@ -726,36 +751,26 @@ func (e *Engine) OnPeriod(p Period) Action {
|
||||
e.heldSince = p.At
|
||||
return Action{}
|
||||
}
|
||||
// IT IS WORKING SOMEBODY ELSE. Stop calling it.
|
||||
// IT IS WORKING SOMEBODY ELSE, AND WE GO ON CALLING.
|
||||
//
|
||||
// Callability was tested when the station was CHOSEN and never again
|
||||
// while it was held — so a station picked on its CQ, which then
|
||||
// answered another caller, went on being called for the whole seven
|
||||
// attempts. Two minutes of transmitting at a station that is in a
|
||||
// QSO and cannot hear the call, while the band moves on.
|
||||
// This used to stop, on the reasoning that a station in a QSO cannot
|
||||
// hear the call. That is how a pileup is NOT worked: a DX with a
|
||||
// queue answers one caller per period, and the only way to be the
|
||||
// next one is to keep calling while it works the others. An operator
|
||||
// watched exactly that — D44TWO finishing a contact, the call
|
||||
// started, the DX coming back to somebody else, and the engine
|
||||
// giving up on a station that was about to be free.
|
||||
//
|
||||
// Its final frame does not count: a station sending RR73 to somebody
|
||||
// else is one period from being free, which is the best moment there
|
||||
// is to be calling it. Only a report or a grid to another station
|
||||
// means the exchange is under way.
|
||||
if !callable(*seen, p.MyCall) {
|
||||
why := fmt.Sprintf("%s is working %s — it cannot answer", tc, addressee(seen.Msg))
|
||||
e.drop()
|
||||
// AND THE SLOT IS STILL FREE. This period's decodes are in hand,
|
||||
// so the next station is picked from them now instead of fifteen
|
||||
// seconds from now — the operator watching the screen sees a CQ
|
||||
// two rows down go unanswered for a whole period, and reads that
|
||||
// as the engine having missed it.
|
||||
//
|
||||
// Never while transmitting: a reply then switches the decoder's
|
||||
// call mid-over and cuts our own transmission in half, which is
|
||||
// the whole reason the halt below is Soft.
|
||||
if !p.TX.Transmitting {
|
||||
if a := e.pick(p, why); a.Kind == DoReply {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return Action{Kind: DoHalt, Soft: true, Reason: why}
|
||||
// The effort is already bounded: seven calls, fifteen for a watched
|
||||
// station, and the miss counter for one that goes off the air. And
|
||||
// nothing is wasted while it is busy — a better station may still
|
||||
// take the slot, because it has not answered us (see preempt).
|
||||
//
|
||||
// The callable test still governs the CHOICE of a target, where it
|
||||
// belongs: a reply to a decode in mid-exchange is one WSJT-X and
|
||||
// JTDX may refuse outright.
|
||||
if e.trace != nil && !callable(*seen, p.MyCall) {
|
||||
e.trace("period %s %s is working %s — carrying on calling", p.Key, tc, addressee(seen.Msg))
|
||||
}
|
||||
return Action{}
|
||||
}
|
||||
@@ -857,7 +872,7 @@ func (e *Engine) pick(p Period, why string) Action {
|
||||
e.txSlot = slotOf(p.At, periodSecs(p, *best))
|
||||
e.answered = false
|
||||
e.waiting = ""
|
||||
reason := fmt.Sprintf("calling %s (%s%s)", best.Call, watchedTag(*best), best.Need)
|
||||
reason := fmt.Sprintf("calling %s (%s%s)", best.Call, watchedTag(*best), needLabel(*best))
|
||||
if why != "" {
|
||||
reason = why + " — " + reason
|
||||
}
|
||||
@@ -893,7 +908,7 @@ func (e *Engine) tracePick(p Period) {
|
||||
best := make([]string, 0, 3)
|
||||
for i := 0; i < len(ok) && i < 3; i++ {
|
||||
best = append(best, fmt.Sprintf("%s(%s%s,%d dB,r%d)",
|
||||
ok[i].c.Call, watchedTag(ok[i].c), ok[i].c.Need, ok[i].c.SNR, ok[i].r))
|
||||
ok[i].c.Call, watchedTag(ok[i].c), needLabel(ok[i].c), ok[i].c.SNR, ok[i].r))
|
||||
}
|
||||
why := make([]string, 0, len(refused))
|
||||
for _, k := range []string{"worked", "nothing-needed", "busy", "resting", "halted", "parked", "hidden", "not-chased", "replay", "self"} {
|
||||
@@ -1059,7 +1074,7 @@ func (e *Engine) preempt(p Period) (Action, bool) {
|
||||
e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
|
||||
return Action{Kind: DoReply, Decode: best.Decode,
|
||||
Reason: fmt.Sprintf("%s (%s%s) takes over from %s — nothing had been answered yet",
|
||||
best.Call, watchedTag(*best), best.Need, was)}, true
|
||||
best.Call, watchedTag(*best), needLabel(*best), was)}, true
|
||||
}
|
||||
|
||||
// drop lets the target go with no verdict attached: not worked, not given up
|
||||
|
||||
@@ -65,6 +65,9 @@ func TestLadderOrder(t *testing.T) {
|
||||
cq("I", NeedNone, 0, watched),
|
||||
cq("B", NeedDXCC, 0), cq("D", NeedBand, 0),
|
||||
cq("F", NeedMode, 0), cq("H", NeedSlot, 0),
|
||||
// The orthogonal markers sit at the foot of the ladder: worth a call
|
||||
// when nothing better is on the air, never worth leaving a band for.
|
||||
cq("J", NeedExtra, 0),
|
||||
}
|
||||
for i := 1; i < len(order); i++ {
|
||||
if rank(order[i-1]) <= rank(order[i]) {
|
||||
@@ -640,7 +643,7 @@ func TestTraceSaysWhyNobodyWasCalled(t *testing.T) {
|
||||
// And when it DOES call, the line names the candidates it ranked.
|
||||
lines = nil
|
||||
e.OnPeriod(period(2, cq("DX", NeedBand, -9, watched)))
|
||||
if len(lines) == 0 || !strings.Contains(lines[0], "DX(watched band,-9 dB,r114)") {
|
||||
if len(lines) == 0 || !strings.Contains(lines[0], "DX(watched band,-9 dB,r118)") {
|
||||
t.Errorf("trace %v does not describe the station it called", lines)
|
||||
}
|
||||
}
|
||||
@@ -678,34 +681,26 @@ func TestAStationCallingUsIsAnsweredEvenWithNothingToGain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestATargetThatStartsWorkingSomebodyElseIsDropped(t *testing.T) {
|
||||
// TestAStationWorkingSomebodyElseIsNotCHOSEN — the test above is about a target
|
||||
// already being called; this is about picking one. A reply to a decode in
|
||||
// mid-exchange is a reply WSJT-X and JTDX may refuse outright, so it never
|
||||
// starts a call there.
|
||||
func TestAStationWorkingSomebodyElseIsNotCHOSEN(t *testing.T) {
|
||||
e := on()
|
||||
// Picked on its CQ.
|
||||
if a := e.OnPeriod(period(0, cq("ON7GB", NeedSlot, +5))); a.Decode.Call != "ON7GB" {
|
||||
t.Fatalf("not called: %+v", a)
|
||||
}
|
||||
// Next period it is answering somebody else. Calling it is pointless: it is
|
||||
// committed, and the seven attempts would be spent transmitting at a
|
||||
// station that cannot hear them.
|
||||
busyNow := busy("ON7GB", NeedSlot, +5)
|
||||
busyNow.Msg = "PY2SAD ON7GB JO21"
|
||||
a := e.OnPeriod(period(2, busyNow))
|
||||
if a.Kind != DoHalt {
|
||||
t.Fatalf("kept calling a station in a QSO with someone else: %+v", a)
|
||||
}
|
||||
if !strings.Contains(a.Reason, "PY2SAD") {
|
||||
t.Errorf("reason %q does not name the station it is working", a.Reason)
|
||||
if a := e.OnPeriod(period(0, busyNow)); a.Kind == DoReply {
|
||||
t.Fatalf("called %+v — it is in a QSO with PY2SAD", a)
|
||||
}
|
||||
if e.Target() != "" {
|
||||
t.Error("the target was not released")
|
||||
t.Errorf("target is %q", e.Target())
|
||||
}
|
||||
// No verdict attached: the moment it CQs again it is fair game, with a full
|
||||
// allowance — it was never given up on.
|
||||
if a := e.OnPeriod(period(4, cq("ON7GB", NeedSlot, +5))); a.Kind != DoReply {
|
||||
t.Errorf("not called again once free: %+v", a)
|
||||
}
|
||||
if e.Status().Attempts != 0 {
|
||||
t.Errorf("attempts = %d on a fresh series, want 0", e.Status().Attempts)
|
||||
// Its final frame is different: one period from free is the best moment
|
||||
// there is to be calling it.
|
||||
last := busy("ON7GB", NeedSlot, +5)
|
||||
last.Msg = "PY2SAD ON7GB RR73"
|
||||
if a := e.OnPeriod(period(2, last)); a.Kind != DoReply {
|
||||
t.Errorf("%+v — a station on its last frame was not called", a)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -864,29 +859,39 @@ func TestAnExchangeInProgressIsNeverAbandoned(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSlotIsNotWastedWhenTheTargetTurnsOutToBeBusy(t *testing.T) {
|
||||
// TestAPileupIsWorkedByCallingThroughIt is the shack report: D44TWO finishing a
|
||||
// contact, the call started, the DX coming back to somebody else — and the
|
||||
// engine giving up on a station that was one period from being free.
|
||||
func TestAPileupIsWorkedByCallingThroughIt(t *testing.T) {
|
||||
e := on()
|
||||
if a := e.OnPeriod(period(0, cq("DX", NeedBand, -7))); a.Kind != DoReply {
|
||||
if a := e.OnPeriod(period(0, cq("D44TWO", NeedDXCC, -7))); a.Kind != DoReply {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
// It answers somebody else, and a station of the same value is calling CQ
|
||||
// in the very same period. Waiting for the next one throws away a slot.
|
||||
a := e.OnPeriod(period(2, busy("DX", NeedBand, -7), cq("ER1CW", NeedBand, -8)))
|
||||
if a.Kind != DoReply || a.Decode.Call != "ER1CW" {
|
||||
t.Fatalf("%+v — the freed slot was not used", a)
|
||||
// It answers three other callers in a row — which is what a DX with a queue
|
||||
// does, and calling through it is how the queue is joined.
|
||||
does := func(n int) Action { return e.OnPeriod(period(n, busy("D44TWO", NeedDXCC, -7))) }
|
||||
for _, n := range []int{2, 4, 6} {
|
||||
if a := does(n); a.Kind != DoNothing {
|
||||
t.Fatalf("%+v — stopped calling a station working the pileup", a)
|
||||
}
|
||||
if e.Target() != "D44TWO" {
|
||||
t.Fatalf("target is %q — the station was released while it worked others", e.Target())
|
||||
}
|
||||
}
|
||||
if !strings.Contains(a.Reason, "cannot answer") || !strings.Contains(a.Reason, "calling ER1CW") {
|
||||
t.Errorf("reason %q says neither what was left nor what was taken", a.Reason)
|
||||
// And when it comes to us, the exchange starts as usual.
|
||||
if a := e.OnPeriod(period(8, callsMe("D44TWO", NeedDXCC, -7))); a.Kind != DoNothing {
|
||||
t.Errorf("%+v", a)
|
||||
}
|
||||
if !e.answered {
|
||||
t.Error("the reply was not taken as the start of the exchange")
|
||||
}
|
||||
|
||||
// Mid-over, it still waits: cutting our own transmission in half is worse
|
||||
// than losing the slot.
|
||||
// A BETTER station may still take the slot while it is busy: nothing has
|
||||
// been answered, so nothing is lost by moving.
|
||||
e = on()
|
||||
e.OnPeriod(period(4, cq("DX", NeedBand, -7)))
|
||||
pp := period(6, busy("DX", NeedBand, -7), cq("ER1CW", NeedBand, -8))
|
||||
pp.TX = TXState{Transmitting: true}
|
||||
if a := e.OnPeriod(pp); a.Kind != DoHalt || !a.Soft {
|
||||
t.Errorf("%+v — replied over our own transmission", a)
|
||||
e.OnPeriod(period(10, cq("D44TWO", NeedSlot, -7)))
|
||||
if a := e.OnPeriod(period(12, busy("D44TWO", NeedSlot, -7), cq("RARE", NeedDXCC, -20, watched))); a.Kind != DoReply || a.Decode.Call != "RARE" {
|
||||
t.Errorf("%+v — a watched new one did not take the slot from a busy station", a)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -395,6 +395,47 @@ func titleCase(s string) string {
|
||||
// lives, and keeps the ones that say WHO they are — name, address, QSL route.
|
||||
// A portable operator's cards still go to the home address, so that address is
|
||||
// not wrong; their county is.
|
||||
// sameEntityName reports whether two country names denote the same entity.
|
||||
//
|
||||
// The two come from different vocabularies — the callbook writes "Germany"
|
||||
// where cty.dat writes "Fed. Rep. of Germany", "United States" where the ADIF
|
||||
// list says "United States of America" — so they are compared on their
|
||||
// significant words with the boilerplate of officialdom removed, and one
|
||||
// containing the other counts as a match.
|
||||
//
|
||||
// Deliberately generous. Getting it wrong in the strict direction discards a
|
||||
// correct grid, which is the fault this exists to fix; getting it wrong in the
|
||||
// generous direction keeps a location from a neighbouring entity, which is the
|
||||
// state of every callbook record that has no page for the portable call anyway.
|
||||
func sameEntityName(a, b string) bool {
|
||||
na, nb := entityKey(a), entityKey(b)
|
||||
if na == "" || nb == "" {
|
||||
return false
|
||||
}
|
||||
return na == nb || strings.Contains(na, nb) || strings.Contains(nb, na)
|
||||
}
|
||||
|
||||
var entityNoise = map[string]bool{
|
||||
"THE": true, "OF": true, "FED": true, "REP": true, "REPUBLIC": true,
|
||||
"FEDERAL": true, "FEDERATION": true, "DEM": true, "DEMOCRATIC": true,
|
||||
"STATE": true, "KINGDOM": true, "AMERICA": true,
|
||||
}
|
||||
|
||||
// entityKey reduces a country name to its significant letters.
|
||||
func entityKey(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
for _, tok := range strings.FieldsFunc(s, func(r rune) bool {
|
||||
return r == ' ' || r == '.' || r == ',' || r == '-'
|
||||
}) {
|
||||
if entityNoise[tok] {
|
||||
continue
|
||||
}
|
||||
b.WriteString(tok)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func clearHomeLocation(r *Result) {
|
||||
r.Country, r.Continent = "", ""
|
||||
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
|
||||
@@ -430,11 +471,23 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||
//
|
||||
// Same-entity portables (F4BPO/P, W2RE/2) are untouched: the entities match,
|
||||
// and there the home details ARE where the operator is.
|
||||
// UNLESS THE RECORD IS ABOUT THE OPERATION ITSELF.
|
||||
//
|
||||
// Some compound calls have a callbook page of their own, filed under the
|
||||
// slashed form and describing where the station actually is: QRZ's HP/WE9G
|
||||
// carries Altos del Maria, Panama, square EJ98xq. Clearing that threw away
|
||||
// the one field the lookup existed to find, and the QSO was logged with no
|
||||
// grid at all while the page plainly showed one.
|
||||
//
|
||||
// The record's OWN country is what tells the two apart: a page for the
|
||||
// operation names the entity being operated from, a home page names home.
|
||||
if dxccNum != 0 && strings.ContainsRune(r.Callsign, '/') && !saysNothingAboutLocation(r.Callsign) {
|
||||
if home := homeCall(r.Callsign); home != "" && home != r.Callsign {
|
||||
if homeNum, _, _, _, _, _, _, homeOK := dxcc.Resolve(home); homeOK && homeNum != 0 && homeNum != dxccNum {
|
||||
clearHomeLocation(r)
|
||||
filled = true
|
||||
if !sameEntityName(r.Country, country) {
|
||||
clearHomeLocation(r)
|
||||
filled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,3 +91,60 @@ func TestSameEntityPortableKeepsItsLocation(t *testing.T) {
|
||||
t.Errorf("lat/lon = %v/%v — the precise home position was replaced by the entity centroid", r.Lat, r.Lon)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half again, and the one an operator reported: a compound call with
|
||||
// a callbook page OF ITS OWN. QRZ files HP/WE9G under that exact form, with the
|
||||
// Panama address and square the station is actually operating from — and the
|
||||
// QSO was being logged with no grid at all while the page plainly showed one.
|
||||
func TestACompoundCallWithItsOwnPageKeepsThatPagesLocation(t *testing.T) {
|
||||
dxcc := testDXCC()
|
||||
dxcc["HP/WE9G"] = struct {
|
||||
num int
|
||||
country string
|
||||
cont string
|
||||
cqz, ituz int
|
||||
lat, lon float64
|
||||
}{num: 88, country: "Panama", cont: "NA", cqz: 7, ituz: 11, lat: 8.5, lon: -80.0}
|
||||
dxcc["WE9G"] = struct {
|
||||
num int
|
||||
country string
|
||||
cont string
|
||||
cqz, ituz int
|
||||
lat, lon float64
|
||||
}{num: 291, country: "United States", cont: "NA", cqz: 5, ituz: 8, lat: 39.8, lon: -98.5}
|
||||
|
||||
r := Result{
|
||||
Callsign: "HP/WE9G",
|
||||
Name: "Richard B",
|
||||
Country: "Panama", // the RECORD's own country: this page is the operation
|
||||
Grid: "EJ98xq",
|
||||
Lat: 8.686667, Lon: -80.043333,
|
||||
}
|
||||
fillFromDXCC(&r, dxcc)
|
||||
|
||||
if r.Grid != "EJ98xq" {
|
||||
t.Errorf("grid = %q, want EJ98xq — the page describes the operation, not a home address", r.Grid)
|
||||
}
|
||||
if r.Lat != 8.686667 || r.Lon != -80.043333 {
|
||||
t.Errorf("lat/lon = %v/%v — the station's own position was replaced by the entity centroid", r.Lat, r.Lon)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameEntityNameAcrossVocabularies(t *testing.T) {
|
||||
same := [][2]string{
|
||||
{"Germany", "Fed. Rep. of Germany"},
|
||||
{"United States", "United States of America"},
|
||||
{"Panama", "Panama"},
|
||||
{"Kosovo", "Republic of Kosovo"},
|
||||
}
|
||||
for _, p := range same {
|
||||
if !sameEntityName(p[0], p[1]) {
|
||||
t.Errorf("%q and %q read as different entities", p[0], p[1])
|
||||
}
|
||||
}
|
||||
for _, p := range [][2]string{{"Costa Rica", "United States"}, {"France", "Belgium"}, {"", "Panama"}} {
|
||||
if sameEntityName(p[0], p[1]) {
|
||||
t.Errorf("%q and %q read as the same entity", p[0], p[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user