This commit is contained in:
2026-06-05 17:22:38 +02:00
parent cf9dbf26f3
commit 88623f55df
21 changed files with 2123 additions and 50 deletions
+360
View File
@@ -0,0 +1,360 @@
// Package award computes amateur-radio award progress (worked / confirmed)
// directly from the logbook. An award is defined declaratively: a QSO FIELD to
// scan plus an optional regular-expression PATTERN that extracts the reference
// from that field. With no pattern the whole field value is the reference; with
// a pattern, capture group 1 (or the whole match) is the reference and a single
// QSO may yield several references (e.g. a Note holding "D74 D73").
//
// Examples:
// DXCC : field "dxcc" (no pattern) → entity number
// WAS : field "state", DXCCFilter [291,110,6] → US state
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
// WPX : field "prefix" (computed from callsign)
package award
import (
"regexp"
"sort"
"strconv"
"strings"
"hamlog/internal/qso"
)
// Def defines one award.
type Def struct {
Code string `json:"code"` // unique key, e.g. "DXCC"
Name string `json:"name"` // friendly name
Field string `json:"field"` // QSO field to scan (see fieldRaw)
Pattern string `json:"pattern"` // optional Go regexp; group 1 = reference
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
Confirm []string `json:"confirm"` // accepted confirmations: lotw|qsl|eqsl
Total int `json:"total"` // known denominator (0 = unknown)
Builtin bool `json:"builtin"` // shipped default (informational)
}
// Defaults are the built-in awards seeded on first run (then user-editable).
func Defaults() []Def {
return []Def{
{Code: "DXCC", Name: "DX Century Club", Field: "dxcc", Confirm: []string{"lotw", "qsl"}, Total: 340, Builtin: true},
{Code: "WAS", Name: "Worked All States", Field: "state", DXCCFilter: []int{291, 110, 6}, Confirm: []string{"lotw", "qsl"}, Total: 50, Builtin: true},
{Code: "WAZ", Name: "Worked All Zones (CQ)", Field: "cqz", Confirm: []string{"lotw", "qsl"}, Total: 40, Builtin: true},
{Code: "WAC", Name: "Worked All Continents", Field: "cont", Confirm: []string{"lotw", "qsl", "eqsl"}, Total: 6, Builtin: true},
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Field: "prefix", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
{Code: "DDFM", Name: "Départements Français Métropolitains", Field: "note", Pattern: `(?i)\bD(\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: []string{"lotw", "qsl"}, Total: 96, Builtin: true},
{Code: "IOTA", Name: "Islands On The Air", Field: "iota", Confirm: []string{"qsl"}, Total: 0, Builtin: true},
{Code: "POTA", Name: "Parks On The Air", Field: "pota_ref", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
{Code: "SOTA", Name: "Summits On The Air", Field: "sota_ref", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
{Code: "WWFF", Name: "World Wide Flora & Fauna", Field: "wwff", Confirm: []string{"lotw", "qsl"}, Total: 0, Builtin: true},
}
}
// Fields lists the scannable QSO fields for the award editor.
func Fields() []string {
return []string{
"dxcc", "cqz", "ituz", "prefix", "callsign",
"state", "cont", "country", "grid",
"iota", "sota_ref", "pota_ref", "wwff",
"name", "qth", "address", "comment", "note",
}
}
// BandCount holds distinct-reference counts on one band.
type BandCount struct {
Band string `json:"band"`
Worked int `json:"worked"`
Confirmed int `json:"confirmed"`
}
// Ref is one reference's status within an award.
type Ref struct {
Ref string `json:"ref"`
Name string `json:"name,omitempty"`
Worked bool `json:"worked"`
Confirmed bool `json:"confirmed"`
Bands []string `json:"bands"`
ConfirmedBands []string `json:"confirmed_bands"`
}
// Result is an award's computed progress.
type Result struct {
Code string `json:"code"`
Name string `json:"name"`
Field string `json:"field"`
Worked int `json:"worked"`
Confirmed int `json:"confirmed"`
Total int `json:"total"`
Bands []BandCount `json:"bands"`
Refs []Ref `json:"refs"`
Error string `json:"error,omitempty"` // e.g. bad regexp pattern
}
// NameResolver optionally maps a (field, ref) pair to a human name. May be nil.
type NameResolver func(field, ref string) string
type refAgg struct {
bands map[string]struct{}
confirmedBands map[string]struct{}
anyConfirmed bool
}
// Compute runs every definition over the QSOs in a single pass.
func Compute(defs []Def, qsos []qso.QSO, nameOf NameResolver) []Result {
// Pre-compile patterns once per award (not per QSO).
res := make([]*regexp.Regexp, len(defs))
perr := make([]string, len(defs))
for i := range defs {
if p := strings.TrimSpace(defs[i].Pattern); p != "" {
re, err := regexp.Compile(p)
if err != nil {
perr[i] = "bad pattern: " + err.Error()
} else {
res[i] = re
}
}
}
agg := make([]map[string]*refAgg, len(defs))
for i := range defs {
agg[i] = map[string]*refAgg{}
}
for qi := range qsos {
q := &qsos[qi]
for i := range defs {
d := &defs[i]
if perr[i] != "" {
continue
}
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
continue
}
refs := refValues(d, res[i], q)
if len(refs) == 0 {
continue
}
band := strings.ToLower(strings.TrimSpace(q.Band))
isConf := confirmed(q, d.Confirm)
for _, ref := range refs {
a := agg[i][ref]
if a == nil {
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}}
agg[i][ref] = a
}
if band != "" {
a.bands[band] = struct{}{}
}
if isConf {
a.anyConfirmed = true
if band != "" {
a.confirmedBands[band] = struct{}{}
}
}
}
}
}
out := make([]Result, len(defs))
for i := range defs {
d := &defs[i]
r := Result{Code: d.Code, Name: d.Name, Field: d.Field, Total: d.Total, Error: perr[i]}
bandWorked := map[string]int{}
bandConfirmed := map[string]int{}
for ref, a := range agg[i] {
r.Worked++
if a.anyConfirmed {
r.Confirmed++
}
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands)}
if nameOf != nil {
rf.Name = nameOf(d.Field, ref)
}
r.Refs = append(r.Refs, rf)
for b := range a.bands {
bandWorked[b]++
}
for b := range a.confirmedBands {
bandConfirmed[b]++
}
}
sort.Slice(r.Refs, func(a, b int) bool {
if r.Refs[a].Confirmed != r.Refs[b].Confirmed {
return r.Refs[a].Confirmed
}
return r.Refs[a].Ref < r.Refs[b].Ref
})
for _, b := range sortedBands(bandWorked) {
r.Bands = append(r.Bands, BandCount{Band: b, Worked: bandWorked[b], Confirmed: bandConfirmed[b]})
}
out[i] = r
}
return out
}
// refValues extracts the reference(s) a QSO contributes to an award.
func refValues(d *Def, re *regexp.Regexp, q *qso.QSO) []string {
raw := fieldRaw(d.Field, q)
if strings.TrimSpace(raw) == "" {
return nil
}
if re == nil {
return []string{normalizeRef(raw)}
}
matches := re.FindAllStringSubmatch(raw, -1)
if len(matches) == 0 {
return nil
}
seen := map[string]struct{}{}
var out []string
for _, m := range matches {
ref := m[0]
if len(m) > 1 && m[1] != "" {
ref = m[1]
}
ref = normalizeRef(ref)
if ref == "" {
continue
}
if _, dup := seen[ref]; dup {
continue
}
seen[ref] = struct{}{}
out = append(out, ref)
}
return out
}
func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }
// fieldRaw returns the raw string value of a QSO field (computed for numeric /
// derived fields). Unknown fields yield "".
func fieldRaw(field string, q *qso.QSO) string {
switch strings.ToLower(strings.TrimSpace(field)) {
case "dxcc":
if q.DXCC != nil && *q.DXCC > 0 {
return strconv.Itoa(*q.DXCC)
}
case "cqz":
if q.CQZ != nil && *q.CQZ > 0 {
return strconv.Itoa(*q.CQZ)
}
case "ituz":
if q.ITUZ != nil && *q.ITUZ > 0 {
return strconv.Itoa(*q.ITUZ)
}
case "prefix":
return wpxPrefix(q.Callsign)
case "callsign":
return q.Callsign
case "state":
return q.State
case "cont":
return q.Continent
case "country":
return q.Country
case "grid":
return q.Grid
case "iota":
return q.IOTA
case "sota_ref":
return q.SOTARef
case "pota_ref":
return q.POTARef
case "name":
return q.Name
case "qth":
return q.QTH
case "address":
return q.Address
case "comment":
return q.Comment
case "note", "notes":
return q.Notes
case "wwff":
if q.Extras != nil {
if v := strings.TrimSpace(q.Extras["WWFF_REF"]); v != "" {
return v
}
if strings.EqualFold(q.Extras["SIG"], "WWFF") {
return q.Extras["SIG_INFO"]
}
}
}
return ""
}
func dxccAllowed(dxcc *int, filter []int) bool {
if dxcc == nil {
return false
}
for _, f := range filter {
if *dxcc == f {
return true
}
}
return false
}
// confirmed reports whether the QSO satisfies any accepted confirmation source.
// ADIF *_QSL_RCVD values Y (confirmed) and V (verified) both count.
func confirmed(q *qso.QSO, sources []string) bool {
for _, s := range sources {
switch s {
case "lotw":
if isYes(q.LOTWRcvd) {
return true
}
case "qsl":
if isYes(q.QSLRcvd) {
return true
}
case "eqsl":
if isYes(q.EQSLRcvd) {
return true
}
}
}
return false
}
func isYes(v string) bool {
switch strings.ToUpper(strings.TrimSpace(v)) {
case "Y", "V":
return true
}
return false
}
func setToSorted(m map[string]struct{}) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm"}
func sortedBands(m map[string]int) []string {
idx := map[string]int{}
for i, b := range bandOrder {
idx[b] = i
}
out := make([]string, 0, len(m))
for b := range m {
out = append(out, b)
}
sort.Slice(out, func(a, b int) bool {
ia, oka := idx[out[a]]
ib, okb := idx[out[b]]
if oka && okb {
return ia < ib
}
if oka != okb {
return oka
}
return out[a] < out[b]
})
return out
}
+75
View File
@@ -0,0 +1,75 @@
package award
import (
"testing"
"hamlog/internal/qso"
)
func TestWPXPrefix(t *testing.T) {
cases := map[string]string{
"F4BPO": "F4",
"EA8ABC": "EA8",
"9A1AA": "9A1",
"OH2BH": "OH2",
"K1ABC": "K1",
"RAEM": "RA0",
"F4BPO/P": "F4",
"F4BPO/9": "F9",
"VP8/F4BPO": "VP8",
"PA0XYZ": "PA0",
}
for in, want := range cases {
if got := wpxPrefix(in); got != want {
t.Errorf("wpxPrefix(%q) = %q, want %q", in, got, want)
}
}
}
func ip(n int) *int { return &n }
func TestComputeDXCCAndConfirm(t *testing.T) {
qsos := []qso.QSO{
{Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA", LOTWRcvd: "Y"},
{Callsign: "K2DEF", Band: "40m", DXCC: ip(291), State: "NY"}, // worked, not confirmed
{Callsign: "DL1XYZ", Band: "20m", DXCC: ip(230), QSLRcvd: "Y"}, // DXCC Germany confirmed
{Callsign: "F4BPO", Band: "20m", DXCC: ip(227), Notes: "nice qso D74", EQSLRcvd: "Y"}, // France dept 74 in note
{Callsign: "F5ABC", Band: "40m", DXCC: ip(227), Notes: "D2A Corsica", QSLRcvd: "Y"}, // France dept 2A, confirmed
}
res := Compute(Defaults(), qsos, nil)
by := map[string]Result{}
for _, r := range res {
by[r.Code] = r
}
dxcc := by["DXCC"]
if dxcc.Worked != 3 { // USA, Germany, France
t.Errorf("DXCC worked = %d, want 3", dxcc.Worked)
}
// DXCC confirms on lotw|qsl → USA(lotw) + Germany(qsl) + France(qsl via F5ABC).
if dxcc.Confirmed != 3 {
t.Errorf("DXCC confirmed = %d, want 3", dxcc.Confirmed)
}
was := by["WAS"]
if was.Worked != 2 { // MA, NY only (France excluded by DXCC filter)
t.Errorf("WAS worked = %d, want 2", was.Worked)
}
// DDFM scans the Note field with pattern D(\d{1,2}[AB]?): 74 and 2A.
ddfm := by["DDFM"]
if ddfm.Worked != 2 {
t.Errorf("DDFM worked = %d, want 2 (refs %v)", ddfm.Worked, refCodes(ddfm))
}
if ddfm.Confirmed != 1 { // 2A confirmed via QSL; 74 only eQSL (not accepted)
t.Errorf("DDFM confirmed = %d, want 1", ddfm.Confirmed)
}
}
func refCodes(r Result) []string {
out := make([]string, 0, len(r.Refs))
for _, rf := range r.Refs {
out = append(out, rf.Ref)
}
return out
}
+119
View File
@@ -0,0 +1,119 @@
package award
import "strings"
// wpxPrefix derives the CQ WPX prefix from a callsign. This is an approximation
// of the official WPX rules — good enough to count distinct prefixes worked:
// - standard call: letters+digits up to and including the LAST digit of the
// first group (F4BPO→F4, EA8ABC→EA8, 9A1AA→9A1, OH2BH→OH2)
// - no digit: first two letters + "0" (RAEM→RA0)
// - portable "A/B": a short alpha(+digit) segment is treated as the prefix
// designator; a lone-digit segment replaces the call's digit (F4BPO/9→F9)
func wpxPrefix(call string) string {
c := strings.ToUpper(strings.TrimSpace(call))
if c == "" {
return ""
}
if strings.Contains(c, "/") {
return portablePrefix(c)
}
return standardPrefix(c)
}
func portablePrefix(c string) string {
parts := strings.Split(c, "/")
// Drop pure operating-modifier suffixes.
kept := make([]string, 0, len(parts))
for _, p := range parts {
switch p {
case "P", "M", "MM", "AM", "QRP", "A", "R", "B", "LH":
continue
}
if p != "" {
kept = append(kept, p)
}
}
if len(kept) == 0 {
kept = parts
}
// Pick a base = the longest segment (the actual call).
base := kept[0]
for _, p := range kept[1:] {
if len(p) > len(base) {
base = p
}
}
for _, p := range kept {
if p == base {
continue
}
if isAllDigits(p) {
// Lone digit replaces the call's region digit: F4BPO/9 → F9.
return replaceLastDigit(standardPrefix(base), p)
}
if len(p) <= 4 && hasLetter(p) {
// Prefix designator wins: VP8/F4BPO → VP8 (+digit if missing).
return ensureTrailingDigit(p)
}
}
return standardPrefix(base)
}
// standardPrefix applies the basic WPX rule to a plain callsign: the prefix is
// the call up to and including its last digit (9A1AA→9A1, EA8ABC→EA8). Standard
// callsigns carry no digit in the suffix, so "last digit" is the prefix digit.
func standardPrefix(c string) string {
lastDigit := -1
for i := 0; i < len(c); i++ {
if c[i] >= '0' && c[i] <= '9' {
lastDigit = i
}
}
if lastDigit < 0 {
// No digit at all: first two letters + 0.
if len(c) >= 2 {
return c[:2] + "0"
}
return c + "0"
}
return c[:lastDigit+1]
}
func ensureTrailingDigit(p string) string {
for i := 0; i < len(p); i++ {
if p[i] >= '0' && p[i] <= '9' {
return p
}
}
return p + "0"
}
func replaceLastDigit(prefix, digit string) string {
for i := len(prefix) - 1; i >= 0; i-- {
if prefix[i] >= '0' && prefix[i] <= '9' {
return prefix[:i] + digit
}
}
return prefix + digit
}
func isAllDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
func hasLetter(s string) bool {
for i := 0; i < len(s); i++ {
if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') {
return true
}
}
return false
}