fix(autocall): the entity's verdict is not the station's
Six faults, all found on the air this evening and all in the same
feature. The decision trace added here is what found the first one:
one line per period, saying what was on the air and why each station
was refused.
- "worked" was read from the ENTITY's status, which means the country
is in the log on this band and mode. On 10 m, where most countries
are, twenty decodes out of twenty-one were refused as worked — the
watched DXpedition among them. The entity decides what is NEEDED;
the callsign's own slot decides whether calling it is a duplicate.
candidateOf() is split out and tested because one line was wrong for
weeks and nothing could catch it.
- A multi-answer line was read only up to its first message. MSHV
answers two stations in one transmission ("YV5ALI RR73; F4BPO
<HK0/PY8WW> -08") and the second half was a report to us: the engine
saw a station working somebody else and dropped the target at the
moment the DX was answering. The decodes panel already read every
segment.
- The slot after a QSO belongs to our own 73. Handing straight on to
the next station took it, switching the DX call mid-sequence, and
the frame that closes the contact never went out whole.
- A station CALLING US is answered whether or not the log wants
anything from it. It was refused for having nothing to gain, so a
QSO would end, two stations would call, and both were ignored.
- Callability was tested when a station was CHOSEN and never again
while it was held: one picked on its CQ that then answered another
caller went on being called for the whole seven attempts.
- A watched callsign now outranks every station that is not on the
list. Lifted one rung at a time it sat at the bottom with nothing
needed from it, and was never reached on a busy band — the opposite
of what putting it on the list means.
Halt is now a verdict: the station is set aside for the session rather
than released, and the chase list does not override it. The attempts
cap lets the over finish instead of cutting the call that counted it,
and a rest is a rest. Only what the decodes list is SHOWING can be
called — the panel publishes the callsigns it shows, so there is one
definition of "shown" and not two.
This commit is contained in:
+138
-17
@@ -31,6 +31,8 @@ const (
|
|||||||
keyAutoCallMisses = "autocall.misses"
|
keyAutoCallMisses = "autocall.misses"
|
||||||
keyAutoCallRounds = "autocall.max_rounds"
|
keyAutoCallRounds = "autocall.max_rounds"
|
||||||
keyAutoCallRestMin = "autocall.rest_min"
|
keyAutoCallRestMin = "autocall.rest_min"
|
||||||
|
keyAutoCallOnScreen = "autocall.on_screen_only"
|
||||||
|
keyAutoCallTrace = "autocall.trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AutoCallSettings is the panel's shape. Durations are in minutes because that
|
// AutoCallSettings is the panel's shape. Durations are in minutes because that
|
||||||
@@ -49,6 +51,14 @@ type AutoCallSettings struct {
|
|||||||
// RestMin the pause between two of them.
|
// RestMin the pause between two of them.
|
||||||
MaxRounds int `json:"max_rounds"`
|
MaxRounds int `json:"max_rounds"`
|
||||||
RestMin int `json:"rest_min"`
|
RestMin int `json:"rest_min"`
|
||||||
|
// OnScreenOnly: call only what the decodes panel is showing, so its filters
|
||||||
|
// steer the transmitter as well as the eye.
|
||||||
|
OnScreenOnly bool `json:"on_screen_only"`
|
||||||
|
// Trace writes one line per period to the log: what was on the air, why
|
||||||
|
// each station was refused, and what was decided. For diagnosing "it is not
|
||||||
|
// calling anything" — and it is a line every fifteen seconds, so it is off
|
||||||
|
// unless asked for.
|
||||||
|
Trace bool `json:"trace"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetAutoCallSettings() AutoCallSettings {
|
func (a *App) GetAutoCallSettings() AutoCallSettings {
|
||||||
@@ -66,6 +76,10 @@ func (a *App) GetAutoCallSettings() AutoCallSettings {
|
|||||||
Only: strings.ToUpper(strings.TrimSpace(a.settingOr(keyAutoCallOnly, ""))),
|
Only: strings.ToUpper(strings.TrimSpace(a.settingOr(keyAutoCallOnly, ""))),
|
||||||
Attempts: num(keyAutoCallAttempts, d.Attempts),
|
Attempts: num(keyAutoCallAttempts, d.Attempts),
|
||||||
WatchedAttempts: num(keyAutoCallWatched, d.WatchedAttempts),
|
WatchedAttempts: num(keyAutoCallWatched, d.WatchedAttempts),
|
||||||
|
// On by default: the filters are in front of the operator, and a station
|
||||||
|
// they have hidden is one they have said they do not want.
|
||||||
|
OnScreenOnly: a.settingOr(keyAutoCallOnScreen, "1") == "1",
|
||||||
|
Trace: a.settingOr(keyAutoCallTrace, "0") == "1",
|
||||||
Misses: num(keyAutoCallMisses, d.Misses),
|
Misses: num(keyAutoCallMisses, d.Misses),
|
||||||
MaxRounds: num(keyAutoCallRounds, d.MaxRounds),
|
MaxRounds: num(keyAutoCallRounds, d.MaxRounds),
|
||||||
RestMin: num(keyAutoCallRestMin, int(d.Rest/time.Minute)),
|
RestMin: num(keyAutoCallRestMin, int(d.Rest/time.Minute)),
|
||||||
@@ -75,6 +89,8 @@ func (a *App) GetAutoCallSettings() AutoCallSettings {
|
|||||||
func (a *App) SaveAutoCallSettings(s AutoCallSettings) error {
|
func (a *App) SaveAutoCallSettings(s AutoCallSettings) error {
|
||||||
a.setSetting(keyAutoCallOn, map[bool]string{true: "1", false: "0"}[s.Enabled])
|
a.setSetting(keyAutoCallOn, map[bool]string{true: "1", false: "0"}[s.Enabled])
|
||||||
a.setSetting(keyAutoCallOnly, strings.ToUpper(strings.TrimSpace(s.Only)))
|
a.setSetting(keyAutoCallOnly, strings.ToUpper(strings.TrimSpace(s.Only)))
|
||||||
|
a.setSetting(keyAutoCallOnScreen, map[bool]string{true: "1", false: "0"}[s.OnScreenOnly])
|
||||||
|
a.setSetting(keyAutoCallTrace, map[bool]string{true: "1", false: "0"}[s.Trace])
|
||||||
for key, v := range map[string]int{
|
for key, v := range map[string]int{
|
||||||
keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts,
|
keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts,
|
||||||
keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds,
|
keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds,
|
||||||
@@ -121,7 +137,7 @@ func (a *App) autoCallEngine() *autocall.Engine {
|
|||||||
func (a *App) autoCallSettings() autocall.Settings {
|
func (a *App) autoCallSettings() autocall.Settings {
|
||||||
s := a.GetAutoCallSettings()
|
s := a.GetAutoCallSettings()
|
||||||
return autocall.Settings{
|
return autocall.Settings{
|
||||||
Enabled: s.Enabled, Only: s.Only,
|
Enabled: s.Enabled, Only: s.Only, OnScreenOnly: s.OnScreenOnly,
|
||||||
Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts,
|
Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts,
|
||||||
Misses: s.Misses, MaxRounds: s.MaxRounds,
|
Misses: s.Misses, MaxRounds: s.MaxRounds,
|
||||||
Rest: time.Duration(s.RestMin) * time.Minute,
|
Rest: time.Duration(s.RestMin) * time.Minute,
|
||||||
@@ -135,6 +151,11 @@ func (a *App) applyAutoCall() {
|
|||||||
e := a.autoCallEngine()
|
e := a.autoCallEngine()
|
||||||
s := a.autoCallSettings()
|
s := a.autoCallSettings()
|
||||||
e.SetSettings(s)
|
e.SetSettings(s)
|
||||||
|
if a.GetAutoCallSettings().Trace {
|
||||||
|
e.SetTrace(func(f string, args ...any) { applog.Printf("autocall: "+f, args...) })
|
||||||
|
} else {
|
||||||
|
e.SetTrace(nil)
|
||||||
|
}
|
||||||
if !s.Enabled {
|
if !s.Enabled {
|
||||||
e.Reset()
|
e.Reset()
|
||||||
a.acMu.Lock()
|
a.acMu.Lock()
|
||||||
@@ -144,13 +165,30 @@ func (a *App) applyAutoCall() {
|
|||||||
a.emitAutoCall()
|
a.emitAutoCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetAutoCall is the operator's restart after it gave up — and what Halt
|
// ResetAutoCall is the operator's restart after the engine gave up on an
|
||||||
// does, since halting means "not this station".
|
// explicit target: it clears every verdict, including the grey list.
|
||||||
func (a *App) ResetAutoCall() {
|
func (a *App) ResetAutoCall() {
|
||||||
a.autoCallEngine().Reset()
|
a.autoCallEngine().Reset()
|
||||||
a.emitAutoCall()
|
a.emitAutoCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HaltAutoCall is the Halt button while a call is in progress.
|
||||||
|
//
|
||||||
|
// It does NOT clear the engine's state, which is what Halt used to do: that
|
||||||
|
// wiped the rests and the rounds along with everything else, so the station the
|
||||||
|
// operator had just stopped was eligible again in the same second and the next
|
||||||
|
// period called it straight back.
|
||||||
|
func (a *App) HaltAutoCall() {
|
||||||
|
call := a.autoCallEngine().Halt()
|
||||||
|
if call != "" {
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acReason = fmt.Sprintf("%s stopped by the operator — set aside until auto-call is switched off and on", call)
|
||||||
|
a.acMu.Unlock()
|
||||||
|
applog.Printf("autocall: %s", a.acReason)
|
||||||
|
}
|
||||||
|
a.emitAutoCall()
|
||||||
|
}
|
||||||
|
|
||||||
// AutoCallStatus is what the toolbar shows.
|
// AutoCallStatus is what the toolbar shows.
|
||||||
type AutoCallStatus struct {
|
type AutoCallStatus struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
@@ -164,6 +202,9 @@ type AutoCallStatus struct {
|
|||||||
Misses int `json:"misses"`
|
Misses int `json:"misses"`
|
||||||
MaxMiss int `json:"max_miss"`
|
MaxMiss int `json:"max_miss"`
|
||||||
Stopped bool `json:"stopped"`
|
Stopped bool `json:"stopped"`
|
||||||
|
// Greylisted counts the stations the operator has stopped this session, so
|
||||||
|
// the toolbar can say why a station on the air is never called.
|
||||||
|
Greylisted int `json:"greylisted"`
|
||||||
// Reason is the last decision in plain words. An auto-call that is doing
|
// Reason is the last decision in plain words. An auto-call that is doing
|
||||||
// nothing on purpose looks exactly like one that is broken.
|
// nothing on purpose looks exactly like one that is broken.
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
@@ -179,6 +220,7 @@ func (a *App) GetAutoCallStatus() AutoCallStatus {
|
|||||||
Enabled: set.Enabled, Only: set.Only,
|
Enabled: set.Enabled, Only: set.Only,
|
||||||
Target: st.Target, Calls: st.Attempts, Max: st.Max,
|
Target: st.Target, Calls: st.Attempts, Max: st.Max,
|
||||||
Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped,
|
Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped,
|
||||||
|
Greylisted: a.autoCallEngine().Greylisted(),
|
||||||
Reason: reason,
|
Reason: reason,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,6 +276,7 @@ func (a *App) autoCallFeed(d autocall.Decode) {
|
|||||||
a.acMu.Lock()
|
a.acMu.Lock()
|
||||||
if a.acPeriod == nil {
|
if a.acPeriod == nil {
|
||||||
a.acPeriod, a.acAt, a.acTR, a.acBuf = map[string]string{}, map[string]time.Time{}, map[string]int{}, map[string][]acDecode{}
|
a.acPeriod, a.acAt, a.acTR, a.acBuf = map[string]string{}, map[string]time.Time{}, map[string]int{}, map[string][]acDecode{}
|
||||||
|
a.acFed = map[string]time.Time{}
|
||||||
}
|
}
|
||||||
if prev := a.acPeriod[inst]; prev != "" && prev != key {
|
if prev := a.acPeriod[inst]; prev != "" && prev != key {
|
||||||
prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst]
|
prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst]
|
||||||
@@ -244,10 +287,29 @@ func (a *App) autoCallFeed(d autocall.Decode) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
a.acPeriod[inst], a.acAt[inst], a.acTR[inst] = key, d.At, d.TRPeriod
|
||||||
|
a.acFed[inst] = time.Now()
|
||||||
a.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d})
|
a.acBuf[inst] = append(a.acBuf[inst], acDecode{d: d})
|
||||||
a.acMu.Unlock()
|
a.acMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// acQuiet is how long a period is left open after its LAST decode arrives.
|
||||||
|
//
|
||||||
|
// This is the whole timing budget of the feature. A decoder finishes a period
|
||||||
|
// and sends its decodes about a second before the next slot opens, so the
|
||||||
|
// answer has to be back before that boundary — a reply that arrives after it
|
||||||
|
// makes the decoder start its call several seconds into the slot, which is what
|
||||||
|
// an operator sees as "it calls late" and what a station on the other end sees
|
||||||
|
// as a message it cannot decode.
|
||||||
|
//
|
||||||
|
// It was a whole slot plus four seconds, measured from the DECODE'S OWN
|
||||||
|
// TIMESTAMP — the start of the period, not the moment it arrived — so the
|
||||||
|
// answer left about four seconds INTO the next slot, every time.
|
||||||
|
//
|
||||||
|
// 800 ms: long enough for a busy period's decodes to arrive together (measured
|
||||||
|
// in bursts of a few hundred milliseconds), short enough to answer inside the
|
||||||
|
// same second they landed.
|
||||||
|
const acQuiet = 800 * time.Millisecond
|
||||||
|
|
||||||
// autoCallSweep closes the periods nothing has closed for us. Called on a timer.
|
// autoCallSweep closes the periods nothing has closed for us. Called on a timer.
|
||||||
//
|
//
|
||||||
// Per receiver, because with two decoders one may fall silent while the other
|
// Per receiver, because with two decoders one may fall silent while the other
|
||||||
@@ -273,10 +335,11 @@ func (a *App) autoCallSweep() {
|
|||||||
if tr <= 0 {
|
if tr <= 0 {
|
||||||
tr = 15
|
tr = 15
|
||||||
}
|
}
|
||||||
// One slot plus a margin: decodes for a period keep arriving for a
|
// Measured from when the last decode ARRIVED, not from the period it
|
||||||
// second or two after it ends, and judging early would count a station
|
// belongs to: a decode is stamped with the start of its own slot, so
|
||||||
// as missing that is about to be listed.
|
// waiting "a slot plus four seconds" from that stamp is waiting until
|
||||||
if time.Since(a.acAt[inst]) < time.Duration(tr)*time.Second+4*time.Second {
|
// the middle of the NEXT slot. See acQuiet.
|
||||||
|
if time.Since(a.acFed[inst]) < acQuiet {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
|
ready = append(ready, due{inst, key, a.acAt[inst], tr, a.acBuf[inst]})
|
||||||
@@ -354,12 +417,10 @@ func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDeco
|
|||||||
cands := make([]autocall.Candidate, 0, len(uniq))
|
cands := make([]autocall.Candidate, 0, len(uniq))
|
||||||
for _, dd := range uniq {
|
for _, dd := range uniq {
|
||||||
st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode]
|
st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode]
|
||||||
cands = append(cands, autocall.Candidate{
|
c := candidateOf(dd.d, st)
|
||||||
Decode: dd.d,
|
c.Watched = a.autoCallWatched(dd.d.Call)
|
||||||
Need: autoCallNeedOf(st.Status),
|
c.Hidden = a.autoCallHidden(dd.d.Call)
|
||||||
Watched: a.autoCallWatched(dd.d.Call),
|
cands = append(cands, c)
|
||||||
Worked: st.Status == "worked",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tx := a.autoCallTX()
|
tx := a.autoCallTX()
|
||||||
@@ -370,6 +431,30 @@ func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDeco
|
|||||||
a.autoCallDo(act)
|
a.autoCallDo(act)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// candidateOf turns one decode and the log's verdict on it into a candidate.
|
||||||
|
//
|
||||||
|
// Split out and kept pure because ONE line of it was wrong for weeks and
|
||||||
|
// nothing could catch it: the entity's verdict was read as the station's.
|
||||||
|
func candidateOf(d autocall.Decode, st SpotStatus) autocall.Candidate {
|
||||||
|
return autocall.Candidate{
|
||||||
|
Decode: d,
|
||||||
|
// The ENTITY's verdict decides what is still needed…
|
||||||
|
Need: autoCallNeedOf(st.Status),
|
||||||
|
// …and THIS CALLSIGN on this band and mode decides whether calling it
|
||||||
|
// would be a duplicate.
|
||||||
|
//
|
||||||
|
// Status was used for both. "worked" there means the COUNTRY is in the
|
||||||
|
// log on this band and mode, so on a band where the operator has most of
|
||||||
|
// them, nearly every station on the air was refused as already worked —
|
||||||
|
// a trace of one evening shows twenty decodes out of twenty-one turned
|
||||||
|
// away that way, the watched DXpedition among them.
|
||||||
|
Worked: st.WorkedSlot,
|
||||||
|
// The need exists only because a QSL never came: worth chasing, and worth
|
||||||
|
// less than the same need never worked at all.
|
||||||
|
Unconfirmed: st.UnconfStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// autoCallDo carries out a decision and records it.
|
// autoCallDo carries out a decision and records it.
|
||||||
func (a *App) autoCallDo(act autocall.Action) {
|
func (a *App) autoCallDo(act autocall.Action) {
|
||||||
if act.Reason != "" {
|
if act.Reason != "" {
|
||||||
@@ -385,9 +470,9 @@ func (a *App) autoCallDo(act autocall.Action) {
|
|||||||
applog.Printf("autocall: the call to %s could not be sent: %v", d.Call, err)
|
applog.Printf("autocall: the call to %s could not be sent: %v", d.Call, err)
|
||||||
}
|
}
|
||||||
case autocall.DoHalt:
|
case autocall.DoHalt:
|
||||||
// autoTxOnly=false: stop now. The whole point of a brake is that it does
|
// Soft: let the over finish, then stop transmitting. Hard: stop now.
|
||||||
// not wait for the over in progress to finish.
|
// The engine decides — see Action.Soft.
|
||||||
if err := a.HaltDecodeTx("", false); err != nil {
|
if err := a.HaltDecodeTx("", act.Soft); err != nil {
|
||||||
applog.Printf("autocall: halt failed: %v", err)
|
applog.Printf("autocall: halt failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,6 +518,38 @@ func (a *App) autoCallSetTX(tx autocall.TXState) {
|
|||||||
a.acMu.Unlock()
|
a.acMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetAutoCallVisible is the decodes panel saying what it is SHOWING.
|
||||||
|
//
|
||||||
|
// The panel owns the filters and therefore owns the answer: reimplementing them
|
||||||
|
// here would give the screen and the transmitter two definitions of the same
|
||||||
|
// word, which is how they end up disagreeing. It sends the callsigns that
|
||||||
|
// survive its filters, and the engine calls nothing else.
|
||||||
|
//
|
||||||
|
// An empty list with active=false means "no filtering in force" — the panel was
|
||||||
|
// closed, or has never been opened this session — and the ladder decides alone.
|
||||||
|
func (a *App) SetAutoCallVisible(calls []string, active bool) {
|
||||||
|
set := make(map[string]bool, len(calls))
|
||||||
|
for _, c := range calls {
|
||||||
|
if c = strings.ToUpper(strings.TrimSpace(c)); c != "" {
|
||||||
|
set[c] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.acMu.Lock()
|
||||||
|
a.acVisible, a.acVisibleOn = set, active
|
||||||
|
a.acMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCallHidden reports whether the panel's filters are keeping a station off
|
||||||
|
// the screen. Unknown when nothing is being published: not hidden.
|
||||||
|
func (a *App) autoCallHidden(call string) bool {
|
||||||
|
a.acMu.Lock()
|
||||||
|
defer a.acMu.Unlock()
|
||||||
|
if !a.acVisibleOn {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !a.acVisible[strings.ToUpper(strings.TrimSpace(call))]
|
||||||
|
}
|
||||||
|
|
||||||
// autoCallNeedOf maps the cluster's own status vocabulary onto the ladder. One
|
// autoCallNeedOf maps the cluster's own status vocabulary onto the ladder. One
|
||||||
// vocabulary for both, so a station that reads NEW BAND in the decodes list is
|
// vocabulary for both, so a station that reads NEW BAND in the decodes list is
|
||||||
// the same NEW BAND the auto-call ranks — two answers to one question is how
|
// the same NEW BAND the auto-call ranks — two answers to one question is how
|
||||||
@@ -482,7 +599,11 @@ func (a *App) startAutoCall() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) autoCallLoop() {
|
func (a *App) autoCallLoop() {
|
||||||
t := time.NewTicker(2 * time.Second)
|
// A quarter of a second. The sweeper is what closes a period, so its tick is
|
||||||
|
// part of the same budget as acQuiet: a two-second tick added up to two
|
||||||
|
// seconds of its own to every answer, which is most of the margin there is.
|
||||||
|
// The work per tick is a map read.
|
||||||
|
t := time.NewTicker(250 * time.Millisecond)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for range t.C {
|
for range t.C {
|
||||||
if a.ctx == nil {
|
if a.ctx == nil {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hamlog/internal/autocall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The entity's verdict is not the station's.
|
||||||
|
//
|
||||||
|
// From the air: on 10 m, where most countries are already in the log, the
|
||||||
|
// engine refused twenty decodes out of twenty-one as "worked" — a watched
|
||||||
|
// DXpedition calling CQ among them — because the ENTITY's status was read as
|
||||||
|
// the station's.
|
||||||
|
func TestCandidateWorkedIsTheCallsignNotTheEntity(t *testing.T) {
|
||||||
|
d := autocall.Decode{Call: "J38DX", Band: "10m", Mode: "FT8", IsNew: true}
|
||||||
|
|
||||||
|
// Grenada worked on 10 m FT8, this callsign never worked: nothing is needed
|
||||||
|
// from it, and calling it is NOT a duplicate.
|
||||||
|
c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: false})
|
||||||
|
if c.Worked {
|
||||||
|
t.Error("a station never worked was refused because its entity was")
|
||||||
|
}
|
||||||
|
if c.Need != autocall.NeedNone {
|
||||||
|
t.Errorf("need = %v on a worked entity, want none", c.Need)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same callsign already in the log on this band and mode IS a duplicate.
|
||||||
|
if c := candidateOf(d, SpotStatus{Status: "worked", WorkedSlot: true}); !c.Worked {
|
||||||
|
t.Error("a callsign already worked on this band and mode was not flagged")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a real need still carries through, with the unconfirmed distinction.
|
||||||
|
c = candidateOf(d, SpotStatus{Status: "new-band", UnconfStatus: true})
|
||||||
|
if c.Need != autocall.NeedBand || !c.Unconfirmed {
|
||||||
|
t.Errorf("new-band unconfirmed came through as %v (unconf=%v)", c.Need, c.Unconfirmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
+356
-60
@@ -47,6 +47,7 @@ package autocall
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
@@ -109,6 +110,14 @@ type Candidate struct {
|
|||||||
Decode
|
Decode
|
||||||
Need Need
|
Need Need
|
||||||
Watched bool
|
Watched bool
|
||||||
|
// 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.
|
||||||
|
Unconfirmed bool
|
||||||
|
// Hidden is true when the decodes panel's own filters are keeping this
|
||||||
|
// station off the screen. The operator's filters are the control: what is
|
||||||
|
// not shown is not called — see Settings.OnScreenOnly.
|
||||||
|
Hidden bool
|
||||||
// Worked is "already in the log on this band AND mode". Nothing is gained
|
// Worked is "already in the log on this band AND mode". Nothing is gained
|
||||||
// by calling it again — and while chasing confirmations it is the trap that
|
// by calling it again — and while chasing confirmations it is the trap that
|
||||||
// makes the same station be called all evening, because an unconfirmed QSO
|
// makes the same station be called all evening, because an unconfirmed QSO
|
||||||
@@ -152,6 +161,19 @@ type Settings struct {
|
|||||||
MaxRounds int
|
MaxRounds int
|
||||||
// MaxHold is the wall-clock backstop on one target.
|
// MaxHold is the wall-clock backstop on one target.
|
||||||
MaxHold time.Duration
|
MaxHold time.Duration
|
||||||
|
// OnScreenOnly restricts the calling to what the decodes panel is actually
|
||||||
|
// showing.
|
||||||
|
//
|
||||||
|
// The filters an operator sets while reading the band — CQ only, LoTW only,
|
||||||
|
// the new-category chips, continents, a minimum report, the search box — now
|
||||||
|
// bind the transmitter too: a station filtered off the screen is not called.
|
||||||
|
// It replaces a separate "LoTW users only" setting, which said one thing
|
||||||
|
// while the chip above the list said another.
|
||||||
|
//
|
||||||
|
// The panel publishes what it is showing; when it is publishing nothing —
|
||||||
|
// the tab was never opened this session — there is nothing to restrict and
|
||||||
|
// the ladder decides alone.
|
||||||
|
OnScreenOnly bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults are what the operator gets before touching anything.
|
// Defaults are what the operator gets before touching anything.
|
||||||
@@ -201,6 +223,19 @@ type Action struct {
|
|||||||
Kind ActionKind
|
Kind ActionKind
|
||||||
Decode Decode
|
Decode Decode
|
||||||
Reason string
|
Reason string
|
||||||
|
// Soft asks the decoder to finish the over it is sending and only then stop
|
||||||
|
// transmitting, rather than cutting the carrier where it stands.
|
||||||
|
//
|
||||||
|
// It matters for exactly one brake. The attempts cap trips WHILE the seventh
|
||||||
|
// call is going out — that is what counts it — so a hard halt cuts that
|
||||||
|
// transmission a second in, which on the air is a half-sent call and on the
|
||||||
|
// screen is an auto-call that "starts and stops". Nothing is saved by
|
||||||
|
// stopping it: the frame is already half gone.
|
||||||
|
//
|
||||||
|
// Every other brake fires on a station that is not being transmitted to (it
|
||||||
|
// has vanished, or the clock ran out between overs), and there a hard halt
|
||||||
|
// is right: it is the one that also clears the decoder's own auto-sequence.
|
||||||
|
Soft bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Engine holds the state between periods. Not safe for concurrent use; the
|
// Engine holds the state between periods. Not safe for concurrent use; the
|
||||||
@@ -214,6 +249,11 @@ type Engine struct {
|
|||||||
// targetInst is the receiver the target was picked on, so the brakes are
|
// targetInst is the receiver the target was picked on, so the brakes are
|
||||||
// counted against ITS periods and its transmissions.
|
// counted against ITS periods and its transmissions.
|
||||||
targetInst string
|
targetInst string
|
||||||
|
// now is the time of the last period seen. Every deadline in here is measured
|
||||||
|
// against it rather than against the wall clock: the periods ARE the clock —
|
||||||
|
// they arrive stamped — and mixing the two gave a rest that expired against
|
||||||
|
// one reading and a hold that ran against another.
|
||||||
|
now time.Time
|
||||||
// heldSince is when this station BECAME the target, and is never refreshed
|
// heldSince is when this station BECAME the target, and is never refreshed
|
||||||
// while it stays one. It was, and that quietly disabled the clock backstop:
|
// while it stays one. It was, and that quietly disabled the clock backstop:
|
||||||
// a station decoded every period for ever kept pushing its own deadline
|
// a station decoded every period for ever kept pushing its own deadline
|
||||||
@@ -230,6 +270,20 @@ type Engine struct {
|
|||||||
// how many series it has already had this session.
|
// how many series it has already had this session.
|
||||||
rested map[string]time.Time
|
rested map[string]time.Time
|
||||||
rounds map[string]int
|
rounds map[string]int
|
||||||
|
// trace, when set, receives one line per period: what was on the air, why
|
||||||
|
// each station was refused, and what was decided. Off unless the operator
|
||||||
|
// asks for it — a line per period is a line every fifteen seconds, all
|
||||||
|
// night.
|
||||||
|
trace func(string, ...any)
|
||||||
|
// greyed is every station the OPERATOR stopped a call to.
|
||||||
|
//
|
||||||
|
// Halt is a verdict, not a pause: the operator watched the engine call this
|
||||||
|
// station and said no. Answering that by releasing the target and picking
|
||||||
|
// the same station again the next period — which is what clearing the state
|
||||||
|
// did — is the machine overruling them, and it is what Halt exists to
|
||||||
|
// prevent. It holds until auto-call is switched off and on again, which is
|
||||||
|
// the one gesture that plainly means "start over".
|
||||||
|
greyed map[string]bool
|
||||||
// done is every station the exchange was completed with this session.
|
// done is every station the exchange was completed with this session.
|
||||||
//
|
//
|
||||||
// The log is the authority on what is worked, and it is SLOW: the QSO is
|
// The log is the authority on what is worked, and it is SLOW: the QSO is
|
||||||
@@ -242,7 +296,8 @@ type Engine struct {
|
|||||||
|
|
||||||
func New(s Settings) *Engine {
|
func New(s Settings) *Engine {
|
||||||
return &Engine{set: s.withDefaults(), txSlot: -1,
|
return &Engine{set: s.withDefaults(), txSlot: -1,
|
||||||
rested: map[string]time.Time{}, rounds: map[string]int{}, done: map[string]bool{}}
|
rested: map[string]time.Time{}, rounds: map[string]int{}, done: map[string]bool{},
|
||||||
|
greyed: map[string]bool{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSettings swaps the settings in place. A target already being called is
|
// SetSettings swaps the settings in place. A target already being called is
|
||||||
@@ -287,9 +342,38 @@ func (e *Engine) Reset() {
|
|||||||
e.targetInst = ""
|
e.targetInst = ""
|
||||||
e.lastMiss, e.stopped, e.stoppedOn = "", false, ""
|
e.lastMiss, e.stopped, e.stoppedOn = "", false, ""
|
||||||
e.rested, e.rounds = map[string]time.Time{}, map[string]int{}
|
e.rested, e.rounds = map[string]time.Time{}, map[string]int{}
|
||||||
e.done = map[string]bool{}
|
e.done, e.greyed = map[string]bool{}, map[string]bool{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Halt is the operator stopping the call in progress.
|
||||||
|
//
|
||||||
|
// The station is set aside for the session — see greyed — and the target is
|
||||||
|
// released. Returns the callsign so the caller can say what happened; empty
|
||||||
|
// when nothing was being called, where Halt is just a halt.
|
||||||
|
func (e *Engine) Halt() string {
|
||||||
|
if e.target == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
call := strings.ToUpper(e.target.Call)
|
||||||
|
e.greyed[call] = true
|
||||||
|
e.release(call, true)
|
||||||
|
// Not counted as a series: the brakes are about a station that will not
|
||||||
|
// answer, and this one was never given the chance. It will not be called
|
||||||
|
// again anyway.
|
||||||
|
e.rounds[call]--
|
||||||
|
if e.rounds[call] < 0 {
|
||||||
|
e.rounds[call] = 0
|
||||||
|
}
|
||||||
|
delete(e.rested, call)
|
||||||
|
return call
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTrace turns the per-period trace on (a non-nil function) or off (nil).
|
||||||
|
func (e *Engine) SetTrace(fn func(string, ...any)) { e.trace = fn }
|
||||||
|
|
||||||
|
// Greylisted reports how many stations the operator has stopped this session.
|
||||||
|
func (e *Engine) Greylisted() int { return len(e.greyed) }
|
||||||
|
|
||||||
// Take hands the operator's own click to the engine: the station they picked
|
// Take hands the operator's own click to the engine: the station they picked
|
||||||
// becomes the target, with the counters restarted. Everything after it — the
|
// becomes the target, with the counters restarted. Everything after it — the
|
||||||
// brakes, the hand-off at the end of the QSO — then applies as usual.
|
// brakes, the hand-off at the end of the QSO — then applies as usual.
|
||||||
@@ -309,23 +393,37 @@ func (e *Engine) maxAttempts(c Candidate) int {
|
|||||||
return e.set.Attempts
|
return e.set.Attempts
|
||||||
}
|
}
|
||||||
|
|
||||||
// rank is the ladder, as one number. Watched lifts a station by one rung, and
|
// rank is the ladder, as one number.
|
||||||
// a watched station with nothing needed sits at the bottom rather than being
|
|
||||||
// ineligible: the operator asked for that callsign.
|
|
||||||
//
|
//
|
||||||
// 9 WL DXCC 8 DXCC 7 WL band 6 band
|
// A WATCHED callsign outranks everything that is not watched. That is what a
|
||||||
// 5 WL mode 4 mode 3 WL slot 2 slot 1 watched, nothing needed
|
// watch list means: the operator has named this station, and the machine's
|
||||||
|
// opinion of what the log needs does not get to overrule it.
|
||||||
|
//
|
||||||
|
// It did before, one rung at a time — watched new band above plain new band,
|
||||||
|
// but a watched station with nothing needed at the very bottom. On a band full
|
||||||
|
// of stations that ARE needed, that station was never reached: a watched
|
||||||
|
// DXpedition sat there all evening while the engine worked Brazilians. The
|
||||||
|
// list is not a tie-breaker, it is the answer.
|
||||||
|
//
|
||||||
|
// Below that line the old order stands, and for the same reasons: what is
|
||||||
|
// needed first, and a real need above one that exists only because a QSL never
|
||||||
|
// came — an unconfirmed new BAND still outranks a real new SLOT, because the
|
||||||
|
// band is the bigger prize whatever state it is in.
|
||||||
|
//
|
||||||
|
// 100+ every watched callsign, ordered among themselves by the same rule
|
||||||
|
// 18 DXCC 16 DXCC unconf
|
||||||
|
// 14 band 12 band unconf
|
||||||
|
// 10 mode 8 mode unconf
|
||||||
|
// 6 slot 4 slot unconf
|
||||||
// 0 nothing to call for
|
// 0 nothing to call for
|
||||||
func rank(c Candidate) int {
|
func rank(c Candidate) int {
|
||||||
if c.Need == NeedNone {
|
r := int(c.Need) * 4 // slot 4, mode 8, band 12, DXCC 16
|
||||||
if c.Watched {
|
if c.Need != NeedNone && !c.Unconfirmed {
|
||||||
return 1
|
r += 2
|
||||||
}
|
}
|
||||||
return 0
|
|
||||||
}
|
|
||||||
r := int(c.Need) * 2 // slot 2, mode 4, band 6, DXCC 8
|
|
||||||
if c.Watched {
|
if c.Watched {
|
||||||
r++
|
// Above every unwatched station, whatever the log makes of either.
|
||||||
|
return 100 + r
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -371,6 +469,33 @@ func isGrid(tok string) bool {
|
|||||||
return gridRe.MatchString(tok)
|
return gridRe.MatchString(tok)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// messages splits a decoded line into the messages it carries.
|
||||||
|
//
|
||||||
|
// One transmission can hold two. MSHV answers several callers at once — its
|
||||||
|
// own multi-answer transmission, not SuperFox — and the decoder prints them as
|
||||||
|
// one line:
|
||||||
|
//
|
||||||
|
// YV5ALI RR73; F4BPO <HK0/PY8WW> -08
|
||||||
|
//
|
||||||
|
// The second half is a report to F4BPO. Reading only the start of the line, the
|
||||||
|
// engine saw a station working YV5ALI, decided it could not answer us, and
|
||||||
|
// dropped the target — at the exact moment the DX was answering. Each segment
|
||||||
|
// is its own message and names its own recipient first.
|
||||||
|
func messages(msg string) []string {
|
||||||
|
return strings.Split(strings.ToUpper(strings.TrimSpace(msg)), ";")
|
||||||
|
}
|
||||||
|
|
||||||
|
// addressedTo reports whether one message segment is addressed to a callsign.
|
||||||
|
// The recipient is the first token, sometimes bracketed when the sender has
|
||||||
|
// compressed a non-standard call.
|
||||||
|
func addressedTo(part, call string) bool {
|
||||||
|
toks := strings.Fields(part)
|
||||||
|
if len(toks) == 0 || call == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Trim(toks[0], "<>") == strings.ToUpper(call)
|
||||||
|
}
|
||||||
|
|
||||||
// callable reports whether a station can be answered RIGHT NOW.
|
// callable reports whether a station can be answered RIGHT NOW.
|
||||||
//
|
//
|
||||||
// A station in mid-exchange is committed to somebody else: it will not answer,
|
// A station in mid-exchange is committed to somebody else: it will not answer,
|
||||||
@@ -383,17 +508,23 @@ func callable(c Candidate, myCall string) bool {
|
|||||||
if c.CQ {
|
if c.CQ {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg)))
|
parts := messages(c.Msg)
|
||||||
if len(toks) == 0 {
|
if len(parts) == 0 {
|
||||||
return false // nothing to read: assume it is busy rather than call blind
|
return false // nothing to read: assume it is busy rather than call blind
|
||||||
}
|
}
|
||||||
if myCall != "" && toks[0] == strings.ToUpper(myCall) {
|
for _, part := range parts {
|
||||||
|
toks := strings.Fields(part)
|
||||||
|
if len(toks) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if addressedTo(part, myCall) {
|
||||||
return true // it is calling us
|
return true // it is calling us
|
||||||
}
|
}
|
||||||
switch toks[len(toks)-1] {
|
switch toks[len(toks)-1] {
|
||||||
case "RR73", "RRR", "73":
|
case "RR73", "RRR", "73":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,8 +534,12 @@ func callingUs(c Candidate, myCall string) bool {
|
|||||||
if myCall == "" || c.CQ {
|
if myCall == "" || c.CQ {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg)))
|
for _, part := range messages(c.Msg) {
|
||||||
return len(toks) > 0 && toks[0] == strings.ToUpper(myCall)
|
if addressedTo(part, myCall) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// finished reports that the exchange with this station is over: it sent us the
|
// finished reports that the exchange with this station is over: it sent us the
|
||||||
@@ -420,8 +555,11 @@ func finished(decodes []Candidate, call, myCall string) bool {
|
|||||||
if strings.ToUpper(c.Call) != call {
|
if strings.ToUpper(c.Call) != call {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg)))
|
// Every segment: a fox says goodbye to one caller and hello to the next
|
||||||
if len(toks) < 2 || toks[0] != me {
|
// in the same transmission.
|
||||||
|
for _, part := range messages(c.Msg) {
|
||||||
|
toks := strings.Fields(part)
|
||||||
|
if len(toks) < 2 || !addressedTo(part, me) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch toks[len(toks)-1] {
|
switch toks[len(toks)-1] {
|
||||||
@@ -429,6 +567,7 @@ func finished(decodes []Candidate, call, myCall string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,6 +605,9 @@ func (e *Engine) OnPeriod(p Period) Action {
|
|||||||
if !e.set.Enabled {
|
if !e.set.Enabled {
|
||||||
return Action{}
|
return Action{}
|
||||||
}
|
}
|
||||||
|
if !p.At.IsZero() {
|
||||||
|
e.now = p.At
|
||||||
|
}
|
||||||
// ONE station at a time, on the receiver it is being called on.
|
// ONE station at a time, on the receiver it is being called on.
|
||||||
//
|
//
|
||||||
// This is the guarantee with two decoders running: while a target is held,
|
// This is the guarantee with two decoders running: while a target is held,
|
||||||
@@ -487,11 +629,20 @@ func (e *Engine) OnPeriod(p Period) Action {
|
|||||||
// period rather than waiting for the next one: the slot is the resource.
|
// period rather than waiting for the next one: the slot is the resource.
|
||||||
if finished(p.Decodes, tc, p.MyCall) || workedNow(p.Decodes, tc) {
|
if finished(p.Decodes, tc, p.MyCall) || workedNow(p.Decodes, tc) {
|
||||||
e.release(tc, false)
|
e.release(tc, false)
|
||||||
// Straight on to the next station, list or no list: pick() is what
|
// AND NOTHING ELSE THIS PERIOD. The next station is picked on the next
|
||||||
// knows the chase list, and with one on it the answer is simply the
|
// one, fifteen seconds later, and it is still there.
|
||||||
// next callsign there — a list of two DXpeditions must not stop
|
//
|
||||||
// after the first.
|
// His RR73 is decoded while we are between overs, so the decoder is
|
||||||
return e.pick(p, fmt.Sprintf("QSO with %s finished", tc))
|
// about to send our own 73 in the slot that is opening. Answering
|
||||||
|
// somebody else now takes that slot: the reply switches the DX call
|
||||||
|
// mid-sequence and the 73 never goes out whole — an operator watching
|
||||||
|
// saw both transmissions in one period, and the station at the other
|
||||||
|
// end never got the frame that closes the contact.
|
||||||
|
//
|
||||||
|
// This used to hand straight on "because the slot is the resource".
|
||||||
|
// The slot is worth less than the QSO it would spoil.
|
||||||
|
return Action{Kind: DoNothing,
|
||||||
|
Reason: fmt.Sprintf("QSO with %s finished — leaving the next slot for our 73", tc)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The clock backstop. Every counter below depends on decodes arriving in
|
// The clock backstop. Every counter below depends on decodes arriving in
|
||||||
@@ -501,6 +652,11 @@ func (e *Engine) OnPeriod(p Period) Action {
|
|||||||
return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s held for %s with nothing to show for it", tc, e.set.MaxHold)}
|
return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s held for %s with nothing to show for it", tc, e.set.MaxHold)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if e.trace != nil {
|
||||||
|
e.trace("period %s holding %s calls=%d/%d misses=%d/%d%s",
|
||||||
|
p.Key, tc, e.attempts, e.maxAttempts(t), e.misses, e.set.Misses,
|
||||||
|
map[bool]string{true: " (transmitting)", false: ""}[p.TX.Transmitting])
|
||||||
|
}
|
||||||
if seen := bestOf(p.Decodes, tc); seen != nil {
|
if seen := bestOf(p.Decodes, tc); seen != nil {
|
||||||
// The decode is refreshed so a reply carries a current timestamp; the
|
// The decode is refreshed so a reply carries a current timestamp; the
|
||||||
// hold clock is NOT — see heldSince.
|
// hold clock is NOT — see heldSince.
|
||||||
@@ -512,6 +668,30 @@ func (e *Engine) OnPeriod(p Period) Action {
|
|||||||
// reply is how two transmissions land in one slot.
|
// reply is how two transmissions land in one slot.
|
||||||
if callingUs(*seen, p.MyCall) {
|
if callingUs(*seen, p.MyCall) {
|
||||||
e.attempts = 0
|
e.attempts = 0
|
||||||
|
// The exchange is under way, so the hold clock starts again from
|
||||||
|
// here: it exists to end a call nobody answers, and this one has
|
||||||
|
// been answered. Without this a QSO that began at the end of a
|
||||||
|
// long wait could be halted mid-exchange by a backstop that was
|
||||||
|
// counting the wait.
|
||||||
|
e.heldSince = p.At
|
||||||
|
return Action{}
|
||||||
|
}
|
||||||
|
// IT IS WORKING SOMEBODY ELSE. Stop calling it.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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) {
|
||||||
|
e.drop()
|
||||||
|
return Action{Kind: DoHalt, Soft: true,
|
||||||
|
Reason: fmt.Sprintf("%s is working %s — it cannot answer", tc, addressee(seen.Msg))}
|
||||||
}
|
}
|
||||||
return Action{}
|
return Action{}
|
||||||
}
|
}
|
||||||
@@ -536,45 +716,40 @@ func (e *Engine) OnPeriod(p Period) Action {
|
|||||||
if e.stopped {
|
if e.stopped {
|
||||||
return Action{} // an explicit target gave up; the operator restarts it
|
return Action{} // an explicit target gave up; the operator restarts it
|
||||||
}
|
}
|
||||||
// Never start a call over a transmission in progress: the reply would land
|
|
||||||
// in a slot the decoder is already using.
|
|
||||||
if p.TX.Transmitting {
|
|
||||||
return Action{}
|
|
||||||
}
|
|
||||||
return e.pick(p, "")
|
return e.pick(p, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// pick chooses the best station on the air and answers it.
|
// pick chooses the best station on the air and answers it.
|
||||||
|
//
|
||||||
|
// NEVER over a transmission in progress, and that guard belongs HERE because
|
||||||
|
// there are two ways in. A reply is not a request the decoder queues: WSJT-X
|
||||||
|
// acts on it at once, drops the exchange it is in and starts calling the new
|
||||||
|
// station — so a reply sent while our own 73 was going out cut that 73 about a
|
||||||
|
// second in, and the station we had just worked never got it. Reported from the
|
||||||
|
// air, and the sequence is exactly this one: his RRR is decoded, the QSO reads
|
||||||
|
// as finished, and the next station is picked in the same instant.
|
||||||
|
//
|
||||||
|
// The slot is worth having, but not at the price of the QSO in hand. The next
|
||||||
|
// period is a fifteen-second wait and everything worth calling is still there.
|
||||||
func (e *Engine) pick(p Period, why string) Action {
|
func (e *Engine) pick(p Period, why string) Action {
|
||||||
|
if e.trace != nil {
|
||||||
|
e.tracePick(p)
|
||||||
|
}
|
||||||
|
if p.TX.Transmitting {
|
||||||
|
if why != "" {
|
||||||
|
return Action{Kind: DoNothing, Reason: why + " — holding until the transmission ends"}
|
||||||
|
}
|
||||||
|
return Action{}
|
||||||
|
}
|
||||||
only := onlyList(e.set.Only)
|
only := onlyList(e.set.Only)
|
||||||
var best *Candidate
|
var best *Candidate
|
||||||
var bestRank int
|
var bestRank int
|
||||||
// bestRank of everything eligible, rested or not: a station that is resting
|
|
||||||
// may only be re-picked while nothing better is on the air, and that is the
|
|
||||||
// comparison.
|
|
||||||
topRank := 0
|
|
||||||
for i := range p.Decodes {
|
|
||||||
c := p.Decodes[i]
|
|
||||||
if !e.eligible(c, p, only) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r := rank(c); r > topRank {
|
|
||||||
topRank = r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i := range p.Decodes {
|
for i := range p.Decodes {
|
||||||
c := p.Decodes[i]
|
c := p.Decodes[i]
|
||||||
if !e.eligible(c, p, only) {
|
if !e.eligible(c, p, only) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
r := rank(c)
|
r := rank(c)
|
||||||
if resting, ok := e.rested[strings.ToUpper(c.Call)]; ok && p.At.Before(resting) {
|
|
||||||
// Rested: allowed back only when it is the best thing there is.
|
|
||||||
// Anything of higher rank takes the slot instead.
|
|
||||||
if r < topRank {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if best == nil || better(c, r, *best, bestRank, p.MyCall) {
|
if best == nil || better(c, r, *best, bestRank, p.MyCall) {
|
||||||
cc := c
|
cc := c
|
||||||
best, bestRank = &cc, r
|
best, bestRank = &cc, r
|
||||||
@@ -595,6 +770,41 @@ func (e *Engine) pick(p Period, why string) Action {
|
|||||||
return Action{Kind: DoReply, Decode: best.Decode, Reason: reason}
|
return Action{Kind: DoReply, Decode: best.Decode, Reason: reason}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tracePick writes the one line that says what this period held and what it
|
||||||
|
// was worth. Counts by refusal, then the best few candidates in rank order —
|
||||||
|
// enough to see WHY a station on the screen was not the one called.
|
||||||
|
func (e *Engine) tracePick(p Period) {
|
||||||
|
only := onlyList(e.set.Only)
|
||||||
|
refused := map[string]int{}
|
||||||
|
type scored struct {
|
||||||
|
c Candidate
|
||||||
|
r int
|
||||||
|
}
|
||||||
|
var ok []scored
|
||||||
|
for i := range p.Decodes {
|
||||||
|
if good, why := e.judge(p.Decodes[i], p, only); good {
|
||||||
|
ok = append(ok, scored{p.Decodes[i], rank(p.Decodes[i])})
|
||||||
|
} else {
|
||||||
|
refused[why]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(ok, func(i, j int) bool { return ok[i].r > ok[j].r })
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
why := make([]string, 0, len(refused))
|
||||||
|
for _, k := range []string{"worked", "nothing-needed", "busy", "resting", "halted", "parked", "hidden", "not-chased", "replay", "self"} {
|
||||||
|
if n := refused[k]; n > 0 {
|
||||||
|
why = append(why, fmt.Sprintf("%s=%d", k, n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.trace("period %s rx=%q decodes=%d callable=%d [%s] refused[%s]%s",
|
||||||
|
p.Key, p.Instance, len(p.Decodes), len(ok), strings.Join(best, " "),
|
||||||
|
strings.Join(why, " "),
|
||||||
|
map[bool]string{true: " (transmitting)", false: ""}[p.TX.Transmitting])
|
||||||
|
}
|
||||||
func watchedTag(c Candidate) string {
|
func watchedTag(c Candidate) string {
|
||||||
if c.Watched {
|
if c.Watched {
|
||||||
return "watched "
|
return "watched "
|
||||||
@@ -604,30 +814,88 @@ func watchedTag(c Candidate) string {
|
|||||||
|
|
||||||
// eligible is every refusal that applies before a station is even ranked.
|
// eligible is every refusal that applies before a station is even ranked.
|
||||||
func (e *Engine) eligible(c Candidate, p Period, only []string) bool {
|
func (e *Engine) eligible(c Candidate, p Period, only []string) bool {
|
||||||
|
ok, _ := e.judge(c, p, only)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// judge is eligible() with the reason attached, in one word.
|
||||||
|
//
|
||||||
|
// The reason exists for the log. An auto-call that calls nobody is the hardest
|
||||||
|
// thing to argue with — the band is full, the panel shows a dozen stations
|
||||||
|
// wanted, and nothing goes out — and "twenty-three decodes, nineteen already
|
||||||
|
// worked, three greyed, one resting" answers it in a line where a boolean
|
||||||
|
// cannot.
|
||||||
|
func (e *Engine) judge(c Candidate, p Period, only []string) (bool, string) {
|
||||||
call := strings.ToUpper(strings.TrimSpace(c.Call))
|
call := strings.ToUpper(strings.TrimSpace(c.Call))
|
||||||
if call == "" || call == strings.ToUpper(p.MyCall) {
|
if call == "" || call == strings.ToUpper(p.MyCall) {
|
||||||
return false
|
return false, "self"
|
||||||
}
|
}
|
||||||
if !c.IsNew {
|
if !c.IsNew {
|
||||||
return false // replayed history: the station may be hours gone
|
return false, "replay" // the station may be hours gone
|
||||||
}
|
}
|
||||||
if len(only) > 0 {
|
if len(only) > 0 {
|
||||||
// The named stations, and each only until the QSO with it is made: an
|
// The named stations, and each only until the QSO with it is made: an
|
||||||
// explicit request is not a standing order to work the same station all
|
// explicit request is not a standing order to work the same station all
|
||||||
// evening. The others on the list stay callable.
|
// evening. The others on the list stay callable.
|
||||||
return inList(only, call) && !e.done[call] && !c.Worked
|
switch {
|
||||||
|
case !inList(only, call):
|
||||||
|
return false, "not-chased"
|
||||||
|
case e.greyed[call]:
|
||||||
|
return false, "halted"
|
||||||
|
case e.done[call] || c.Worked:
|
||||||
|
return false, "worked"
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
}
|
}
|
||||||
if c.Worked || e.done[call] {
|
if c.Worked || e.done[call] {
|
||||||
return false
|
return false, "worked"
|
||||||
|
}
|
||||||
|
if e.greyed[call] {
|
||||||
|
return false, "halted"
|
||||||
|
}
|
||||||
|
// A STATION CALLING US is answered, needed or not.
|
||||||
|
//
|
||||||
|
// It has heard us, it is waiting, and the QSO is one over away — refusing it
|
||||||
|
// because the log has nothing to gain is how an operator finishes a contact,
|
||||||
|
// sees two stations calling them on the next sequence, and watches the
|
||||||
|
// machine ignore both. Everything below this point is about choosing whom to
|
||||||
|
// CALL; a caller is not chosen, it is already there.
|
||||||
|
//
|
||||||
|
// The refusals above still apply — worked on this band and mode, the QSO
|
||||||
|
// already made, a station the operator stopped. Those are answers about this
|
||||||
|
// station, not about the log's appetite.
|
||||||
|
if callingUs(c, p.MyCall) {
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
// "LoTW users only" is a rule about STRANGERS. It cannot overrule the watch
|
||||||
|
// list: naming a callsign is the operator saying they want that station, and
|
||||||
|
// a DXpedition that uploads nowhere is exactly the kind of station one puts
|
||||||
|
// on the list. Reported from the air — a watched J38DX, plainly calling CQ,
|
||||||
|
// never called all evening because it is not in the LoTW user file.
|
||||||
|
// FILTERED OFF THE SCREEN. The operator's own filters, applied to the
|
||||||
|
// transmitter: what is not shown is not called.
|
||||||
|
if e.set.OnScreenOnly && c.Hidden {
|
||||||
|
return false, "hidden"
|
||||||
}
|
}
|
||||||
if rank(c) == 0 {
|
if rank(c) == 0 {
|
||||||
return false
|
return false, "nothing-needed"
|
||||||
}
|
}
|
||||||
if e.rounds[call] >= e.set.MaxRounds {
|
if e.rounds[call] >= e.set.MaxRounds {
|
||||||
return false // parked for the session
|
return false, "parked" // its series are spent for the session
|
||||||
|
}
|
||||||
|
// RESTING. A series that ended in a brake is followed by a real pause,
|
||||||
|
// whatever else is on the air — the exception used to be "unless nothing
|
||||||
|
// better is decoding", and for the station everybody is chasing that is
|
||||||
|
// every period: seven calls, a halt, and the same station called again four
|
||||||
|
// seconds later. From the outside that is not a rest, it is a stutter.
|
||||||
|
if until, ok := e.rested[call]; ok && p.At.Before(until) {
|
||||||
|
return false, "resting"
|
||||||
}
|
}
|
||||||
// Mid-exchange with somebody else: it cannot answer us — see callable.
|
// Mid-exchange with somebody else: it cannot answer us — see callable.
|
||||||
return callable(c, p.MyCall)
|
if !callable(c, p.MyCall) {
|
||||||
|
return false, "busy"
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// better orders two eligible stations: the need first, then the one already
|
// better orders two eligible stations: the need first, then the one already
|
||||||
@@ -645,6 +913,29 @@ func better(a Candidate, ra int, b Candidate, rb int, myCall string) bool {
|
|||||||
return a.SNR > b.SNR
|
return a.SNR > b.SNR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drop lets the target go with no verdict attached: not worked, not given up
|
||||||
|
// on, not rested. It is simply not the station to be calling right now, and it
|
||||||
|
// is picked again like any other the moment it is.
|
||||||
|
func (e *Engine) drop() {
|
||||||
|
e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, ""
|
||||||
|
e.targetInst = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// addressee is who a message is being sent to — the first token, which is the
|
||||||
|
// callsign being answered — the first segment of a multi-answer line, which is
|
||||||
|
// the QSO it is finishing.
|
||||||
|
func addressee(msg string) string {
|
||||||
|
parts := messages(msg)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "someone else"
|
||||||
|
}
|
||||||
|
toks := strings.Fields(parts[0])
|
||||||
|
if len(toks) == 0 {
|
||||||
|
return "someone else"
|
||||||
|
}
|
||||||
|
return strings.Trim(toks[0], "<>")
|
||||||
|
}
|
||||||
|
|
||||||
// release clears the target. gaveUp marks it as a series that ended in a brake
|
// release clears the target. gaveUp marks it as a series that ended in a brake
|
||||||
// rather than in a QSO.
|
// rather than in a QSO.
|
||||||
func (e *Engine) release(call string, gaveUp bool) {
|
func (e *Engine) release(call string, gaveUp bool) {
|
||||||
@@ -662,7 +953,11 @@ func (e *Engine) release(call string, gaveUp bool) {
|
|||||||
func (e *Engine) giveUp(call string, t Candidate) {
|
func (e *Engine) giveUp(call string, t Candidate) {
|
||||||
e.release(call, true)
|
e.release(call, true)
|
||||||
e.rounds[call]++
|
e.rounds[call]++
|
||||||
e.rested[call] = time.Now().Add(e.set.Rest)
|
at := e.now
|
||||||
|
if at.IsZero() {
|
||||||
|
at = time.Now()
|
||||||
|
}
|
||||||
|
e.rested[call] = at.Add(e.set.Rest)
|
||||||
// An explicit "call this station" that runs out of attempts stops the
|
// An explicit "call this station" that runs out of attempts stops the
|
||||||
// feature instead of moving on. There is nothing else it was asked to do.
|
// feature instead of moving on. There is nothing else it was asked to do.
|
||||||
if strings.TrimSpace(e.set.Only) != "" {
|
if strings.TrimSpace(e.set.Only) != "" {
|
||||||
@@ -715,7 +1010,8 @@ func (e *Engine) NoteTX(tx TXState) Action {
|
|||||||
}
|
}
|
||||||
max := e.maxAttempts(t)
|
max := e.maxAttempts(t)
|
||||||
e.giveUp(tc, t)
|
e.giveUp(tc, t)
|
||||||
return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s called %d times without an answer", tc, max)}
|
return Action{Kind: DoHalt, Soft: true,
|
||||||
|
Reason: fmt.Sprintf("%s called %d times without an answer — stopping after this over", tc, max)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bestOf returns the most callable decode of one station in a period.
|
// bestOf returns the most callable decode of one station in a period.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package autocall
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -56,13 +57,14 @@ func on() *Engine { return New(Settings{Enabled: true}) }
|
|||||||
// ── The ladder ────────────────────────────────────────────────────────────
|
// ── The ladder ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestLadderOrder(t *testing.T) {
|
func TestLadderOrder(t *testing.T) {
|
||||||
// Every rung, in the order the operator asked for.
|
// Every rung. The watched ones come FIRST, ordered among themselves by what
|
||||||
|
// is needed — the list is the operator's answer, not a tie-breaker.
|
||||||
order := []Candidate{
|
order := []Candidate{
|
||||||
cq("A", NeedDXCC, 0, watched), cq("B", NeedDXCC, 0),
|
cq("A", NeedDXCC, 0, watched), cq("C", NeedBand, 0, watched),
|
||||||
cq("C", NeedBand, 0, watched), cq("D", NeedBand, 0),
|
cq("E", NeedMode, 0, watched), cq("G", NeedSlot, 0, watched),
|
||||||
cq("E", NeedMode, 0, watched), cq("F", NeedMode, 0),
|
|
||||||
cq("G", NeedSlot, 0, watched), cq("H", NeedSlot, 0),
|
|
||||||
cq("I", NeedNone, 0, watched),
|
cq("I", NeedNone, 0, watched),
|
||||||
|
cq("B", NeedDXCC, 0), cq("D", NeedBand, 0),
|
||||||
|
cq("F", NeedMode, 0), cq("H", NeedSlot, 0),
|
||||||
}
|
}
|
||||||
for i := 1; i < len(order); i++ {
|
for i := 1; i < len(order); i++ {
|
||||||
if rank(order[i-1]) <= rank(order[i]) {
|
if rank(order[i-1]) <= rank(order[i]) {
|
||||||
@@ -76,10 +78,17 @@ func TestLadderOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// And the pick agrees with the ladder, whatever order the period lists them.
|
// And the pick agrees with the ladder, whatever order the period lists them.
|
||||||
e := on()
|
e := on()
|
||||||
a := e.OnPeriod(period(0, order[7], order[3], order[0], order[5]))
|
a := e.OnPeriod(period(0, order[8], order[5], order[0], order[6]))
|
||||||
if a.Kind != DoReply || a.Decode.Call != "A" {
|
if a.Kind != DoReply || a.Decode.Call != "A" {
|
||||||
t.Fatalf("picked %+v, want the watched new entity", a)
|
t.Fatalf("picked %+v, want the watched new entity", a)
|
||||||
}
|
}
|
||||||
|
// A watched station with NOTHING needed still beats a new entity that is not
|
||||||
|
// watched — the case that sent an operator hunting: a watched DXpedition sat
|
||||||
|
// on the band all evening while the engine worked what the log wanted.
|
||||||
|
e = on()
|
||||||
|
if a := e.OnPeriod(period(2, cq("RARE", NeedDXCC, 0), cq("WATCHED", NeedNone, -20, watched))); a.Decode.Call != "WATCHED" {
|
||||||
|
t.Errorf("picked %q, want the watched callsign", a.Decode.Call)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStrongestWinsBetweenEquals(t *testing.T) {
|
func TestStrongestWinsBetweenEquals(t *testing.T) {
|
||||||
@@ -232,24 +241,52 @@ func TestAReleasedStationYieldsToAnythingBetter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAReleasedStationIsCalledAgainWhenNothingBetterIsOnTheAir(t *testing.T) {
|
func TestAReleasedStationRestsBeforeItIsCalledAgain(t *testing.T) {
|
||||||
e := on()
|
e := on()
|
||||||
e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
|
e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
|
||||||
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
||||||
for i := 0; i < 7; i++ {
|
for i := 0; i < 7; i++ {
|
||||||
e.NoteTX(tx)
|
e.NoteTX(tx)
|
||||||
}
|
}
|
||||||
a := e.OnPeriod(period(2, cq("DX", NeedBand, -5)))
|
// Still the only thing on the air, and still resting: seven calls, a halt,
|
||||||
if a.Kind != DoReply || a.Decode.Call != "DX" {
|
// and the same station called again four seconds later is not a rest.
|
||||||
t.Errorf("nothing better on the air and the station was not called again: %+v", a)
|
for _, n := range []int{2, 4, 6} { // all inside the two minutes
|
||||||
|
if a := e.OnPeriod(period(n, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
|
||||||
|
t.Fatalf("period %d: called again during the rest: %+v", n, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Two minutes later (period 8 × 15 s = 2 min past the give-up) it may go
|
||||||
|
// again — the station is still there and nothing better is.
|
||||||
|
if a := e.OnPeriod(period(9, cq("DX", NeedBand, -5))); a.Kind != DoReply {
|
||||||
|
t.Errorf("after the rest: %+v, want the station called again", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheAttemptsCapLetsTheOverFinish(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
|
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
||||||
|
var a Action
|
||||||
|
for i := 0; i < 7; i++ {
|
||||||
|
a = e.NoteTX(tx)
|
||||||
|
}
|
||||||
|
if a.Kind != DoHalt {
|
||||||
|
t.Fatalf("no halt after seven calls: %+v", a)
|
||||||
|
}
|
||||||
|
// The cap trips WHILE the seventh call is going out — cutting the carrier
|
||||||
|
// there sends half a call. It asks the decoder to finish the over first.
|
||||||
|
if !a.Soft {
|
||||||
|
t.Error("the attempts cap cut the transmission that counted it")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAStationIsParkedAfterItsRounds(t *testing.T) {
|
func TestAStationIsParkedAfterItsRounds(t *testing.T) {
|
||||||
e := on()
|
e := on()
|
||||||
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
|
||||||
|
// Rounds are spaced past the rest (2 min = 8 periods) or the station would
|
||||||
|
// simply be resting rather than parked.
|
||||||
for round := 1; round <= 3; round++ {
|
for round := 1; round <= 3; round++ {
|
||||||
a := e.OnPeriod(period(round*2, cq("DX", NeedBand, -5)))
|
a := e.OnPeriod(period(round*10, cq("DX", NeedBand, -5)))
|
||||||
if a.Kind != DoReply {
|
if a.Kind != DoReply {
|
||||||
t.Fatalf("round %d: not called (%+v)", round, a)
|
t.Fatalf("round %d: not called (%+v)", round, a)
|
||||||
}
|
}
|
||||||
@@ -259,7 +296,7 @@ func TestAStationIsParkedAfterItsRounds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Three series of seven is twenty-one calls. That is the end of it for this
|
// Three series of seven is twenty-one calls. That is the end of it for this
|
||||||
// session — the whole point of the exercise is that it cannot reach fifty.
|
// session — the whole point of the exercise is that it cannot reach fifty.
|
||||||
if a := e.OnPeriod(period(20, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
|
if a := e.OnPeriod(period(60, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
|
||||||
t.Errorf("a fourth series was started: %+v", a)
|
t.Errorf("a fourth series was started: %+v", a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,9 +308,13 @@ func TestFinishedQSOMovesToTheNextPriority(t *testing.T) {
|
|||||||
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
done := callsMe("DX", NeedDXCC, -5)
|
done := callsMe("DX", NeedDXCC, -5)
|
||||||
done.Msg = me + " DX RR73"
|
done.Msg = me + " DX RR73"
|
||||||
a := e.OnPeriod(period(2, done, cq("NEXT", NeedBand, -10)))
|
// The period the QSO ends in belongs to our own 73 — nothing else is called.
|
||||||
|
if a := e.OnPeriod(period(2, done, cq("NEXT", NeedBand, -10))); a.Kind != DoNothing {
|
||||||
|
t.Fatalf("took the slot our 73 goes out in: %+v", a)
|
||||||
|
}
|
||||||
|
a := e.OnPeriod(period(4, cq("NEXT", NeedBand, -10)))
|
||||||
if a.Kind != DoReply || a.Decode.Call != "NEXT" {
|
if a.Kind != DoReply || a.Decode.Call != "NEXT" {
|
||||||
t.Errorf("after the QSO ended: %+v, want the next priority in the same period", a)
|
t.Errorf("next period: %+v, want the next priority", a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,12 +323,14 @@ func TestFinishedQSOWithNoPriorityAnswersWhoeverIsCallingUs(t *testing.T) {
|
|||||||
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
done := callsMe("DX", NeedDXCC, -5)
|
done := callsMe("DX", NeedDXCC, -5)
|
||||||
done.Msg = me + " DX RR73"
|
done.Msg = me + " DX RR73"
|
||||||
// Two stations calling us, nothing needed from either: the strongest wins.
|
// Two stations calling us, nothing needed from either: the strongest wins —
|
||||||
|
// on the period after the one our 73 goes out in.
|
||||||
weak := callsMe("WEAK", NeedNone, -18)
|
weak := callsMe("WEAK", NeedNone, -18)
|
||||||
weak.Watched = true
|
weak.Watched = true
|
||||||
loud := callsMe("LOUD", NeedNone, -4)
|
loud := callsMe("LOUD", NeedNone, -4)
|
||||||
loud.Watched = true
|
loud.Watched = true
|
||||||
a := e.OnPeriod(period(2, done, weak, loud))
|
e.OnPeriod(period(2, done))
|
||||||
|
a := e.OnPeriod(period(4, weak, loud))
|
||||||
if a.Kind != DoReply || a.Decode.Call != "LOUD" {
|
if a.Kind != DoReply || a.Decode.Call != "LOUD" {
|
||||||
t.Errorf("answered %+v, want the strongest of the stations calling us", a)
|
t.Errorf("answered %+v, want the strongest of the stations calling us", a)
|
||||||
}
|
}
|
||||||
@@ -403,7 +446,8 @@ func TestChaseListTakesSeveralCallsigns(t *testing.T) {
|
|||||||
// single request.
|
// single request.
|
||||||
done := callsMe("VP6D", NeedDXCC, -22)
|
done := callsMe("VP6D", NeedDXCC, -22)
|
||||||
done.Msg = me + " VP6D RR73"
|
done.Msg = me + " VP6D RR73"
|
||||||
a = e.OnPeriod(period(4, done, cq("3Y0J", NeedSlot, -1)))
|
e.OnPeriod(period(4, done, cq("3Y0J", NeedSlot, -1)))
|
||||||
|
a = e.OnPeriod(period(6, cq("3Y0J", NeedSlot, -1)))
|
||||||
if a.Kind != DoReply || a.Decode.Call != "3Y0J" {
|
if a.Kind != DoReply || a.Decode.Call != "3Y0J" {
|
||||||
t.Errorf("after working VP6D: %+v, want the other station on the list", a)
|
t.Errorf("after working VP6D: %+v, want the other station on the list", a)
|
||||||
}
|
}
|
||||||
@@ -460,3 +504,294 @@ func TestTheOtherReceiverCannotBreakTheQSOInProgress(t *testing.T) {
|
|||||||
t.Errorf("after the QSO ended, the other receiver was still locked out: %+v", a)
|
t.Errorf("after the QSO ended, the other receiver was still locked out: %+v", a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The one reported from the air: his RRR arrives, we are sending our 73, and
|
||||||
|
// the engine picked the next station in the same instant. WSJT-X acts on a
|
||||||
|
// reply at once — it dropped the exchange and started calling the new station,
|
||||||
|
// cutting the 73 a second in.
|
||||||
|
func TestNothingIsCalledOverOurOwnTransmission(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
|
done := callsMe("DX", NeedDXCC, -5)
|
||||||
|
done.Msg = me + " DX RRR"
|
||||||
|
p := period(2, done, cq("NEXT", NeedBand, -10))
|
||||||
|
p.TX = TXState{Transmitting: true, Msg: "DX " + me + " 73"}
|
||||||
|
if a := e.OnPeriod(p); a.Kind != DoNothing {
|
||||||
|
t.Fatalf("called %+v while our own over was still going out", a)
|
||||||
|
}
|
||||||
|
// The QSO is still released — only the next call waits.
|
||||||
|
if e.Target() != "" {
|
||||||
|
t.Errorf("target = %q after the QSO finished, want none", e.Target())
|
||||||
|
}
|
||||||
|
// Next period, carrier down: now it may take the next station.
|
||||||
|
if a := e.OnPeriod(period(4, cq("NEXT", NeedBand, -10))); a.Kind != DoReply || a.Decode.Call != "NEXT" {
|
||||||
|
t.Errorf("once the transmission ended: %+v, want the next station called", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheHoldClockRestartsWhenTheStationAnswers(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedDXCC, -5)))
|
||||||
|
// Called for a long time — nearly the backstop — and then he answers.
|
||||||
|
for p := 2; p <= 14; p += 2 {
|
||||||
|
e.OnPeriod(period(p, cq("DX", NeedDXCC, -5)))
|
||||||
|
}
|
||||||
|
e.OnPeriod(period(16, callsMe("DX", NeedDXCC, -5)))
|
||||||
|
// The exchange must not be halted by a backstop that was counting the wait.
|
||||||
|
for _, p := range []int{18, 20} {
|
||||||
|
if a := e.OnPeriod(period(p, callsMe("DX", NeedDXCC, -5))); a.Kind == DoHalt {
|
||||||
|
t.Fatalf("period %d: halted an exchange in progress (%s)", p, a.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARealNeedOutranksAnUnconfirmedOne(t *testing.T) {
|
||||||
|
real := cq("REAL", NeedBand, -20)
|
||||||
|
unconf := cq("UNCONF", NeedBand, -2)
|
||||||
|
unconf.Unconfirmed = true
|
||||||
|
// Same need, and the unconfirmed one is 18 dB louder. The band never worked
|
||||||
|
// is still the catch: the other is a QSL to chase, not a QSO to make.
|
||||||
|
e := on()
|
||||||
|
if a := e.OnPeriod(period(0, unconf, real)); a.Decode.Call != "REAL" {
|
||||||
|
t.Errorf("picked %q, want the band never worked", a.Decode.Call)
|
||||||
|
}
|
||||||
|
// Watched changes that, and is meant to: the list is the operator's answer.
|
||||||
|
unconf.Watched = true
|
||||||
|
e = on()
|
||||||
|
if a := e.OnPeriod(period(0, unconf, real)); a.Decode.Call != "UNCONF" {
|
||||||
|
t.Errorf("picked %q, want the watched station", a.Decode.Call)
|
||||||
|
}
|
||||||
|
unconf.Watched = false
|
||||||
|
// Between two unwatched stations, an unconfirmed BAND still beats a real
|
||||||
|
// SLOT: the band is the bigger prize whatever state it is in.
|
||||||
|
slot := cq("SLOT", NeedSlot, 0)
|
||||||
|
e = on()
|
||||||
|
if a := e.OnPeriod(period(0, slot, unconf)); a.Decode.Call != "UNCONF" {
|
||||||
|
t.Errorf("picked %q, want the unconfirmed band over a real slot", a.Decode.Call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The operator's Halt ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHaltSetsTheStationAsideForTheSession(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
if a := e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))); a.Decode.Call != "DX" {
|
||||||
|
t.Fatalf("not called: %+v", a)
|
||||||
|
}
|
||||||
|
// The operator stops it mid-call.
|
||||||
|
if got := e.Halt(); got != "DX" {
|
||||||
|
t.Fatalf("Halt returned %q, want the station being called", got)
|
||||||
|
}
|
||||||
|
if e.Target() != "" {
|
||||||
|
t.Error("the target survived a halt")
|
||||||
|
}
|
||||||
|
// Still the loudest new entity on the air, period after period. It is the
|
||||||
|
// operator's verdict, so it is not called again — not next period, not in
|
||||||
|
// ten minutes.
|
||||||
|
for _, n := range []int{2, 4, 40} {
|
||||||
|
if a := e.OnPeriod(period(n, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("period %d: called a station the operator had stopped: %+v", n, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Naming it explicitly does not override the operator either.
|
||||||
|
e.SetSettings(Settings{Enabled: true, Only: "DX"})
|
||||||
|
if a := e.OnPeriod(period(42, cq("DX", NeedDXCC, -5))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("the chase list overrode a halt: %+v", a)
|
||||||
|
}
|
||||||
|
// Switching auto-call off and on is what starts over.
|
||||||
|
e.Reset()
|
||||||
|
e.SetSettings(Settings{Enabled: true})
|
||||||
|
if a := e.OnPeriod(period(44, cq("DX", NeedDXCC, -5))); a.Kind != DoReply {
|
||||||
|
t.Errorf("after a restart the station is fair game again: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHaltWithNothingBeingCalledIsJustAHalt(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
if got := e.Halt(); got != "" {
|
||||||
|
t.Errorf("Halt returned %q with no target", got)
|
||||||
|
}
|
||||||
|
if e.Greylisted() != 0 {
|
||||||
|
t.Errorf("greylisted %d stations from an idle halt", e.Greylisted())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraceSaysWhyNobodyWasCalled(t *testing.T) {
|
||||||
|
var lines []string
|
||||||
|
e := on()
|
||||||
|
e.SetTrace(func(f string, args ...any) { lines = append(lines, fmt.Sprintf(f, args...)) })
|
||||||
|
|
||||||
|
worked := cq("A", NeedDXCC, -5, worked)
|
||||||
|
nothing := cq("B", NeedNone, -5)
|
||||||
|
busyOne := busy("C", NeedBand, -5)
|
||||||
|
e.Halt() // nothing held: no-op, keeps the set empty
|
||||||
|
e.OnPeriod(period(0, worked, nothing, busyOne))
|
||||||
|
|
||||||
|
if len(lines) == 0 {
|
||||||
|
t.Fatal("tracing on and nothing was written")
|
||||||
|
}
|
||||||
|
got := lines[len(lines)-1]
|
||||||
|
for _, want := range []string{"decodes=3", "callable=0", "worked=1", "nothing-needed=1", "busy=1"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("trace %q does not say %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)") {
|
||||||
|
t.Errorf("trace %v does not describe the station it called", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAStationCallingUsIsAnsweredEvenWithNothingToGain(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
// A QSO has just ended and two stations are calling us. Neither is worth
|
||||||
|
// anything to the log — and both are worth answering: they have heard us,
|
||||||
|
// they are waiting, and the contact is one over away.
|
||||||
|
weak := callsMe("WEAK", NeedNone, -18)
|
||||||
|
loud := callsMe("LOUD", NeedNone, -3)
|
||||||
|
a := e.OnPeriod(period(0, weak, loud))
|
||||||
|
if a.Kind != DoReply || a.Decode.Call != "LOUD" {
|
||||||
|
t.Fatalf("answered %+v, want the strongest of the stations calling us", a)
|
||||||
|
}
|
||||||
|
// A caller already worked on this band and mode is a duplicate, not a QSO.
|
||||||
|
e = on()
|
||||||
|
done := callsMe("DUPE", NeedNone, -1)
|
||||||
|
done.Worked = true
|
||||||
|
if a := e.OnPeriod(period(2, done)); a.Kind != DoNothing {
|
||||||
|
t.Errorf("answered a station already in the log on this band and mode: %+v", a)
|
||||||
|
}
|
||||||
|
// And one the operator halted stays halted, however politely it calls.
|
||||||
|
e = on()
|
||||||
|
e.OnPeriod(period(4, cq("STOP", NeedDXCC, -5)))
|
||||||
|
e.Halt()
|
||||||
|
if a := e.OnPeriod(period(6, callsMe("STOP", NeedDXCC, -5))); a.Kind != DoNothing {
|
||||||
|
t.Errorf("answered a station the operator had stopped: %+v", a)
|
||||||
|
}
|
||||||
|
// A needed station still comes first: a caller with nothing to gain is
|
||||||
|
// answered when nothing better is on the air, which is what was asked for.
|
||||||
|
e = on()
|
||||||
|
if a := e.OnPeriod(period(8, callsMe("CALLER", NeedNone, 0), cq("RARE", NeedDXCC, -20))); a.Decode.Call != "RARE" {
|
||||||
|
t.Errorf("picked %q, want the new entity ahead of a caller with nothing needed", a.Decode.Call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATargetThatStartsWorkingSomebodyElseIsDropped(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 e.Target() != "" {
|
||||||
|
t.Error("the target was not released")
|
||||||
|
}
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFinalFrameToSomebodyElseIsNotABusyTarget(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("DX", NeedSlot, 0)))
|
||||||
|
// Sending RR73 to another station: one period from being free, and the best
|
||||||
|
// moment there is to be calling it.
|
||||||
|
wrap := busy("DX", NeedSlot, 0)
|
||||||
|
wrap.Msg = "PY2SAD DX RR73"
|
||||||
|
if a := e.OnPeriod(period(2, wrap)); a.Kind != DoNothing || e.Target() != "DX" {
|
||||||
|
t.Errorf("dropped a station that was finishing: %+v (target %q)", a, e.Target())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNothingHiddenByThePanelIsCalled(t *testing.T) {
|
||||||
|
e := New(Settings{Enabled: true, OnScreenOnly: true})
|
||||||
|
// The operator has filtered the list down to CQs: what is not on the screen
|
||||||
|
// is not called, whatever the log makes of it.
|
||||||
|
hidden := cq("RARE", NeedDXCC, 0)
|
||||||
|
hidden.Hidden = true
|
||||||
|
shown := cq("PLAIN", NeedSlot, -20)
|
||||||
|
if a := e.OnPeriod(period(0, hidden, shown)); a.Decode.Call != "PLAIN" {
|
||||||
|
t.Errorf("picked %q, want the station the panel is showing", a.Decode.Call)
|
||||||
|
}
|
||||||
|
// With the panel publishing nothing, nothing is hidden and the ladder
|
||||||
|
// decides alone.
|
||||||
|
e = New(Settings{Enabled: true, OnScreenOnly: true})
|
||||||
|
free := cq("RARE", NeedDXCC, 0)
|
||||||
|
if a := e.OnPeriod(period(2, free, shown)); a.Decode.Call != "RARE" {
|
||||||
|
t.Errorf("picked %q with no filtering in force, want the new entity", a.Decode.Call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MSHV answers two stations in one transmission and the decoder prints them as
|
||||||
|
// one line. Reported from the air: the DX was answering us in the second half
|
||||||
|
// and the engine, reading only the first, dropped it as busy.
|
||||||
|
func TestAMultiAnswerLineIsReadWhole(t *testing.T) {
|
||||||
|
fox := cq("HK0/PY8WW", NeedDXCC, -17, watched)
|
||||||
|
fox.CQ = false
|
||||||
|
fox.Msg = "YV5ALI RR73; " + me + " <HK0/PY8WW> -08"
|
||||||
|
|
||||||
|
if !callingUs(fox, me) {
|
||||||
|
t.Error("the second half is a report to us and was not read as one")
|
||||||
|
}
|
||||||
|
if !callable(fox, me) {
|
||||||
|
t.Error("a station answering us was judged uncallable")
|
||||||
|
}
|
||||||
|
// Held as the target, that line must not release it — it is the answer we
|
||||||
|
// were waiting for, not a station working somebody else.
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("HK0/PY8WW", NeedDXCC, -17, watched)))
|
||||||
|
if a := e.OnPeriod(period(2, fox)); a.Kind != DoNothing || e.Target() != "HK0/PY8WW" {
|
||||||
|
t.Errorf("dropped the DX as it answered us: %+v (target %q)", a, e.Target())
|
||||||
|
}
|
||||||
|
// The exchange is under way, so the attempt counter has done its job.
|
||||||
|
if e.Status().Attempts != 0 {
|
||||||
|
t.Errorf("attempts = %d while in QSO, want 0", e.Status().Attempts)
|
||||||
|
}
|
||||||
|
// And its final frame to us, in the same shape, ends the QSO.
|
||||||
|
done := fox
|
||||||
|
done.Msg = "IK2ABC RR73; " + me + " <HK0/PY8WW> RR73"
|
||||||
|
if !finished([]Candidate{done}, "HK0/PY8WW", me) {
|
||||||
|
t.Error("a 73 to us inside a multi-answer line did not end the QSO")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reported from the air twice: the DX sends RR73, our own 73 is about to go
|
||||||
|
// out, and the engine answered somebody else in that very slot — two
|
||||||
|
// transmissions in one period, and the station at the other end never got the
|
||||||
|
// frame that closes the contact.
|
||||||
|
func TestTheSlotAfterAQSOBelongsToOur73(t *testing.T) {
|
||||||
|
e := on()
|
||||||
|
e.OnPeriod(period(0, cq("HP/WE9G", NeedDXCC, -6)))
|
||||||
|
bye := callsMe("HP/WE9G", NeedDXCC, -6)
|
||||||
|
bye.Msg = "<" + me + "> HP/WE9G RR73"
|
||||||
|
// A new band is decoding in the same period and is not called.
|
||||||
|
a := e.OnPeriod(period(2, bye, cq("GW8DX", NeedBand, +2)))
|
||||||
|
if a.Kind != DoNothing {
|
||||||
|
t.Fatalf("called %+v in the slot our 73 goes out in", a)
|
||||||
|
}
|
||||||
|
if !strings.Contains(a.Reason, "73") {
|
||||||
|
t.Errorf("reason %q does not say why the slot was left alone", a.Reason)
|
||||||
|
}
|
||||||
|
// It is still there fifteen seconds later, which is the whole cost.
|
||||||
|
if a := e.OnPeriod(period(4, cq("GW8DX", NeedBand, +2))); a.Kind != DoReply || a.Decode.Call != "GW8DX" {
|
||||||
|
t.Errorf("next period: %+v, want the new band called", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user