fix(autocall): the entity's verdict is not the station's

Six faults, all found on the air this evening and all in the same
feature. The decision trace added here is what found the first one:
one line per period, saying what was on the air and why each station
was refused.

- "worked" was read from the ENTITY's status, which means the country
  is in the log on this band and mode. On 10 m, where most countries
  are, twenty decodes out of twenty-one were refused as worked — the
  watched DXpedition among them. The entity decides what is NEEDED;
  the callsign's own slot decides whether calling it is a duplicate.
  candidateOf() is split out and tested because one line was wrong for
  weeks and nothing could catch it.

- A multi-answer line was read only up to its first message. MSHV
  answers two stations in one transmission ("YV5ALI RR73; F4BPO
  <HK0/PY8WW> -08") and the second half was a report to us: the engine
  saw a station working somebody else and dropped the target at the
  moment the DX was answering. The decodes panel already read every
  segment.

- The slot after a QSO belongs to our own 73. Handing straight on to
  the next station took it, switching the DX call mid-sequence, and
  the frame that closes the contact never went out whole.

- A station CALLING US is answered whether or not the log wants
  anything from it. It was refused for having nothing to gain, so a
  QSO would end, two stations would call, and both were ignored.

- Callability was tested when a station was CHOSEN and never again
  while it was held: one picked on its CQ that then answered another
  caller went on being called for the whole seven attempts.

- A watched callsign now outranks every station that is not on the
  list. Lifted one rung at a time it sat at the bottom with nothing
  needed from it, and was never reached on a busy band — the opposite
  of what putting it on the list means.

Halt is now a verdict: the station is set aside for the session rather
than released, and the chase list does not override it. The attempts
cap lets the over finish instead of cutting the call that counted it,
and a rest is a rest. Only what the decodes list is SHOWING can be
called — the panel publishes the callsigns it shows, so there is one
definition of "shown" and not two.
This commit is contained in:
2026-09-05 21:45:25 +02:00
parent be889681a9
commit a41626955e
4 changed files with 899 additions and 109 deletions
+142 -21
View File
@@ -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 {