feat(rda): measure the two district sources before arbitrating between them

A log imported from HAMLOG.online carries the Russian district in CNTY, and the
offline RDA database has its own answer for the same contact. Which wins is a
real question — but only if the two are independent. If HAMLOG's value is itself
a lookup in the same kind of database, there is nothing to arbitrate and a
precedence rule would be ceremony around a redundancy.

So this measures instead of deciding: Settings → RDA gains a read-only compare
that reports agreements, disagreements, and the contacts where only one side
knows, then lists the disagreements — callsign, date, both districts, whether
the database answer was a DATED record (the award administrators' own fact) or
the callsign's current district (an assumption), and whether the QSO is
confirmed on HAMLOG (their value has then been through a match of two logs).

It changes nothing in the log. What to do about a conflict is the next decision,
and it should be taken with the operator's own numbers in hand.

Also: the four HAMLOG fields become filterable, so 'never sent there' is a
filter rather than a guess. A field that can be written and not read back is
half a feature — pinned by a test against the bulk-editable list.
This commit is contained in:
2026-08-23 14:30:48 +02:00
parent f4a72da118
commit b3e398b8d6
10 changed files with 335 additions and 7 deletions
+145
View File
@@ -0,0 +1,145 @@
package main
// Comparing the two sources of a Russian district, BEFORE deciding which wins.
//
// A QSO can carry an RDA district from two places now:
//
// - the offline RDA database (internal/rda), compiled by the award's own
// administrators, which knows where a callsign was on a GIVEN DAY;
// - the CNTY field of a log downloaded from HAMLOG.online, whose export
// writes the district there (<CNTY:5>RO-19 on a real record).
//
// Whether that second source is EVIDENCE or merely a COPY is not a matter of
// opinion, and it decides everything downstream: if HAMLOG's value always
// equals the database's, there is nothing to arbitrate and a precedence rule
// would be ceremony around a redundancy. If they genuinely differ on some
// contacts, then two independent records disagree about a QSO that counts for
// an award, and that has to be surfaced rather than silently resolved.
//
// So: measure first. This reports agreements, disagreements, and the contacts
// where only one side has an answer — and it changes nothing in the log.
import (
"fmt"
"regexp"
"strings"
"hamlog/internal/applog"
"hamlog/internal/award"
"hamlog/internal/qso"
"hamlog/internal/rda"
)
// rdaDistrictRe is the shape of an RDA reference: two letters, a dash, two
// digits (RO-19, KO-05). Used to tell a real district in CNTY from a US county
// name or whatever else a callbook dropped in that field.
var rdaDistrictRe = regexp.MustCompile(`^[A-Z]{2}-[0-9]{2}$`)
// RDAConflict is one contact where the two sources disagree.
type RDAConflict struct {
QSOID int64 `json:"qso_id"`
Callsign string `json:"callsign"`
Date string `json:"date"`
Band string `json:"band"`
Mode string `json:"mode"`
// FromLog is the district the imported log carries (CNTY).
FromLog string `json:"from_log"`
// FromDB is what the offline RDA database answers for that callsign on that
// day, and Dated says whether it was a dated activity record (a fact) or the
// callsign's current district (an assumption).
FromDB string `json:"from_db"`
Dated bool `json:"dated"`
// Confirmed is whether the QSO is confirmed on HAMLOG — a confirmed contact's
// CNTY has been through their matching of two logs, which is what makes it
// worth weighing against the database at all.
Confirmed bool `json:"confirmed"`
}
// RDACompareResult is the measurement.
type RDACompareResult struct {
Scanned int `json:"scanned"` // Russian QSOs examined
BothKnown int `json:"both_known"` // a district from each source
Agree int `json:"agree"`
Disagree int `json:"disagree"`
OnlyLog int `json:"only_log"` // CNTY has it, the database does not
OnlyDB int `json:"only_db"` // the database has it, the log does not
Neither int `json:"neither"`
// Conflicts lists the disagreements, newest first, capped so a large log
// cannot produce a dialog nobody can read. Count is in Disagree regardless.
Conflicts []RDAConflict `json:"conflicts"`
}
// rdaCompareMax bounds the returned list. The COUNT is always exact; it is the
// enumeration that is capped, because a hundred rows is already more than
// anyone reviews in one sitting.
const rdaCompareMax = 200
// CompareRDASources measures the two sources against each other. Read-only.
func (a *App) CompareRDASources() (RDACompareResult, error) {
var res RDACompareResult
if a.qso == nil {
return res, fmt.Errorf("db not initialized")
}
rows, err := a.qso.List(a.ctx, qso.ListFilter{Limit: 1_000_000})
if err != nil {
return res, err
}
for i := range rows {
q := &rows[i]
// The same three entities the award covers — see BackfillRDA.
if q.DXCC == nil {
continue
}
switch *q.DXCC {
case 15, 54, 126:
default:
continue
}
res.Scanned++
fromLog := strings.ToUpper(strings.TrimSpace(q.County))
if !rdaDistrictRe.MatchString(fromLog) {
fromLog = "" // a county name, a stray value: not a district
}
var fromDB string
var dated bool
if m, ok := rda.Lookup(q.Callsign, q.QSODate); ok {
fromDB, dated = m.District, m.Dated
}
switch {
case fromLog == "" && fromDB == "":
res.Neither++
case fromLog == "":
res.OnlyDB++
case fromDB == "":
res.OnlyLog++
default:
res.BothKnown++
if fromLog == fromDB {
res.Agree++
continue
}
res.Disagree++
if len(res.Conflicts) < rdaCompareMax {
res.Conflicts = append(res.Conflicts, RDAConflict{
QSOID: q.ID, Callsign: q.Callsign,
Date: q.QSODate.UTC().Format("2006-01-02"),
Band: q.Band, Mode: q.Mode,
FromLog: fromLog, FromDB: fromDB, Dated: dated,
Confirmed: q.Extras != nil && isHamlogConfirmed(q.Extras[award.HamlogQSLKey]),
})
}
}
}
applog.Printf("rda compare: %d Russian QSOs — both %d (agree %d, disagree %d), only-log %d, only-db %d, neither %d",
res.Scanned, res.BothKnown, res.Agree, res.Disagree, res.OnlyLog, res.OnlyDB, res.Neither)
return res, nil
}
// isHamlogConfirmed reads their Y — anything that is not an explicit no counts,
// the same rule the award engine applies.
func isHamlogConfirmed(v string) bool {
v = strings.ToUpper(strings.TrimSpace(v))
return v != "" && v != "N" && v != "NO"
}