Two faults, and the first was a design mistake of mine. The choice was written only as the award override, to keep the imported CNTY as a record of what HAMLOG said. But CNTY is what the comparison READS, so the contact went on disagreeing for ever: settle a hundred rows, run the comparison again, get the same hundred back. The reasoning was about preserving evidence; the effect was a button with no visible consequence anywhere, which is indistinguishable from one that does nothing. This is the operator's own log, and correcting a field in it is the point of the exercise. The district now lands in CNTY as well as in the award reference. And the settled rows stayed on screen, which made the same button look broken a second time. They are dropped as they are applied — locally, rather than by re-running the comparison, which reads the whole log and would be seconds of silence on a remote database to be told what is already known. The agree/disagree counters follow.
221 lines
7.8 KiB
Go
221 lines
7.8 KiB
Go
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")
|
|
}
|
|
// Said before the work starts, not only after: this reads the WHOLE log, and
|
|
// on a remote MySQL that is seconds of silence during which the only
|
|
// evidence that anything is happening is this line.
|
|
applog.Printf("rda compare: reading the log…")
|
|
rows, err := a.qso.List(a.ctx, qso.ListFilter{Limit: 1_000_000})
|
|
if err != nil {
|
|
applog.Printf("rda compare: reading the log failed: %v", err)
|
|
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"
|
|
}
|
|
|
|
// RDAChoice is one arbitrated contact: which district the operator decided is
|
|
// right for that QSO.
|
|
type RDAChoice struct {
|
|
QSOID int64 `json:"qso_id"`
|
|
District string `json:"district"`
|
|
}
|
|
|
|
// RDAApplyResult reports what an arbitration did.
|
|
type RDAApplyResult struct {
|
|
Applied int `json:"applied"`
|
|
Failed int `json:"failed"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// ApplyRDAChoices writes the chosen district onto each contact — into CNTY and
|
|
// as the award reference, both.
|
|
//
|
|
// The first version wrote only the award override, to keep the imported CNTY as
|
|
// a record of what HAMLOG said. That was wrong in the way that matters: CNTY is
|
|
// what the comparison READS, so the contact went on disagreeing for ever. The
|
|
// operator settled a hundred contacts, ran the comparison again and got the
|
|
// same hundred rows — the decision had no visible effect anywhere, which is
|
|
// indistinguishable from a button that does nothing.
|
|
//
|
|
// So the chosen district lands in CNTY, where it settles the disagreement, and
|
|
// in the award reference, where it decides what the contact counts for. This is
|
|
// the operator's own log: correcting a field in it is the point of the exercise,
|
|
// not a loss of evidence.
|
|
func (a *App) ApplyRDAChoices(choices []RDAChoice) (RDAApplyResult, error) {
|
|
var res RDAApplyResult
|
|
if a.qso == nil {
|
|
return res, fmt.Errorf("db not initialized")
|
|
}
|
|
for _, c := range choices {
|
|
district := strings.ToUpper(strings.TrimSpace(c.District))
|
|
if !rdaDistrictRe.MatchString(district) {
|
|
res.Failed++
|
|
applog.Printf("rda apply: qso %d — %q is not a district reference", c.QSOID, c.District)
|
|
continue
|
|
}
|
|
q, err := a.qso.GetByID(a.ctx, c.QSOID)
|
|
if err != nil {
|
|
res.Failed++
|
|
applog.Printf("rda apply: qso %d could not be read: %v", c.QSOID, err)
|
|
continue
|
|
}
|
|
if q.Extras == nil {
|
|
q.Extras = map[string]string{}
|
|
}
|
|
q.County = district
|
|
q.Extras[award.ManualRefsKey] = setOverrideRef(q.Extras[award.ManualRefsKey], "RDA", district)
|
|
if err := a.qso.Update(a.ctx, q); err != nil {
|
|
res.Failed++
|
|
applog.Printf("rda apply: qso %d update failed: %v", c.QSOID, err)
|
|
continue
|
|
}
|
|
res.Applied++
|
|
}
|
|
if res.Applied > 0 {
|
|
// The award totals were computed from the old answers.
|
|
a.invalidateAwardStats()
|
|
}
|
|
applog.Printf("rda apply: %d contacts settled, %d failed", res.Applied, res.Failed)
|
|
res.Message = fmt.Sprintf("%d settled", res.Applied)
|
|
if res.Failed > 0 {
|
|
res.Message += fmt.Sprintf(", %d failed (see the log)", res.Failed)
|
|
}
|
|
return res, nil
|
|
}
|