fix(autocall): the QSO in progress outranks the ladder

Six faults from an evening on 60 m, all in the same family: the engine
judging a station by what the log wants from it and forgetting what is
already under way.

- An exchange was abandoned mid-QSO. The reply lands in the same period
  the ladder is re-read, and that period was judged before the reply was
  taken into account, so a better-ranked caller took the slot from a
  station that had just come back to us. The answer is settled first now,
  and our own report counts as being inside the exchange too — which also
  protects a QSO the operator started by hand.
- A station just picked started with misses against it. Its transmit slot
  was unknown until a second decode, and with the parity unknown every
  period counted, including the one spent transmitting to it.
- The freed slot after "it is working somebody else" was thrown away: the
  period's decodes are in hand, so the next station is picked from them
  rather than fifteen seconds later. Never mid-over.
- Auto-call is never armed from a stored setting — not at launch, not on
  a profile switch. It is the one feature that puts the station on the
  air by itself and OpsLog starts with Windows.
- It says what it is waiting for: a wanted station in a QSO with somebody
  else now shows beside the Auto button instead of looking idle.
- Switching profile left the previous logbook's verdicts on screen. The
  worked-index, chase-new and the frontend's cached verdicts are dropped
  when the logbook changes.

FT decodes: distance column, a message addressed to you set whole in
green (the station you are calling keeps a tint — most of what it sends
goes to other people), badge order L / Wkd / WL, list cleared when the
RIG changes band.

