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:
+27
-7
@@ -30,7 +30,10 @@ const (
|
||||
keyAutoCallWatched = "autocall.watched_attempts"
|
||||
keyAutoCallMisses = "autocall.misses"
|
||||
keyAutoCallRounds = "autocall.max_rounds"
|
||||
keyAutoCallRestMin = "autocall.rest_min"
|
||||
// Periods, not minutes — see autocall.Settings.RestPeriods. A new key rather
|
||||
// than a reinterpreted one: the old value was in minutes, and reading "2" as
|
||||
// two periods or as two minutes are eight periods apart.
|
||||
keyAutoCallRest = "autocall.rest_periods"
|
||||
keyAutoCallOnScreen = "autocall.on_screen_only"
|
||||
keyAutoCallTrace = "autocall.trace"
|
||||
)
|
||||
@@ -48,9 +51,10 @@ type AutoCallSettings struct {
|
||||
// it, before it is given up on.
|
||||
Misses int `json:"misses"`
|
||||
// MaxRounds: how many series of calls one station gets in a session, and
|
||||
// RestMin the pause between two of them.
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
RestMin int `json:"rest_min"`
|
||||
// RestPeriods the pause between two of them, counted in the station's own
|
||||
// transmit periods.
|
||||
MaxRounds int `json:"max_rounds"`
|
||||
RestPeriods int `json:"rest_periods"`
|
||||
// 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"`
|
||||
@@ -61,6 +65,16 @@ type AutoCallSettings struct {
|
||||
Trace bool `json:"trace"`
|
||||
}
|
||||
|
||||
// intOr reads a stored integer, keeping a stored ZERO — which num() cannot,
|
||||
// since it treats zero as "nothing set".
|
||||
func intOr(v string, def int) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil || n < 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (a *App) GetAutoCallSettings() AutoCallSettings {
|
||||
d := autocall.Defaults()
|
||||
num := func(key string, def int) int {
|
||||
@@ -82,7 +96,9 @@ func (a *App) GetAutoCallSettings() AutoCallSettings {
|
||||
Trace: a.settingOr(keyAutoCallTrace, "0") == "1",
|
||||
Misses: num(keyAutoCallMisses, d.Misses),
|
||||
MaxRounds: num(keyAutoCallRounds, d.MaxRounds),
|
||||
RestMin: num(keyAutoCallRestMin, int(d.Rest/time.Minute)),
|
||||
// Zero is meaningful (call it again next period), so it is read directly
|
||||
// rather than through num(), which treats zero as "unset".
|
||||
RestPeriods: intOr(a.settingOr(keyAutoCallRest, ""), d.RestPeriods),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,12 +110,16 @@ func (a *App) SaveAutoCallSettings(s AutoCallSettings) error {
|
||||
for key, v := range map[string]int{
|
||||
keyAutoCallAttempts: s.Attempts, keyAutoCallWatched: s.WatchedAttempts,
|
||||
keyAutoCallMisses: s.Misses, keyAutoCallRounds: s.MaxRounds,
|
||||
keyAutoCallRestMin: s.RestMin,
|
||||
} {
|
||||
if v > 0 {
|
||||
a.setSetting(key, strconv.Itoa(v))
|
||||
}
|
||||
}
|
||||
// Stored even at zero, unlike the counters above: no rest at all is a
|
||||
// setting, not an empty field.
|
||||
if s.RestPeriods >= 0 {
|
||||
a.setSetting(keyAutoCallRest, strconv.Itoa(s.RestPeriods))
|
||||
}
|
||||
a.applyAutoCall()
|
||||
applog.Printf("autocall: %v (only=%q, %d/%d calls, %d misses, %d rounds)",
|
||||
s.Enabled, s.Only, s.Attempts, s.WatchedAttempts, s.Misses, s.MaxRounds)
|
||||
@@ -140,7 +160,7 @@ func (a *App) autoCallSettings() autocall.Settings {
|
||||
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,
|
||||
RestPeriods: s.RestPeriods,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -14,7 +14,10 @@
|
||||
"Changing mode with a callsign in the field now fixes the report. The “the operator chose this report” flag was holding across a change of mode, where it means nothing — “+00” is not a weak SSB report, it is not a report at all — and anything that fills the field from the rig (the S-meter readouts in the rig consoles) sets that flag too, so it could stay in the wrong notation for the whole QSO. A judgement that can be carried across is carried (57 → 579, 599 → 59); otherwise the mode’s preset answers.",
|
||||
"[NEW] The world map opens centred on YOUR square, not on Greenwich. Centred on 0° it left an Australian looking at their own country in the bottom-right corner with every path running off both edges; centred on their own longitude the same map reads the way their antenna does — the Americas to the east, Europe and Africa to the west. The latitude leans towards your hemisphere without following you to the pole, and a view you have panned to yourself still wins.",
|
||||
"The FT decodes map and the grid-square map remember where you left them — centre and zoom, portable with the data folder like the world map’s own view. Panning a map is the operator saying which part of the world they are working, and it was being thrown away on every tab switch.",
|
||||
"The world map now waits for the station’s square before painting. It used to draw the world at 0° and then move to your longitude, fetching a screenful of tiles and discarding it on every first run; it is built once, knowing where it is looking. A profile with no locator still gets the default view after a moment rather than a blank panel."
|
||||
"The world map now waits for the station’s square before painting. It used to draw the world at 0° and then move to your longitude, fetching a screenful of tiles and discarding it on every first run; it is built once, knowing where it is looking. A profile with no locator still gets the default view after a moment rather than a blank panel.",
|
||||
"Auto-call: the rest between two series is counted in the station’s own overs, not in minutes, and is ONE by default. Two minutes is four overs on FT8 — by then the DX has worked four other callers and half the time it has gone. Seven calls, one over listened through, and it goes again if the station is still there (Settings → DXHunter, “Rest (overs)”).",
|
||||
"Auto-call: a period the station was decoded in is never counted as a miss. A period is judged more than once — the decodes arrive in a burst and stragglers follow — and a later judgement holds a partial view of it, not evidence of absence: a station answering in that very period showed “1/3 missed” against it.",
|
||||
"The auto-call readout moved out of the Auto button and beside it: the station being called is the biggest thing on the row, the calls and the missed periods each carry a label instead of reading as one number, and a station being waited for shows with an hourglass. The button had been changing width every period."
|
||||
],
|
||||
"fr": [
|
||||
"L’auto-call voit un décodage qui arrive après les autres. Un décodeur envoie une période en rafale, puis les retardataires — un décodage « deep » une seconde plus tard — et le retardataire était jugé tout seul, sans les trente stations de sa propre période. La période reste maintenant ouverte jusqu’au début de la suivante, et un arrivant tardif est pesé face à l’ensemble.",
|
||||
@@ -28,7 +31,10 @@
|
||||
"Changer de mode avec un indicatif dans le champ corrige désormais le report. Le drapeau « l’opérateur a choisi ce report » tenait au travers d’un changement de mode, où il ne veut rien dire — « +00 » n’est pas un report SSB faible, ce n’est pas un report du tout — et tout ce qui remplit le champ depuis le poste (les lectures S-mètre des consoles) lève ce drapeau aussi : la notation pouvait rester fausse pour tout le QSO. Un jugement transposable l’est (57 → 579, 599 → 59) ; sinon le préréglage du mode répond.",
|
||||
"[NEW] La carte du monde s’ouvre centrée sur VOTRE locator, plus sur Greenwich. Centrée sur 0°, elle laissait un Australien avec son pays dans le coin en bas à droite et tous les trajets qui sortaient des deux bords ; centrée sur sa longitude, la même carte se lit comme son antenne travaille — les Amériques à l’est, l’Europe et l’Afrique à l’ouest. La latitude penche vers votre hémisphère sans vous suivre jusqu’au pôle, et une vue que vous avez déplacée vous-même reste prioritaire.",
|
||||
"La carte des décodages FTx et la carte des locators retiennent où vous les avez laissées — centre et zoom, portables avec le dossier de données comme la vue de la carte du monde. Déplacer une carte, c’est dire quelle partie du monde on travaille, et c’était jeté à chaque changement d’onglet.",
|
||||
"La carte du monde attend désormais le locator de la station avant de peindre. Elle dessinait le monde à 0° puis se déplaçait sur votre longitude, chargeant un écran de tuiles jeté aussitôt à chaque premier lancement ; elle est construite une fois, en sachant où elle regarde. Un profil sans locator obtient toujours la vue par défaut après un instant, pas un panneau vide."
|
||||
"La carte du monde attend désormais le locator de la station avant de peindre. Elle dessinait le monde à 0° puis se déplaçait sur votre longitude, chargeant un écran de tuiles jeté aussitôt à chaque premier lancement ; elle est construite une fois, en sachant où elle regarde. Un profil sans locator obtient toujours la vue par défaut après un instant, pas un panneau vide.",
|
||||
"Auto-call : le repos entre deux séries se compte en tours de la station, plus en minutes, et vaut UN par défaut. Deux minutes, c’est quatre tours en FT8 — le DX a travaillé quatre autres appelants entre-temps, et la moitié du temps il est parti. Sept appels, un tour écouté, et ça repart si la station est toujours là (Réglages → DXHunter, « Repos (tours) »).",
|
||||
"Auto-call : une période où la station a été décodée n’est plus comptée comme un raté. Une période est jugée plusieurs fois — les décodages arrivent en rafale puis les retardataires — et un jugement tardif n’en donne qu’une vue partielle, pas la preuve d’une absence : une station qui répondait dans cette période exacte se voyait compter « 1/3 raté ».",
|
||||
"L’affichage de l’auto-call sort du bouton Auto pour se placer à côté : la station appelée est l’élément le plus lisible de la ligne, les appels et les périodes ratées portent chacun leur étiquette au lieu de se lire comme un seul nombre, et une station attendue s’affiche avec un sablier. Le bouton changeait de largeur à chaque période."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1009,22 +1009,48 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
>
|
||||
<Bot className="size-3.5" />
|
||||
{t('dec.autoCall')}
|
||||
{autoCallOn && autoCall?.target && (
|
||||
<span className="font-mono text-xs">
|
||||
{autoCall.target} {autoCall.calls}/{autoCall.max}
|
||||
{autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{/* Wanted, decoded, and in a QSO with somebody else. Holding fire
|
||||
looks exactly like having nothing to do, and the operator had no
|
||||
way to tell them apart. */}
|
||||
{autoCallOn && !autoCall?.target && autoCall?.waiting && (
|
||||
<span className="font-mono text-xs animate-pulse" title={t('dec.autoWaitTip', { call: autoCall.waiting })}>
|
||||
{autoCall.waiting} ⏳
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* WHAT IT IS DOING, BESIDE THE SWITCH RATHER THAN ON IT.
|
||||
A thing that keys the transmitter must never be a switch with no
|
||||
readout — but the readout was crammed inside the button as
|
||||
"Auto D2ACE 1/7 ·1/3", where the two counters read as one number and
|
||||
the whole control changed width every period. Out here each figure
|
||||
gets a label, and the station being called is the biggest thing on
|
||||
the row. */}
|
||||
{autoCallOn && autoCall?.target && (
|
||||
<span className="h-8 inline-flex items-center gap-2 rounded-lg border border-success/50 bg-success/10 px-2"
|
||||
title={t('dec.autoTargetTip', { call: autoCall.target })}>
|
||||
<span className="font-mono text-sm font-bold text-success">{autoCall.target}</span>
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span className="uppercase tracking-wide">{t('dec.autoCalls')}</span>
|
||||
<span className="font-mono tabular-nums text-foreground">{autoCall.calls}/{autoCall.max}</span>
|
||||
</span>
|
||||
{/* Only once there is one to report: a zero that is always there is
|
||||
a figure nobody reads, and it was the half of the pair that got
|
||||
mistaken for the call count. */}
|
||||
{autoCall.misses > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<span className="uppercase tracking-wide">{t('dec.autoMisses')}</span>
|
||||
<span className={cn('font-mono tabular-nums',
|
||||
autoCall.misses + 1 >= autoCall.max_miss ? 'text-warning' : 'text-foreground')}>
|
||||
{autoCall.misses}/{autoCall.max_miss}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* Wanted, decoded, and in a QSO with somebody else. Holding fire looks
|
||||
exactly like having nothing to do, and the operator had no way to
|
||||
tell them apart. */}
|
||||
{autoCallOn && !autoCall?.target && autoCall?.waiting && (
|
||||
<span className="h-8 inline-flex items-center gap-1.5 rounded-lg border border-warning/50 bg-warning/10 px-2 animate-pulse"
|
||||
title={t('dec.autoWaitTip', { call: autoCall.waiting })}>
|
||||
<span className="text-sm">⏳</span>
|
||||
<span className="font-mono text-sm font-bold text-warning">{autoCall.waiting}</span>
|
||||
<span className="text-[11px] uppercase tracking-wide text-muted-foreground">{t('dec.autoWaiting')}</span>
|
||||
</span>
|
||||
)}
|
||||
{/* The chase list. Raw text while typing, committed on blur or Enter:
|
||||
the stored value is upper-cased and trimmed, and binding the box to
|
||||
that makes the space bar look dead — in a field whose whole purpose
|
||||
|
||||
@@ -2106,7 +2106,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
|
||||
const [chaseNew, setChaseNew] = useState(false);
|
||||
const [pskTgt, setPskTgt] = useState<any>({ enabled: false, scope: 'target' });
|
||||
const [ac, setAc] = useState<any>({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_min: 2, on_screen_only: true });
|
||||
const [ac, setAc] = useState<any>({ enabled: false, only: '', attempts: 7, watched_attempts: 15, misses: 3, max_rounds: 3, rest_periods: 1, on_screen_only: true });
|
||||
// The named contest callsigns. Raw text in state, written on blur: it is a
|
||||
// multi-line list, and normalising it on every keystroke would fight the
|
||||
// Return key — the one key this box is built around.
|
||||
@@ -5566,7 +5566,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
['watched_attempts', t('ac.watchedAttempts'), 1, 60],
|
||||
['misses', t('ac.misses'), 1, 10],
|
||||
['max_rounds', t('ac.rounds'), 1, 10],
|
||||
['rest_min', t('ac.rest'), 1, 60],
|
||||
['rest_periods', t('ac.rest'), 1, 20],
|
||||
] as [string, string, number, number][]).map(([k, label, min, max]) => (
|
||||
<span key={k} className="inline-flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
|
||||
@@ -193,8 +193,8 @@ const en: Dict = {
|
||||
'ac.ladder': 'Order of priority: every callsign on the watch list first (among themselves: new entity, band, mode, slot), then new entity, new band, new mode, new slot. A real need comes before one that exists only because a QSL never came. A station calling YOU is answered whatever the log makes of it. A station in the middle of a QSO with somebody else is never called — it cannot answer.',
|
||||
'ac.only': 'Chase only', 'ac.onlyPh': 'VP6D 3Y0J (space or comma)', 'ac.onlyHint': 'one or more callsigns — nothing else is called; empty means call whatever the log needs',
|
||||
'ac.attempts': 'Calls before giving up', 'ac.watchedAttempts': 'if watched', 'ac.misses': 'Missed periods',
|
||||
'ac.rounds': 'Series per station', 'ac.rest': 'Rest (min)', 'ac.trace': 'Log every decision', 'ac.traceHint': '— one line per period in the diagnostic log: what was on the air, why each station was refused, and what was decided. For working out why nothing is being called. A line every fifteen seconds, so leave it off otherwise.', 'ac.onScreen': 'Call only what the decodes list is showing', 'ac.onScreenHint': '— the filters above the list (CQ only, LoTW only, the new-category chips, continents, report, search) steer the transmitter too: a station filtered off the screen is not called. With the list closed nothing is filtered.',
|
||||
'dec.chasePh': 'chase…', 'dec.chaseTip': 'Call ONLY these stations — one or more callsigns, spaces or commas. Empty means call whatever the log needs. Same field as Preferences → DXHunter.', 'dec.autoWaitTip': '{call} is wanted and is working somebody else — it is called the moment it is free.', 'dec.autoStoppedTip': 'Auto-call has given up on a station from the chase list — click to switch it off, or Halt to clear it.',
|
||||
'ac.rounds': 'Series per station', 'ac.rest': 'Rest (overs)', 'ac.trace': 'Log every decision', 'ac.traceHint': '— one line per period in the diagnostic log: what was on the air, why each station was refused, and what was decided. For working out why nothing is being called. A line every fifteen seconds, so leave it off otherwise.', 'ac.onScreen': 'Call only what the decodes list is showing', 'ac.onScreenHint': '— the filters above the list (CQ only, LoTW only, the new-category chips, continents, report, search) steer the transmitter too: a station filtered off the screen is not called. With the list closed nothing is filtered.',
|
||||
'dec.chasePh': 'chase…', 'dec.chaseTip': 'Call ONLY these stations — one or more callsigns, spaces or commas. Empty means call whatever the log needs. Same field as Preferences → DXHunter.', 'dec.autoCalls': 'calls', 'dec.autoMisses': 'missed', 'dec.autoWaiting': 'waiting', 'dec.autoTargetTip': 'Auto-call is calling {call} — calls made of the allowance, and periods of its own it was not decoded in.', 'dec.autoWaitTip': '{call} is wanted and is working somebody else — it is called the moment it is free.', 'dec.autoStoppedTip': 'Auto-call has given up on a station from the chase list — click to switch it off, or Halt to clear it.',
|
||||
// Contest watchlist (Settings → DXHunter): how a special-event fleet joins the list on its own
|
||||
'wlc.title': 'Contest', 'wlc.pattern': 'Auto-add on', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'any spotted callsign CONTAINING this joins the watchlist as a contest entry (TM29WWA, HB9WWA, F4WWA/P)',
|
||||
'wlc.calls': 'And these callsigns, one per line', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'For the entries a pattern cannot catch: a station taking part under a callsign that says nothing about the event. Named here, it joins the contest watchlist the moment it is spotted. Commas and spaces work too.',
|
||||
@@ -750,8 +750,8 @@ const fr: Dict = {
|
||||
'ac.ladder': "Ordre de priorité : d'abord tout indicatif de la liste de surveillance (entre eux : nouvelle entité, bande, mode, slot), puis nouvelle entité, nouvelle bande, nouveau mode, nouveau slot. Un vrai besoin passe devant un besoin qui n'existe que faute de QSL. Une station qui VOUS appelle est toujours prise, quoi qu'en dise le log. Une station en plein QSO avec quelqu'un d'autre n'est jamais appelée — elle ne peut pas répondre.",
|
||||
'ac.only': 'Chasser uniquement', 'ac.onlyPh': 'VP6D 3Y0J (espace ou virgule)', 'ac.onlyHint': 'un ou plusieurs indicatifs — rien d’autre n’est appelé ; vide = appeler ce dont le log a besoin',
|
||||
'ac.attempts': 'Appels avant abandon', 'ac.watchedAttempts': 'si surveillé', 'ac.misses': 'Périodes manquées',
|
||||
'ac.rounds': 'Séries par station', 'ac.rest': 'Repos (min)', 'ac.trace': 'Journaliser chaque décision', 'ac.traceHint': '— une ligne par période dans le journal de diagnostic : ce qu’il y avait sur l’air, pourquoi chaque station a été écartée, et ce qui a été décidé. Pour comprendre pourquoi rien n’est appelé. Une ligne toutes les quinze secondes : à laisser éteint le reste du temps.', 'ac.onScreen': 'N’appeler que ce qu’affiche la liste des décodages', 'ac.onScreenHint': '— les filtres au-dessus de la liste (CQ seuls, LoTW, les pastilles de catégorie, continents, report, recherche) pilotent aussi l’émetteur : une station filtrée hors écran n’est pas appelée. La liste fermée, rien n’est filtré.',
|
||||
'dec.chasePh': 'chasser…', 'dec.chaseTip': "N'appeler QUE ces stations — un ou plusieurs indicatifs, espaces ou virgules. Vide = appeler ce dont le log a besoin. Même champ que Réglages → DXHunter.", 'dec.autoWaitTip': '{call} est voulue et travaille quelqu’un d’autre — elle sera appelée dès qu’elle sera libre.', 'dec.autoStoppedTip': "L'appel automatique a renoncé à une station de la liste de chasse — cliquez pour l'éteindre, ou Stop pour effacer.",
|
||||
'ac.rounds': 'Séries par station', 'ac.rest': 'Repos (tours)', 'ac.trace': 'Journaliser chaque décision', 'ac.traceHint': '— une ligne par période dans le journal de diagnostic : ce qu’il y avait sur l’air, pourquoi chaque station a été écartée, et ce qui a été décidé. Pour comprendre pourquoi rien n’est appelé. Une ligne toutes les quinze secondes : à laisser éteint le reste du temps.', 'ac.onScreen': 'N’appeler que ce qu’affiche la liste des décodages', 'ac.onScreenHint': '— les filtres au-dessus de la liste (CQ seuls, LoTW, les pastilles de catégorie, continents, report, recherche) pilotent aussi l’émetteur : une station filtrée hors écran n’est pas appelée. La liste fermée, rien n’est filtré.',
|
||||
'dec.chasePh': 'chasser…', 'dec.chaseTip': "N'appeler QUE ces stations — un ou plusieurs indicatifs, espaces ou virgules. Vide = appeler ce dont le log a besoin. Même champ que Réglages → DXHunter.", 'dec.autoCalls': 'appels', 'dec.autoMisses': 'ratés', 'dec.autoWaiting': 'en attente', 'dec.autoTargetTip': 'L’auto-call appelle {call} — appels effectués sur l’allocation, et périodes à lui où il n’a pas été décodé.', 'dec.autoWaitTip': '{call} est voulue et travaille quelqu’un d’autre — elle sera appelée dès qu’elle sera libre.', 'dec.autoStoppedTip': "L'appel automatique a renoncé à une station de la liste de chasse — cliquez pour l'éteindre, ou Stop pour effacer.",
|
||||
// Watchlist contest (Réglages → DXHunter) : comment une flotte d’événement rejoint la liste toute seule
|
||||
'wlc.title': 'Contest', 'wlc.pattern': 'Ajout auto sur', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'tout indicatif spotté CONTENANT ceci rejoint la watchlist comme entrée contest (TM29WWA, HB9WWA, F4WWA/P)',
|
||||
'wlc.calls': 'Et ces indicatifs, un par ligne', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'Pour les participants qu’aucun motif ne peut attraper : une station engagée sous un indicatif qui ne dit rien de l’événement. Nommée ici, elle rejoint la watchlist contest dès qu’elle est spottée. Les virgules et les espaces marchent aussi.',
|
||||
|
||||
@@ -1961,7 +1961,7 @@ export namespace main {
|
||||
watched_attempts: number;
|
||||
misses: number;
|
||||
max_rounds: number;
|
||||
rest_min: number;
|
||||
rest_periods: number;
|
||||
on_screen_only: boolean;
|
||||
trace: boolean;
|
||||
|
||||
@@ -1977,7 +1977,7 @@ export namespace main {
|
||||
this.watched_attempts = source["watched_attempts"];
|
||||
this.misses = source["misses"];
|
||||
this.max_rounds = source["max_rounds"];
|
||||
this.rest_min = source["rest_min"];
|
||||
this.rest_periods = source["rest_periods"];
|
||||
this.on_screen_only = source["on_screen_only"];
|
||||
this.trace = source["trace"];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user