// Package autocall answers FT8/FT4 decodes without the operator clicking them. // // THIS KEYS THE TRANSMITTER ON ITS OWN, which is why the decision lives here, // in one place, as a function of state that can be read, argued with and // tested — rather than spread through a panel that only runs while its tab is // open. Everything below is written as a series of refusals: a call goes out // only when nothing says it should not. // // ── What it is for ──────────────────────────────────────────────────────── // On a busy band the operator cannot read twenty decodes, judge each against // the log and click the right one inside a fifteen-second slot. This does that // part: it ranks what is on the air by what the log still needs, calls the best // one, and — the harder half — knows when to STOP calling it. // // ── The ladder ──────────────────────────────────────────────────────────── // A watched callsign outranks the same need from anybody else, at every level: // // WL new DXCC > new DXCC > WL new band > new band > WL new mode > new mode // > WL new slot > new slot > watched with nothing needed // // Watching a callsign is the operator saying "this one matters more than the // rule", so it lifts a station by one rung rather than jumping the whole // ladder: a watched new-slot must not outrank a new entity that will not come // back. // // ── Why it stops ────────────────────────────────────────────────────────── // The failure that matters is not calling the wrong station, it is calling one // station for ever. Four independent brakes, any of which releases the target: // // - attempts: 7 calls, or 15 for a watched callsign // - misses: 3 periods IN WHICH IT TRANSMITS with no decode of it // - the clock: a target held longer than MaxHold is dropped whatever the // counters say, because a counter that never advances never trips // - rounds: a callsign released this way is rested, and after MaxRounds // series it is parked for the session // // A released station is not banned. It can be picked again once rested, but // only while nothing of HIGHER rank is callable — which is what keeps "seven // calls, then straight back to the same station" from being fifty calls. // // ── Who is callable ─────────────────────────────────────────────────────── // A station in the middle of an exchange with somebody else is never targeted. // It cannot answer, and WSJT-X and JTDX ignore a reply to it anyway; only the // station calling CQ, calling US, or sending its last frame is worth a slot. package autocall import ( "fmt" "regexp" "sort" "strings" "time" "unicode" ) // Need is what the log still wants from a station, worst to best. type Need int const ( NeedNone Need = iota NeedSlot // entity worked on this band and in this mode, never together NeedMode // entity never worked in this mode NeedBand // entity never worked on this band NeedDXCC // entity never worked at all ) func (n Need) String() string { switch n { case NeedDXCC: return "DXCC" case NeedBand: return "band" case NeedMode: return "mode" case NeedSlot: return "slot" } return "-" } // Decode is one line from the decoder, carrying both what the decision needs // and what a reply has to send back untouched. type Decode struct { Call string Band string Mode string Msg string SNR int At time.Time TRPeriod int // slot length in seconds; 0 means "work it out from the mode" Instance string CQ bool // Replay of the decoder's own line. WSJT-X matches a Reply against its // decode list field for field, so these are passed through unread. Ms uint32 DT float64 AudioHz int64 ModeRaw string MsgRaw string LowConf bool // IsNew is false for a decode the sender replayed from its history. Shown // in the panel, never answered: the station may have gone hours ago. IsNew bool } // Candidate is a decode with the log's verdict on it attached. 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 // leaves the entity flagged as still wanted. Worked bool } // TXState is what the decoding application says it is doing. type TXState struct { Transmitting bool Enabled bool // its own Enable Tx: nothing we send transmits while false DXCall string Msg string Instance string } // Settings are the operator's. type Settings struct { Enabled bool // Only is the "chase these callsigns" field: one or more, separated by // spaces or commas. Filled, nothing else is ever called — the ladder still // orders the ones listed, so "VP6D 3Y0J" calls whichever of the two is on // the air and takes the better catch when both are. Wildcards are the watch // list's job; this is a hunt list for right now. // // The brakes are unchanged, and hitting one stops the feature rather than // moving on to somebody else — an explicit request deserves an explicit // restart. Only string // Attempts before a target is released, and the larger allowance for a // watched callsign. Attempts int WatchedAttempts int // Misses is how many of the station's OWN transmit periods may pass with no // decode of it before it is given up on. Misses int // Rest is how long a released callsign waits before it can be picked again, // and MaxRounds how many such series it gets before being parked for the // session. Rest time.Duration 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. func Defaults() Settings { return Settings{ Attempts: 7, WatchedAttempts: 15, Misses: 3, Rest: 2 * time.Minute, MaxRounds: 3, MaxHold: 4 * time.Minute, } } func (s Settings) withDefaults() Settings { d := Defaults() if s.Attempts <= 0 { s.Attempts = d.Attempts } if s.WatchedAttempts <= 0 { s.WatchedAttempts = d.WatchedAttempts } if s.Misses <= 0 { s.Misses = d.Misses } if s.Rest <= 0 { s.Rest = d.Rest } if s.MaxRounds <= 0 { s.MaxRounds = d.MaxRounds } if s.MaxHold <= 0 { s.MaxHold = d.MaxHold } return s } // ActionKind is what the caller must do about the decision. type ActionKind int const ( DoNothing ActionKind = iota DoReply // answer this decode DoHalt // stop the decoder transmitting ) // Action is the decision, with the reason in plain words. The reason is not // decoration: it is the only way an operator can tell an auto-call that is // working from one that is stuck, and it goes to the log line by line. 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 // caller owns the serialisation, which in OpsLog is the UDP event loop. type Engine struct { set Settings // target is the station being called, held as the decode last seen of it so // a reply always carries a fresh timestamp. target *Candidate // 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 // waiting is the station the engine WOULD call and cannot, because it is in // a QSO with somebody else. Kept so the toolbar can say so: an auto-call // deliberately holding its fire looks exactly like one with nothing to do, // and from the outside there was no way to tell them apart. waiting string // answered says the target has replied to us at least once — the exchange is // under way and nothing may take its place. answered bool // 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 // forward, which is exactly the case the backstop exists for. heldSince time.Time attempts int misses int lastMiss string // period already counted, so one period counts once txSlot int // which of the two slots the target transmits in; -1 unknown stopped bool // an explicit "Only" target gave up: needs a restart stoppedOn string // rested says when a released callsign may be considered again, and rounds // 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 // logged when the operator (or the watcher) gets to it, several periods // later. In between, the station we have just worked is still flagged as a // new entity and is still on the air sending its 73 — which read as a // perfectly good station to call, and the engine called it straight back. done map[string]bool } 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{}, greyed: map[string]bool{}} } // SetSettings swaps the settings in place. A target already being called is // kept: the operator adjusting a limit is not asking to abandon the QSO in // flight, and switching the feature off is a separate call. func (e *Engine) SetSettings(s Settings) { e.set = s.withDefaults() } // Target is the callsign being called, for the panel. Empty when idle. func (e *Engine) Target() string { if e.target == nil { return "" } return e.target.Call } // Status is what the panel shows: who, how far into the brakes, and whether // the engine has given up and is waiting for the operator. type Status struct { Target string `json:"target"` // Waiting: wanted, decoded, and in a QSO with somebody else. Waiting string `json:"waiting"` Attempts int `json:"attempts"` Max int `json:"max"` Misses int `json:"misses"` MaxMiss int `json:"max_miss"` Stopped bool `json:"stopped"` StoppedOn string `json:"stopped_on"` } func (e *Engine) Status() Status { st := Status{Misses: e.misses, MaxMiss: e.set.Misses, Stopped: e.stopped, StoppedOn: e.stoppedOn, Waiting: e.waiting} if e.target != nil { st.Target = e.target.Call st.Attempts = e.attempts st.Max = e.maxAttempts(*e.target) } return st } // Reset clears everything except the settings — the operator's Halt, a profile // change, or switching the feature off and on again. func (e *Engine) Reset() { e.target, e.attempts, e.misses, e.txSlot = nil, 0, 0, -1 e.targetInst = "" e.lastMiss, e.stopped, e.stoppedOn = "", false, "" e.rested, e.rounds = map[string]time.Time{}, map[string]int{} 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. func (e *Engine) Take(c Candidate) { e.target, e.targetInst = &c, c.Instance e.heldSince = c.At e.attempts, e.misses, e.txSlot, e.lastMiss = 0, 0, -1, "" e.stopped, e.stoppedOn = false, "" } // maxAttempts is the allowance for one target: larger for a watched callsign, // because that is the operator saying this one is worth the extra slots. func (e *Engine) maxAttempts(c Candidate) int { if c.Watched { return e.set.WatchedAttempts } return e.set.Attempts } // rank is the ladder, as one number. // // 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 { r := int(c.Need) * 4 // slot 4, mode 8, band 12, DXCC 16 if c.Need != NeedNone && !c.Unconfirmed { r += 2 } if c.Watched { // Above every unwatched station, whatever the log makes of either. return 100 + r } return r } // onlyList splits the chase field. Space or comma, either way: an operator // typing a list types it the way they think of it, and a field that silently // ignores half of what was entered is worse than one that refuses it. func onlyList(field string) []string { out := []string{} for _, tok := range strings.FieldsFunc(strings.ToUpper(field), func(r rune) bool { return r == ',' || r == ';' || unicode.IsSpace(r) }) { if tok = strings.TrimSpace(tok); tok != "" { out = append(out, tok) } } return out } func inList(list []string, call string) bool { for _, c := range list { if c == call { return true } } return false } var gridRe = regexp.MustCompile(`^[A-R]{2}[0-9]{2}([A-X]{2})?$`) // isGrid is the grid test with the exchange tokens taken out first. // // "RR73" IS a valid Maidenhead square by the pattern — two letters in A-R, two // digits — and it is also the second most common thing to find in that position // of a message. Counting it as a fresh call spent an attempt on every QSO the // engine actually completed, which is precisely backwards: a station that // answers should cost nothing. func isGrid(tok string) bool { switch tok { case "RR73", "RRR", "73", "RR", "R": return false } 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)), ";") } // bare strips the angle brackets a decoder puts round a compressed callsign, // and upper-cases what is left. "" and "HP/WE9G" are one station, and // every comparison in here has to agree about that. func bare(call string) string { return strings.Trim(strings.ToUpper(strings.TrimSpace(call)), "<>") } // 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 bare(toks[0]) == bare(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, // and WSJT-X and JTDX refuse to act on a reply to it at all — so calling it is // at best a wasted slot and at worst the operator watching an auto-call that // appears to do nothing. CQ, a message addressed to us, and the final frame of // somebody else's QSO (the station is free from the next period) are the three // states worth a call. func callable(c Candidate, myCall string) bool { if c.CQ { return true } parts := messages(c.Msg) if len(parts) == 0 { return false // nothing to read: assume it is busy rather than call blind } 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 } // callingUs is the stronger half of callable: the station has our callsign in // its message, so it has heard us and is waiting for an answer. func callingUs(c Candidate, myCall string) bool { if myCall == "" || c.CQ { return false } 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 // last frame. Read from ITS message rather than from our own transmit state, // which says only what we did. func finished(decodes []Candidate, call, myCall string) bool { if myCall == "" { return false } me := strings.ToUpper(myCall) call = strings.ToUpper(call) for _, c := range decodes { if strings.ToUpper(c.Call) != call { continue } // 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 } // slotOf is which of the two alternating slots a moment falls in. FT8 stations // transmit in one and listen in the other, so a station is legitimately absent // half the time — counting that as a miss dropped a perfectly workable station // after two unlucky periods. func slotOf(at time.Time, trSec int) int { if trSec <= 0 { trSec = 15 } return int((at.UTC().Unix() / int64(trSec)) % 2) } // Period is one slot's worth of decodes, with the state of the world around it. type Period struct { // Instance is the receiver this period came from. With two decoders running // — the split view, two bands — their slots are separate: a station absent // from the OTHER receiver's period says nothing about the one being called // on this one, and counting it as a miss dropped a target that was being // decoded perfectly well. Instance string // Key identifies the period, so a handler that runs twice for one slot // cannot count the same miss twice. Key string At time.Time TRPeriod int Decodes []Candidate TX TXState MyCall string } // OnPeriod is the decision, taken once per receive period. 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, // a period from any OTHER receiver is not even looked at. It cannot start a // second QSO over the top of the one in progress, however much better the // station it hears is, and its periods count no missed periods against a // target that was never on its band. if e.target != nil && e.targetInst != "" && p.Instance != "" && p.Instance != e.targetInst { return Action{} } // ── An existing target ──────────────────────────────────────────────── if e.target != nil { t := *e.target tc := strings.ToUpper(t.Call) // HAS IT ANSWERED US? Settled first, before any of the checks below can // decide to leave it. // // The reply lands in the same period the ladder is re-read, and that // period used to be judged with answered still false — so a station // answering our CQ-call was dropped for a better-ranked one in the exact // period it came back to us. Watched from the shack: mid-exchange with // V31MA, report sent, and OpsLog switched to a station calling on the // other side of the screen. A QSO in progress outranks the ladder. if seen := bestOf(p.Decodes, tc); seen != nil && callingUs(*seen, p.MyCall) { e.answered = true } // The exchange is over — either the station sent us its last frame, or // the log now holds it. Hand straight on to the next station in the same // 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) // 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 // a particular shape; this one does not depend on anything. if !e.heldSince.IsZero() && p.At.Sub(e.heldSince) > e.set.MaxHold { e.giveUp(tc, t) 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]) } // SOMETHING BETTER HAS COME ON THE AIR. // // A target is held until it answers or a brake releases it, and that is // right once an exchange has started — but before it has, holding means // spending the next two minutes on a new slot while the watched // DXpedition calls CQ three rows above it. Which is what an operator // sees: the first CQ ignored, then the second one answered because the // other call had run its course. // // So while the target has NOT answered us, a strictly better station // takes its place. "Better" is the ladder, which does not flicker: a // station's need does not change from period to period, so this cannot // oscillate between two of them — only a genuinely higher rung wins. if !e.answered { if a, ok := e.preempt(p); ok { return a } } 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. e.target = seen e.misses, e.lastMiss = 0, "" e.txSlot = slotOf(p.At, periodSecs(p, *seen)) // In QSO: the decoder is sequencing the exchange on its own and the // attempt counter has done its job. Interrupting it with another // reply is how two transmissions land in one slot. if callingUs(*seen, p.MyCall) { e.attempts = 0 e.answered = true // 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) { why := fmt.Sprintf("%s is working %s — it cannot answer", tc, addressee(seen.Msg)) e.drop() // AND THE SLOT IS STILL FREE. This period's decodes are in hand, // so the next station is picked from them now instead of fifteen // seconds from now — the operator watching the screen sees a CQ // two rows down go unanswered for a whole period, and reads that // as the engine having missed it. // // Never while transmitting: a reply then switches the decoder's // call mid-over and cuts our own transmission in half, which is // the whole reason the halt below is Soft. if !p.TX.Transmitting { if a := e.pick(p, why); a.Kind == DoReply { return a } } return Action{Kind: DoHalt, Soft: true, Reason: why} } return Action{} } // Absent. Only its OWN transmit periods count against it — and never one // of ours: our transmission is the reason it is not there. if p.TX.Transmitting { return Action{} } if e.txSlot >= 0 && slotOf(p.At, periodSecs(p, t)) != e.txSlot { return Action{} } if e.lastMiss == p.Key { return Action{} } e.lastMiss = p.Key e.misses++ if e.misses >= e.set.Misses { e.giveUp(tc, t) return Action{Kind: DoHalt, Reason: fmt.Sprintf("%s not decoded for %d of its own periods", tc, e.set.Misses)} } return Action{} } // ── No target ───────────────────────────────────────────────────────── if e.stopped { return Action{} // an explicit target gave up; the operator restarts it } 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 // The best station that is wanted, decoded, and working somebody else. It // is not a candidate — it cannot answer — but it is the reason the engine // is silent, and the operator is entitled to know that. waiting, waitingRank := "", 0 for i := range p.Decodes { c := p.Decodes[i] if ok, why := e.judge(c, p, only); !ok { if why == "busy" { if r := rank(c); r > waitingRank { waiting, waitingRank = strings.ToUpper(c.Call), r } } continue } r := rank(c) if best == nil || better(c, r, *best, bestRank, p.MyCall) { cc := c best, bestRank = &cc, r } } e.waiting = waiting if best == nil { if waiting != "" { return Action{Kind: DoNothing, Reason: fmt.Sprintf("waiting for %s — it is working somebody else", waiting)} } if why != "" { return Action{Kind: DoNothing, Reason: why + " — nothing else worth calling"} } return Action{} } e.waiting = "" e.target, e.heldSince, e.targetInst = best, p.At, best.Instance // ITS SLOT IS KNOWN FROM THE DECODE THAT MADE IT A CANDIDATE. // // This used to start at -1, meaning "parity unknown", and with parity // unknown the miss counter has nothing to filter on: every period without // the station counted, INCLUDING the one we spend transmitting to it, where // it cannot be decoded by definition. An operator answering a CQ saw the // counter at two misses out of three before the station had had a single // chance to come back — one bad period from being given up on. e.attempts, e.misses, e.lastMiss = 0, 0, "" e.txSlot = slotOf(p.At, periodSecs(p, *best)) e.answered = false e.waiting = "" reason := fmt.Sprintf("calling %s (%s%s)", best.Call, watchedTag(*best), best.Need) if why != "" { reason = why + " — " + 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{} // The callsigns too, not only the counts. "worked=2" answers "how many" and // leaves "which" — and which is the whole question when an operator is // looking at one station on the screen and asking why it was passed over. who := map[string][]string{} 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]++ if len(who[why]) < 4 { who[why] = append(who[why], p.Decodes[i].Call) } } } 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 { names := strings.Join(who[k], ",") if n > len(who[k]) { names += ",…" } why = append(why, fmt.Sprintf("%s=%d(%s)", k, n, names)) } } 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 " } return "" } // 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, "self" } if !c.IsNew { 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. 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, "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, "nothing-needed" } if e.rounds[call] >= e.set.MaxRounds { 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. if !callable(c, p.MyCall) { return false, "busy" } return true, "" } // better orders two eligible stations: the need first, then the one already // calling us, then a CQ over a station about to be free, then the strongest. func better(a Candidate, ra int, b Candidate, rb int, myCall string) bool { if ra != rb { return ra > rb } if x, y := callingUs(a, myCall), callingUs(b, myCall); x != y { return x } if a.CQ != b.CQ { return a.CQ } return a.SNR > b.SNR } // preempt hands the slot to a better station, if one is on the air. func (e *Engine) preempt(p Period) (Action, bool) { if e.target == nil || p.TX.Transmitting { // Mid-over: the reply would switch the decoder's call in the middle of // a transmission. It waits for the gap, like every other call. return Action{}, false } held := rank(*e.target) heldSeen := bestOf(p.Decodes, strings.ToUpper(e.target.Call)) != nil only := onlyList(e.set.Only) best, bestRank := (*Candidate)(nil), held for i := range p.Decodes { c := p.Decodes[i] if strings.EqualFold(c.Call, e.target.Call) || !e.eligible(c, p, only) { continue } // Strictly better takes over. So does an EQUAL rung when the station we // are calling is not even on the air this period: the seven calls are // going into the void while a station of the same value is calling CQ // three rows above it, which is what an operator sees and cannot explain. r := rank(c) switch { case r > bestRank: case r == bestRank && !heldSeen && best == nil: case best != nil && r == bestRank && better(c, r, *best, bestRank, p.MyCall): default: continue } { cc := c best, bestRank = &cc, r } } if best == nil { return Action{}, false } was := strings.ToUpper(e.target.Call) e.drop() e.target, e.heldSince, e.targetInst = best, p.At, best.Instance return Action{Kind: DoReply, Decode: best.Decode, Reason: fmt.Sprintf("%s (%s%s) takes over from %s — nothing had been answered yet", best.Call, watchedTag(*best), best.Need, was)}, true } // 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, e.answered = "", false } // 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 bare(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) { e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, "" e.targetInst, e.answered = "", false if !gaveUp { // A finished QSO is not a failed series — the callsign keeps its rounds — // but it IS finished: nothing more is wanted from this station today, // whatever the log still says while it catches up. delete(e.rounds, call) e.done[call] = true } } func (e *Engine) giveUp(call string, t Candidate) { e.release(call, true) e.rounds[call]++ 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) != "" { e.stopped, e.stoppedOn = true, call } } // NoteTX counts what actually goes on the air, and is the ONLY place attempts // are counted or capped. // // Counted here rather than where the reply is sent, because those are different // things: a reply may be refused by the decoder, and the decoder transmits // several times per QSO on its own. Capped here too — the period handler used // to cap as well, and a target could sit at twenty-four calls while none of its // conditions were met. One place that counts and stops cannot overshoot. // // A FRESH call only: the third token of an FT8 call is a grid, where the rest // of the exchange carries a report, R-report or 73. Without that, the answer to // "how many times have we called this station" counted the whole QSO. func (e *Engine) NoteTX(tx TXState) Action { if !e.set.Enabled || e.target == nil || !tx.Transmitting { return Action{} } // The OTHER decoder transmitting is not us calling this station: in a split // view both are on the air, and counting both spent the seven calls in half // the time — on a target the other receiver had never heard of. if e.targetInst != "" && tx.Instance != "" && tx.Instance != e.targetInst { return Action{} } t := *e.target tc := bare(t.Call) msg := strings.ToUpper(strings.TrimSpace(tx.Msg)) switch { case msg != "": toks := strings.Fields(msg) // bare(): a decoder compresses a non-standard callsign into angle // brackets — " F4BPO JN36" is a call to HP/WE9G — and comparing // the raw token left the counter at 0/15 while the operator watched call // after call go out. Every brake downstream of it was dead too. if len(toks) < 3 || bare(toks[0]) != tc { return Action{} } if !isGrid(toks[2]) { // A report, an R-report, RR73: we are INSIDE the exchange, not // opening it. It does not count as a call — and it settles that // nothing may take this station's place, which matters when the // decoder is answering somebody the engine never picked (the // operator double-clicked a row) and no reply has been decoded yet. e.answered = true return Action{} } default: // JTDX reports no transmit message. The DX call is all there is, so // every transmit period aimed at the target counts — more generous, and // far better than a counter frozen at zero for ever. if bare(tx.DXCall) != tc { return Action{} } } e.attempts++ if e.attempts < e.maxAttempts(t) { return Action{} } max := e.maxAttempts(t) e.giveUp(tc, t) 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. // // A station running several streams at once — a DXpedition answering four // callers — appears several times in one period: a report to one, RR73 to // another, a CQ to the band. Judging it on whichever line came first reads a // station that is free right now as busy. func bestOf(decodes []Candidate, call string) *Candidate { var best *Candidate for i := range decodes { c := decodes[i] if strings.ToUpper(c.Call) != call { continue } if best == nil || (c.CQ && !best.CQ) || (c.CQ == best.CQ && c.SNR > best.SNR) { cc := c best = &cc } } return best } // workedNow reports that the log has caught up with a station mid-series — the // QSO was logged, by this or by another hand. func workedNow(decodes []Candidate, call string) bool { for _, c := range decodes { if strings.ToUpper(c.Call) == call && c.Worked { return true } } return false } func periodSecs(p Period, c Candidate) int { if c.TRPeriod > 0 { return c.TRPeriod } if p.TRPeriod > 0 { return p.TRPeriod } return 15 } // TargetInstance names the receiver the current target is being called on, and // the callsign. Both empty when idle. func (e *Engine) TargetInstance() (instance, call string) { if e.target == nil { return "", "" } return e.targetInst, e.target.Call }