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:
+145
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user