fix(autocall): rest in overs, no phantom miss, and a readable auto readout

The rest between two series was two minutes — four overs on FT8, by
which time the DX has worked four other callers and half the time has
gone. It is now counted in the station's OWN overs and is one by default:
seven calls, one over listened through, and it goes again if the station
is still there. The deadline lands mid-cycle on purpose, so "sit out one
over" means one over rather than depending on a millisecond of clock
skew.

A period the target was DECODED in is never counted as a miss. A period
is judged more than once — decodes arrive in a burst and stragglers
follow — and a later judgement holds a partial view of it, not evidence
of absence: D2ACE answered in the very period the counter then read as a
miss.

The readout moved out of the Auto button and beside it. "Auto D2ACE 1/7
·1/3" read as one number, and the control changed width every period.
The callsign is now the biggest thing on the row, the calls and the
missed periods each carry a label, the miss count appears only once there
is one, and a station being waited for gets its own amber chip.
This commit is contained in:
2026-09-06 14:36:32 +02:00
parent f56630d7d6
commit ed66e9394b
8 changed files with 176 additions and 56 deletions
+49 -11
View File
@@ -169,11 +169,17 @@ type Settings struct {
// 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
// RestPeriods is how many of the station's OWN OVERS a released callsign sits
// out before it can be picked again, and MaxRounds how many such series it
// gets before being parked for the session.
//
// Counted in overs, not in minutes, because that is the unit the thing is
// happening in: an operator who has called a DX seven times without an answer
// listens through one of its transmissions and calls again if it is still
// there. Two minutes is four overs on FT8 — the DX has worked four other
// callers by then, and half the time it has gone.
RestPeriods int
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
@@ -195,7 +201,7 @@ type Settings struct {
func Defaults() Settings {
return Settings{
Attempts: 7, WatchedAttempts: 15, Misses: 3,
Rest: 2 * time.Minute, MaxRounds: 3, MaxHold: 4 * time.Minute,
RestPeriods: 1, MaxRounds: 3, MaxHold: 4 * time.Minute,
}
}
@@ -210,8 +216,8 @@ func (s Settings) withDefaults() Settings {
if s.Misses <= 0 {
s.Misses = d.Misses
}
if s.Rest <= 0 {
s.Rest = d.Rest
if s.RestPeriods <= 0 {
s.RestPeriods = d.RestPeriods
}
if s.MaxRounds <= 0 {
s.MaxRounds = d.MaxRounds
@@ -284,6 +290,12 @@ type Engine struct {
heldSince time.Time
attempts int
misses int
// seenKey is the period in which the target was last DECODED. A period is
// judged more than once — the decodes arrive in bursts and stragglers follow —
// and a later judgement holds a partial view of it, not evidence of absence:
// the station answered in that very period and the counter still read one
// miss out of three.
seenKey string
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
@@ -735,7 +747,7 @@ func (e *Engine) OnPeriod(p Period) Action {
// 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.misses, e.lastMiss, e.seenKey = 0, "", p.Key
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
@@ -780,6 +792,10 @@ func (e *Engine) OnPeriod(p Period) Action {
if p.TX.Transmitting {
return Action{}
}
// Nor a period it was already decoded in: see seenKey.
if e.seenKey == p.Key {
return Action{}
}
if e.txSlot >= 0 && slotOf(p.At, periodSecs(p, t)) != e.txSlot {
return Action{}
}
@@ -1094,7 +1110,7 @@ func (e *Engine) preempt(p Period) (Action, bool) {
// 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
e.targetInst, e.answered, e.seenKey = "", false, ""
}
// addressee is who a message is being sent to — the first token, which is the
@@ -1133,7 +1149,7 @@ func (e *Engine) giveUp(call string, t Candidate) {
if at.IsZero() {
at = time.Now()
}
e.rested[call] = at.Add(e.set.Rest)
e.rested[call] = at.Add(restFor(e.set.RestPeriods, candidateSecs(t)))
// 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) != "" {
@@ -1235,6 +1251,28 @@ func workedNow(decodes []Candidate, call string) bool {
return false
}
// restFor turns a number of the station's own overs into a deadline.
//
// A station transmits every OTHER slot, so one of its overs is two T/R periods —
// and the extra half-cycle is what makes the answer stable: without it the
// deadline lands exactly on the station's next transmission, where a
// millisecond of clock skew decides whether it is called or passed over. Landing
// it mid-cycle makes "sit out one over" mean one over, every time.
func restFor(overs, trSec int) time.Duration {
if overs <= 0 {
return time.Duration(trSec) * time.Second // no rest: its next over will do
}
return time.Duration(overs*2*trSec+trSec) * time.Second
}
// candidateSecs is the station's own T/R period in seconds, defaulting to FT8's.
func candidateSecs(c Candidate) int {
if c.TRPeriod > 0 {
return c.TRPeriod
}
return 15
}
func periodSecs(p Period, c Candidate) int {
if c.TRPeriod > 0 {
return c.TRPeriod
+44 -14
View File
@@ -244,24 +244,28 @@ func TestAReleasedStationYieldsToAnythingBetter(t *testing.T) {
}
}
func TestAReleasedStationRestsBeforeItIsCalledAgain(t *testing.T) {
// A series that ended in a brake is followed by ONE of the station's own overs
// passed over — long enough to be a pause, short enough that the DX everybody
// is chasing is still there when the calling resumes. It used to be two
// minutes, which on FT8 is four overs: by then the DX has worked four other
// callers, and half the time it has gone.
func TestAReleasedStationSitsOutOneOfItsOvers(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("DX", NeedBand, -5)))
tx := TXState{Transmitting: true, Msg: "DX " + me + " JN36"}
for i := 0; i < 7; i++ {
e.NoteTX(tx)
}
// Still the only thing on the air, and still resting: seven calls, a halt,
// and the same station called again four seconds later is not a rest.
for _, n := range []int{2, 4, 6} { // all inside the two minutes
if a := e.OnPeriod(period(n, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
t.Fatalf("period %d: called again during the rest: %+v", n, a)
}
// Its very next over is passed over, whatever else is on the air: seven
// calls, a halt, and the same station called again four seconds later is not
// a rest, it is a stutter.
if a := e.OnPeriod(period(2, cq("DX", NeedBand, -5))); a.Kind != DoNothing {
t.Fatalf("called again during the rest: %+v", a)
}
// Two minutes later (period 8 × 15 s = 2 min past the give-up) it may go
// again — the station is still there and nothing better is.
if a := e.OnPeriod(period(9, cq("DX", NeedBand, -5))); a.Kind != DoReply {
t.Errorf("after the rest: %+v, want the station called again", a)
// And the one after that goes: still there, still wanted, and the operator
// has listened through an over.
if a := e.OnPeriod(period(4, cq("DX", NeedBand, -5))); a.Kind != DoReply {
t.Errorf("after one over: %+v, want the station called again", a)
}
}
@@ -929,20 +933,46 @@ func TestTheFirstCallStartsWithNoMisses(t *testing.T) {
// rest of the session after two series of unanswered calls.
func TestAWatchedCallsignIsNeverParked(t *testing.T) {
spent := func(c Candidate) *Engine {
e := New(Settings{Enabled: true, MaxRounds: 2, Rest: 0})
e := New(Settings{Enabled: true, MaxRounds: 2})
e.OnPeriod(period(0)) // the engine's clock, so the rest below is period time
for i := 0; i < 2; i++ {
e.giveUp(strings.ToUpper(c.Call), c) // a series ended by a brake
}
return e
}
// Period 8, well past the rest that follows a series: what is left is the
// parking, and only the parking.
plain := cq("PLAIN", NeedDXCC, 0)
if a := spent(plain).OnPeriod(period(0, plain)); a.Kind == DoReply {
if a := spent(plain).OnPeriod(period(8, plain)); a.Kind == DoReply {
t.Errorf("%+v — a station whose series are spent was called again", a)
}
dx := cq("ZD8GB", NeedDXCC, 0, watched)
if a := spent(dx).OnPeriod(period(0, dx)); a.Kind != DoReply {
if a := spent(dx).OnPeriod(period(8, dx)); a.Kind != DoReply {
t.Errorf("%+v — a watched DXpedition was parked for the session", a)
}
}
// A period is judged more than once — the decodes arrive in a burst and
// stragglers follow — and a later judgement of it holds a partial view, not
// evidence that the station has gone. From the air: D2ACE answered in the very
// period the counter then read as a miss.
func TestAPeriodTheTargetWasSeenInIsNeverAMiss(t *testing.T) {
e := on()
if a := e.OnPeriod(period(1, cq("D2ACE", NeedBand, 13))); a.Kind != DoReply {
t.Fatalf("%+v", a)
}
// Its own next period: decoded, so the exchange is under way.
p := period(3, callsMe("D2ACE", NeedBand, 13))
e.OnPeriod(p)
if e.Status().Misses != 0 {
t.Fatalf("misses=%d after being decoded", e.Status().Misses)
}
// The same period again, this time from a flush that carries only the
// stragglers — the target is not among them.
e.OnPeriod(period(3))
if got := e.Status().Misses; got != 0 {
t.Errorf("misses=%d — a second flush of a period it was seen in counted against it", got)
}
}