awards
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user