diff --git a/autocall.go b/autocall.go index e8250ce..311025b 100644 --- a/autocall.go +++ b/autocall.go @@ -31,6 +31,8 @@ const ( keyAutoCallMisses = "autocall.misses" keyAutoCallRounds = "autocall.max_rounds" keyAutoCallRestMin = "autocall.rest_min" + keyAutoCallOnScreen = "autocall.on_screen_only" + keyAutoCallTrace = "autocall.trace" ) // 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. MaxRounds int `json:"max_rounds"` 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 { @@ -66,15 +76,21 @@ func (a *App) GetAutoCallSettings() AutoCallSettings { Only: strings.ToUpper(strings.TrimSpace(a.settingOr(keyAutoCallOnly, ""))), Attempts: num(keyAutoCallAttempts, d.Attempts), WatchedAttempts: num(keyAutoCallWatched, d.WatchedAttempts), - Misses: num(keyAutoCallMisses, d.Misses), - MaxRounds: num(keyAutoCallRounds, d.MaxRounds), - RestMin: num(keyAutoCallRestMin, int(d.Rest/time.Minute)), + // 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), + MaxRounds: num(keyAutoCallRounds, d.MaxRounds), + RestMin: num(keyAutoCallRestMin, int(d.Rest/time.Minute)), } } func (a *App) SaveAutoCallSettings(s AutoCallSettings) error { a.setSetting(keyAutoCallOn, map[bool]string{true: "1", false: "0"}[s.Enabled]) 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{ keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts, keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds, @@ -121,7 +137,7 @@ func (a *App) autoCallEngine() *autocall.Engine { func (a *App) autoCallSettings() autocall.Settings { s := a.GetAutoCallSettings() return autocall.Settings{ - Enabled: s.Enabled, Only: s.Only, + Enabled: s.Enabled, Only: s.Only, OnScreenOnly: s.OnScreenOnly, Attempts: s.Attempts, WatchedAttempts: s.WatchedAttempts, Misses: s.Misses, MaxRounds: s.MaxRounds, Rest: time.Duration(s.RestMin) * time.Minute, @@ -135,6 +151,11 @@ func (a *App) applyAutoCall() { e := a.autoCallEngine() s := a.autoCallSettings() 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 { e.Reset() a.acMu.Lock() @@ -144,13 +165,30 @@ func (a *App) applyAutoCall() { a.emitAutoCall() } -// ResetAutoCall is the operator's restart after it gave up — and what Halt -// does, since halting means "not this station". +// ResetAutoCall is the operator's restart after the engine gave up on an +// explicit target: it clears every verdict, including the grey list. func (a *App) ResetAutoCall() { a.autoCallEngine().Reset() 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. type AutoCallStatus struct { Enabled bool `json:"enabled"` @@ -164,6 +202,9 @@ type AutoCallStatus struct { Misses int `json:"misses"` MaxMiss int `json:"max_miss"` 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 // nothing on purpose looks exactly like one that is broken. Reason string `json:"reason"` @@ -179,7 +220,8 @@ func (a *App) GetAutoCallStatus() AutoCallStatus { Enabled: set.Enabled, Only: set.Only, Target: st.Target, Calls: st.Attempts, Max: st.Max, Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped, - Reason: reason, + Greylisted: a.autoCallEngine().Greylisted(), + Reason: reason, } } @@ -234,6 +276,7 @@ func (a *App) autoCallFeed(d autocall.Decode) { a.acMu.Lock() 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.acFed = map[string]time.Time{} } if prev := a.acPeriod[inst]; prev != "" && prev != key { prevAt, prevTR, buf := a.acAt[inst], a.acTR[inst], a.acBuf[inst] @@ -244,10 +287,29 @@ func (a *App) autoCallFeed(d autocall.Decode) { return } 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.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. // // Per receiver, because with two decoders one may fall silent while the other @@ -273,10 +335,11 @@ func (a *App) autoCallSweep() { if tr <= 0 { tr = 15 } - // One slot plus a margin: decodes for a period keep arriving for a - // second or two after it ends, and judging early would count a station - // as missing that is about to be listed. - if time.Since(a.acAt[inst]) < time.Duration(tr)*time.Second+4*time.Second { + // Measured from when the last decode ARRIVED, not from the period it + // belongs to: a decode is stamped with the start of its own slot, so + // waiting "a slot plus four seconds" from that stamp is waiting until + // the middle of the NEXT slot. See acQuiet. + if time.Since(a.acFed[inst]) < acQuiet { continue } 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)) for _, dd := range uniq { st := status[dd.d.Call+"|"+dd.d.Band+"|"+dd.d.Mode] - cands = append(cands, autocall.Candidate{ - Decode: dd.d, - Need: autoCallNeedOf(st.Status), - Watched: a.autoCallWatched(dd.d.Call), - Worked: st.Status == "worked", - }) + c := candidateOf(dd.d, st) + c.Watched = a.autoCallWatched(dd.d.Call) + c.Hidden = a.autoCallHidden(dd.d.Call) + cands = append(cands, c) } tx := a.autoCallTX() @@ -370,6 +431,30 @@ func (a *App) autoCallJudge(inst, key string, at time.Time, tr int, buf []acDeco 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. func (a *App) autoCallDo(act autocall.Action) { 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) } case autocall.DoHalt: - // autoTxOnly=false: stop now. The whole point of a brake is that it does - // not wait for the over in progress to finish. - if err := a.HaltDecodeTx("", false); err != nil { + // Soft: let the over finish, then stop transmitting. Hard: stop now. + // The engine decides — see Action.Soft. + if err := a.HaltDecodeTx("", act.Soft); err != nil { applog.Printf("autocall: halt failed: %v", err) } } @@ -433,6 +518,38 @@ func (a *App) autoCallSetTX(tx autocall.TXState) { 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 // 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 @@ -482,7 +599,11 @@ func (a *App) startAutoCall() { } 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() for range t.C { if a.ctx == nil { diff --git a/autocall_wiring_test.go b/autocall_wiring_test.go new file mode 100644 index 0000000..7da6a98 --- /dev/null +++ b/autocall_wiring_test.go @@ -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) + } +} diff --git a/internal/autocall/autocall.go b/internal/autocall/autocall.go index 3ac4175..a2053bf 100644 --- a/internal/autocall/autocall.go +++ b/internal/autocall/autocall.go @@ -47,6 +47,7 @@ package autocall import ( "fmt" "regexp" + "sort" "strings" "time" "unicode" @@ -109,6 +110,14 @@ type Candidate struct { Decode Need Need 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 // 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 @@ -152,6 +161,19 @@ type Settings struct { MaxRounds int // MaxHold is the wall-clock backstop on one target. 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. @@ -201,6 +223,19 @@ type Action struct { Kind ActionKind Decode Decode 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 @@ -214,6 +249,11 @@ type Engine struct { // targetInst is the receiver the target was picked on, so the brakes are // counted against ITS periods and its transmissions. 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 // 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 @@ -230,6 +270,20 @@ type Engine struct { // how many series it has already had this session. rested map[string]time.Time 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. // // 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 { 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 @@ -287,9 +342,38 @@ func (e *Engine) Reset() { e.targetInst = "" e.lastMiss, e.stopped, e.stoppedOn = "", false, "" 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 // 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. @@ -309,23 +393,37 @@ func (e *Engine) maxAttempts(c Candidate) int { return e.set.Attempts } -// rank is the ladder, as one number. Watched lifts a station by one rung, and -// a watched station with nothing needed sits at the bottom rather than being -// ineligible: the operator asked for that callsign. +// rank is the ladder, as one number. // -// 9 WL DXCC 8 DXCC 7 WL band 6 band -// 5 WL mode 4 mode 3 WL slot 2 slot 1 watched, nothing needed -// 0 nothing to call for +// A WATCHED callsign outranks everything that is not watched. That is what a +// 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 func rank(c Candidate) int { - if c.Need == NeedNone { - if c.Watched { - return 1 - } - return 0 + r := int(c.Need) * 4 // slot 4, mode 8, band 12, DXCC 16 + if c.Need != NeedNone && !c.Unconfirmed { + r += 2 } - r := int(c.Need) * 2 // slot 2, mode 4, band 6, DXCC 8 if c.Watched { - r++ + // Above every unwatched station, whatever the log makes of either. + return 100 + r } return r } @@ -371,6 +469,33 @@ func isGrid(tok string) bool { 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 -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. // // A station in mid-exchange is committed to somebody else: it will not answer, @@ -383,16 +508,22 @@ func callable(c Candidate, myCall string) bool { if c.CQ { return true } - toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg))) - if len(toks) == 0 { + parts := messages(c.Msg) + if len(parts) == 0 { return false // nothing to read: assume it is busy rather than call blind } - if myCall != "" && toks[0] == strings.ToUpper(myCall) { - return true // it is calling us - } - switch toks[len(toks)-1] { - case "RR73", "RRR", "73": - return true + for _, part := range parts { + toks := strings.Fields(part) + if len(toks) == 0 { + continue + } + if addressedTo(part, myCall) { + return true // it is calling us + } + switch toks[len(toks)-1] { + case "RR73", "RRR", "73": + return true + } } return false } @@ -403,8 +534,12 @@ func callingUs(c Candidate, myCall string) bool { if myCall == "" || c.CQ { return false } - toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg))) - return len(toks) > 0 && toks[0] == strings.ToUpper(myCall) + for _, part := range messages(c.Msg) { + if addressedTo(part, myCall) { + return true + } + } + return false } // finished reports that the exchange with this station is over: it sent us the @@ -420,13 +555,17 @@ func finished(decodes []Candidate, call, myCall string) bool { if strings.ToUpper(c.Call) != call { continue } - toks := strings.Fields(strings.ToUpper(strings.TrimSpace(c.Msg))) - if len(toks) < 2 || toks[0] != me { - continue - } - switch toks[len(toks)-1] { - case "RR73", "RRR", "73": - return true + // Every segment: a fox says goodbye to one caller and hello to the next + // in the same transmission. + for _, part := range messages(c.Msg) { + toks := strings.Fields(part) + if len(toks) < 2 || !addressedTo(part, me) { + continue + } + switch toks[len(toks)-1] { + case "RR73", "RRR", "73": + return true + } } } return false @@ -466,6 +605,9 @@ func (e *Engine) OnPeriod(p Period) Action { if !e.set.Enabled { return Action{} } + if !p.At.IsZero() { + e.now = p.At + } // 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, @@ -487,11 +629,20 @@ func (e *Engine) OnPeriod(p Period) Action { // period rather than waiting for the next one: the slot is the resource. if finished(p.Decodes, tc, p.MyCall) || workedNow(p.Decodes, tc) { e.release(tc, false) - // Straight on to the next station, list or no list: pick() is what - // knows the chase list, and with one on it the answer is simply the - // next callsign there — a list of two DXpeditions must not stop - // after the first. - return e.pick(p, fmt.Sprintf("QSO with %s finished", tc)) + // AND NOTHING ELSE THIS PERIOD. The next station is picked on the next + // one, fifteen seconds later, and it is still there. + // + // His RR73 is decoded while we are between overs, so the decoder is + // 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 @@ -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)} } + 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 { // The decode is refreshed so a reply carries a current timestamp; the // 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. if callingUs(*seen, p.MyCall) { 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{} } @@ -536,45 +716,40 @@ func (e *Engine) OnPeriod(p Period) Action { if e.stopped { 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, "") } // 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 { + 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) var best *Candidate 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 { c := p.Decodes[i] if !e.eligible(c, p, only) { continue } 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) { cc := c 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} } +// 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 { if c.Watched { return "watched " @@ -604,30 +814,88 @@ func watchedTag(c Candidate) string { // eligible is every refusal that applies before a station is even ranked. 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)) if call == "" || call == strings.ToUpper(p.MyCall) { - return false + return false, "self" } 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 { // 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 // 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] { - 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 { - return false + return false, "nothing-needed" } 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. - 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 @@ -645,6 +913,29 @@ func better(a Candidate, ra int, b Candidate, rb int, myCall string) bool { 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 // rather than in a QSO. 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) { e.release(call, true) 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 // feature instead of moving on. There is nothing else it was asked to do. if strings.TrimSpace(e.set.Only) != "" { @@ -715,7 +1010,8 @@ func (e *Engine) NoteTX(tx TXState) Action { } max := e.maxAttempts(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. diff --git a/internal/autocall/autocall_test.go b/internal/autocall/autocall_test.go index 521979a..95edd2b 100644 --- a/internal/autocall/autocall_test.go +++ b/internal/autocall/autocall_test.go @@ -2,6 +2,7 @@ package autocall import ( "fmt" + "strings" "testing" "time" ) @@ -56,13 +57,14 @@ func on() *Engine { return New(Settings{Enabled: true}) } // ── The ladder ──────────────────────────────────────────────────────────── 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{ - cq("A", NeedDXCC, 0, watched), cq("B", NeedDXCC, 0), - cq("C", NeedBand, 0, watched), cq("D", NeedBand, 0), - cq("E", NeedMode, 0, watched), cq("F", NeedMode, 0), - cq("G", NeedSlot, 0, watched), cq("H", NeedSlot, 0), + cq("A", NeedDXCC, 0, watched), cq("C", NeedBand, 0, watched), + cq("E", NeedMode, 0, watched), cq("G", NeedSlot, 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++ { 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. 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" { 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) { @@ -232,24 +241,52 @@ func TestAReleasedStationYieldsToAnythingBetter(t *testing.T) { } } -func TestAReleasedStationIsCalledAgainWhenNothingBetterIsOnTheAir(t *testing.T) { +func TestAReleasedStationRestsBeforeItIsCalledAgain(t *testing.T) { e := on() e.OnPeriod(period(0, cq("DX", NeedBand, -5))) tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"} for i := 0; i < 7; i++ { e.NoteTX(tx) } - a := e.OnPeriod(period(2, cq("DX", NeedBand, -5))) - if a.Kind != DoReply || a.Decode.Call != "DX" { - t.Errorf("nothing better on the air and the station was not called again: %+v", a) + // Still the only thing on the air, and still resting: seven calls, a halt, + // and the same station called again four seconds later is not a rest. + 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) { e := on() 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++ { - a := e.OnPeriod(period(round*2, cq("DX", NeedBand, -5))) + a := e.OnPeriod(period(round*10, cq("DX", NeedBand, -5))) if a.Kind != DoReply { 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 // 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) } } @@ -271,9 +308,13 @@ func TestFinishedQSOMovesToTheNextPriority(t *testing.T) { e.OnPeriod(period(0, cq("DX", NeedDXCC, -5))) done := callsMe("DX", NeedDXCC, -5) 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" { - 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))) done := callsMe("DX", NeedDXCC, -5) 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.Watched = true loud := callsMe("LOUD", NeedNone, -4) 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" { t.Errorf("answered %+v, want the strongest of the stations calling us", a) } @@ -403,7 +446,8 @@ func TestChaseListTakesSeveralCallsigns(t *testing.T) { // single request. done := callsMe("VP6D", NeedDXCC, -22) 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" { 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) } } + +// 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 + " -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 + " 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) + } +}