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:
+177
-15
@@ -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{}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user