Rotor: new world-map compass from EC1KD's design, with the Ultrabeam boom
and second lobe restored and the compact form preserved; the classic dial
is kept and Settings → Rotator chooses between them. Stop no longer
flickers on a rotor standing still — movement was inferred from a degree,
less than the jitter a controller reports at rest.
This commit is contained in:
2026-09-05 23:02:31 +02:00
parent 8dbc4b7e62
commit 23323c91e0
14 changed files with 1752 additions and 389 deletions
+177 -15
View File
@@ -254,6 +254,14 @@ type Engine struct {
// 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
@@ -316,7 +324,9 @@ func (e *Engine) Target() string {
// 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"`
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"`
@@ -326,7 +336,8 @@ type Status struct {
}
func (e *Engine) Status() Status {
st := Status{Misses: e.misses, MaxMiss: e.set.Misses, Stopped: e.stopped, StoppedOn: e.stoppedOn}
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
@@ -485,6 +496,13 @@ 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. "<HP/WE9G>" 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.
@@ -493,7 +511,7 @@ func addressedTo(part, call string) bool {
if len(toks) == 0 || call == "" {
return false
}
return strings.Trim(toks[0], "<>") == strings.ToUpper(call)
return bare(toks[0]) == bare(call)
}
// callable reports whether a station can be answered RIGHT NOW.
@@ -624,6 +642,19 @@ func (e *Engine) OnPeriod(p Period) Action {
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.
@@ -657,6 +688,24 @@ func (e *Engine) OnPeriod(p Period) Action {
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.
@@ -668,6 +717,7 @@ func (e *Engine) OnPeriod(p Period) Action {
// 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
@@ -689,14 +739,32 @@ func (e *Engine) OnPeriod(p Period) Action {
// 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()
return Action{Kind: DoHalt, Soft: true,
Reason: fmt.Sprintf("%s is working %s — it cannot answer", tc, addressee(seen.Msg))}
// 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.
// 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{}
}
@@ -744,9 +812,18 @@ func (e *Engine) pick(p Period, why string) 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 !e.eligible(c, p, only) {
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)
@@ -755,14 +832,31 @@ func (e *Engine) pick(p Period, why string) Action {
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
e.attempts, e.misses, e.txSlot, e.lastMiss = 0, 0, -1, ""
// 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
@@ -776,6 +870,10 @@ func (e *Engine) pick(p Period, why string) Action {
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
@@ -786,6 +884,9 @@ func (e *Engine) tracePick(p Period) {
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 })
@@ -797,7 +898,11 @@ func (e *Engine) tracePick(p Period) {
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))
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",
@@ -913,12 +1018,56 @@ func better(a Candidate, ra int, b Candidate, rb int, myCall string) bool {
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.targetInst, e.answered = "", false
}
// addressee is who a message is being sent to — the first token, which is the
@@ -933,14 +1082,14 @@ func addressee(msg string) string {
if len(toks) == 0 {
return "someone else"
}
return strings.Trim(toks[0], "<>")
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.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,
@@ -988,19 +1137,32 @@ func (e *Engine) NoteTX(tx TXState) Action {
return Action{}
}
t := *e.target
tc := strings.ToUpper(t.Call)
tc := bare(t.Call)
msg := strings.ToUpper(strings.TrimSpace(tx.Msg))
switch {
case msg != "":
toks := strings.Fields(msg)
if len(toks) < 3 || toks[0] != tc || !isGrid(toks[2]) {
// bare(): a decoder compresses a non-standard callsign into angle
// brackets — "<HP/WE9G> 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 strings.ToUpper(strings.TrimSpace(tx.DXCall)) != tc {
if bare(tx.DXCall) != tc {
return Action{}
}
}
+122
View File
@@ -795,3 +795,125 @@ func TestTheSlotAfterAQSOBelongsToOur73(t *testing.T) {
t.Errorf("next period: %+v, want the new band called", a)
}
}
func TestABetterStationTakesOverBeforeAnybodyHasAnswered(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("PLAIN", NeedSlot, +10)))
// A watched station comes on the air: nothing has answered us yet, so the
// call in progress is worth less than the one now possible.
a := e.OnPeriod(period(2, cq("PLAIN", NeedSlot, +10), cq("WATCHED", NeedNone, -15, watched)))
if a.Kind != DoReply || a.Decode.Call != "WATCHED" {
t.Fatalf("%+v — a better station did not take over", a)
}
// Equal rungs do NOT take over while the station being called is on the air.
e = on()
e.OnPeriod(period(4, cq("A", NeedBand, 0)))
if a := e.OnPeriod(period(6, cq("A", NeedBand, 0), cq("B", NeedBand, +20))); a.Kind != DoNothing {
t.Errorf("%+v — swapped between two stations of equal value", a)
}
// But they do when it is absent: seven calls into the void while a station
// of the same value is calling CQ is what the operator was watching.
if a := e.OnPeriod(period(8, cq("B", NeedBand, +20))); a.Kind != DoReply || a.Decode.Call != "B" {
t.Errorf("%+v — kept calling a station that was not on the air", a)
}
// Once the station has answered, nothing takes its place.
e = on()
e.OnPeriod(period(10, cq("DX", NeedSlot, 0)))
e.OnPeriod(period(12, callsMe("DX", NeedSlot, 0)))
if a := e.OnPeriod(period(14, callsMe("DX", NeedSlot, 0), cq("RARE", NeedDXCC, 0, watched))); a.Kind != DoNothing {
t.Errorf("%+v — abandoned an exchange in progress", a)
}
}
// TestAnExchangeInProgressIsNeverAbandoned is the shack report: mid-QSO with
// V31MA — its report decoded, our RR73 going out — and a station of a higher
// rung called us from the other side of the screen. The engine switched.
func TestAnExchangeInProgressIsNeverAbandoned(t *testing.T) {
e := on()
// V31MA is worth nothing on the ladder: worked before, nothing needed.
if a := e.OnPeriod(period(0, callsMe("V31MA", NeedNone, -12))); a.Kind != DoReply {
t.Fatalf("%+v — a station calling us was not answered", a)
}
// Its report arrives in the same period a better station calls us. That
// period used to be judged before the reply was taken into account.
a := e.OnPeriod(period(2,
callsMe("V31MA", NeedNone, -12),
callsMe("F5NNN", NeedSlot, -10)))
if a.Kind != DoNothing {
t.Fatalf("%+v — left V31MA mid-exchange", a)
}
if e.target == nil || e.target.Call != "V31MA" {
t.Fatalf("target is %v — the QSO in progress lost its place", e.target)
}
// Our own transmission settles it too: a report is not an opening call, so
// even a QSO the operator started by hand is protected.
e = on()
e.OnPeriod(period(4, cq("V31MA", NeedNone, -12, watched)))
e.NoteTX(TXState{Transmitting: true, Msg: "V31MA F4BPO RR73"})
if !e.answered {
t.Error("sending a report did not count as being inside the exchange")
}
if a := e.OnPeriod(period(6, cq("V31MA", NeedNone, -12, watched), callsMe("F5NNN", NeedDXCC, -10))); a.Kind != DoNothing {
t.Errorf("%+v — abandoned a QSO we were in the middle of", a)
}
if e.attempts != 0 {
t.Errorf("attempts=%d — an exchange frame was counted as a call", e.attempts)
}
}
func TestTheSlotIsNotWastedWhenTheTargetTurnsOutToBeBusy(t *testing.T) {
e := on()
if a := e.OnPeriod(period(0, cq("DX", NeedBand, -7))); a.Kind != DoReply {
t.Fatalf("%+v", a)
}
// It answers somebody else, and a station of the same value is calling CQ
// in the very same period. Waiting for the next one throws away a slot.
a := e.OnPeriod(period(2, busy("DX", NeedBand, -7), cq("ER1CW", NeedBand, -8)))
if a.Kind != DoReply || a.Decode.Call != "ER1CW" {
t.Fatalf("%+v — the freed slot was not used", a)
}
if !strings.Contains(a.Reason, "cannot answer") || !strings.Contains(a.Reason, "calling ER1CW") {
t.Errorf("reason %q says neither what was left nor what was taken", a.Reason)
}
// Mid-over, it still waits: cutting our own transmission in half is worse
// than losing the slot.
e = on()
e.OnPeriod(period(4, cq("DX", NeedBand, -7)))
pp := period(6, busy("DX", NeedBand, -7), cq("ER1CW", NeedBand, -8))
pp.TX = TXState{Transmitting: true}
if a := e.OnPeriod(pp); a.Kind != DoHalt || !a.Soft {
t.Errorf("%+v — replied over our own transmission", a)
}
}
// TestTheFirstCallStartsWithNoMisses is the shack report: a CQ answered for the
// first time, and the toolbar already reading two misses out of three.
func TestTheFirstCallStartsWithNoMisses(t *testing.T) {
e := on()
// It calls CQ on an odd slot; we answer.
if a := e.OnPeriod(period(1, cq("ER1CW", NeedBand, -2))); a.Kind != DoReply {
t.Fatalf("%+v", a)
}
// The next period is OURS: we are transmitting to it, and of course it is
// not decoded. That is not a miss.
pp := period(2)
pp.TX = TXState{Transmitting: true}
e.OnPeriod(pp)
if e.misses != 0 {
t.Fatalf("misses=%d after our own transmit period", e.misses)
}
// Nor is a period of the wrong parity with nothing in it.
e.OnPeriod(period(4))
if e.misses != 0 {
t.Fatalf("misses=%d — counted a period the station never transmits in", e.misses)
}
// ITS period, and it is not there: that is a miss.
e.OnPeriod(period(3))
if e.misses != 1 {
t.Fatalf("misses=%d — the station's own silent period was not counted", e.misses)
}
}