Files
OpsLog/internal/dxcc/dxcc.go
T
rouggy b00552f617 fix(dxcc): a retired prefix is not a wrong one
Reported from a real import: every ZK2 contact came back New Zealand and
Niue vanished from a DXCC that had it confirmed. cty.dat is not wrong,
it is CURRENT — Niue moved to E6, so ZK2 reverted to New Zealand there.
ZK1 loses the Cook Islands the same way, and the reporter was right to
suspect more.

ClubLog's prefix table is date-ranged and still knows both, which is the
whole reason for enabling its country file. We consulted it only for
callsigns that already HAD a per-callsign exception — so a ZK2 with no
exception never reached it. It is now asked whenever no exception covers
the QSO's date.

Two limits keep the blast radius honest. It never overrules an exact
'=CALLSIGN' entry in cty.dat — that is somebody having looked at this
very callsign, and a prefix rule does not overrule it, which is why
Match now says how it matched. And where ClubLog has no answer (E6, TO5A
and their like are absent from its prefix table) cty.dat still decides,
because silence is not an answer. Measured before changing: on a sample
of thirty calls the two files agreed on twenty-eight, and both
disagreements were this bug. Opens 0.27.11.
2026-09-03 22:11:55 +02:00

408 lines
13 KiB
Go

// Package dxcc resolves a callsign to its DXCC entity (country, CQ/ITU
// zones, continent) by longest-prefix-matching against cty.dat — the
// canonical prefix database maintained by AD1C at country-files.com,
// the same file that every contest / logger consumes.
//
// The parser is line-oriented and tolerant: it handles cty.dat's
// per-prefix overrides ((CQ), [ITU], <lat/lon>, {Cont}) and the
// "=CALL" exact-callsign entries. Common operating suffixes (/P, /MM,
// /5, …) are stripped before matching.
package dxcc
import (
"bufio"
"io"
"sort"
"strconv"
"strings"
"sync"
)
// Entity is one DXCC entity entry from cty.dat.
type Entity struct {
Name string `json:"name"`
Continent string `json:"continent"`
CQZone int `json:"cqz"`
ITUZone int `json:"ituz"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
TZOffset float64 `json:"tz_offset"`
Primary string `json:"primary_prefix"` // canonical short prefix (F, DL, K, …)
}
// Match is the resolved DXCC info for a callsign. Per-prefix overrides
// from cty.dat are baked in; the Entity pointer is the unmodified parent.
type Match struct {
Entity *Entity `json:"entity"`
Prefix string `json:"matched_prefix"`
CQZone int `json:"cqz"`
ITUZone int `json:"ituz"`
Continent string `json:"continent"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
// Exact marks a hit on cty.dat's "=CALLSIGN" list rather than on a prefix.
//
// The two carry very different authority. A prefix match is a rule of thumb
// about a block of callsigns; an exact entry is somebody having looked at
// THIS callsign and written down where it was. A second country file may
// improve on the first kind and should not be allowed to overrule the
// second.
Exact bool `json:"exact,omitempty"`
}
type prefixEntry struct {
prefix string
entity *Entity
cqOverride int
ituOverride int
contOverride string
latOverride float64
lonOverride float64
hasLatLon bool
}
// DB is a parsed cty.dat ready for lookups.
type DB struct {
mu sync.RWMutex
entities []*Entity
exact map[string]prefixEntry // "=CALLSIGN" entries
byPrefix []prefixEntry // sorted longest first
}
// Load parses a cty.dat stream. Safe to call once at startup.
func Load(r io.Reader) (*DB, error) {
db := &DB{exact: make(map[string]prefixEntry)}
sc := bufio.NewScanner(r)
// cty.dat lines can be ~2 KB after wrapping; default 64 KB buffer is fine
// but we bump it to be safe.
sc.Buffer(make([]byte, 64*1024), 1024*1024)
var current *Entity
var buf strings.Builder
for sc.Scan() {
line := strings.TrimRight(sc.Text(), "\r")
if line == "" {
continue
}
// Entity header lines start at column 0; continuation lines are
// indented (cty.dat uses 4 spaces).
if line[0] != ' ' && line[0] != '\t' {
if e := parseEntityHeader(line); e != nil {
db.entities = append(db.entities, e)
current = e
buf.Reset()
}
continue
}
if current == nil {
continue
}
buf.WriteString(strings.TrimSpace(line))
// An entity's prefix list ends with ';' — possibly on a later line.
if strings.HasSuffix(strings.TrimSpace(line), ";") {
text := strings.TrimSuffix(buf.String(), ";")
for _, raw := range strings.Split(text, ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
entry, exact := parsePrefix(raw, current)
if exact {
db.exact[entry.prefix] = entry
} else {
db.byPrefix = append(db.byPrefix, entry)
}
}
buf.Reset()
}
}
if err := sc.Err(); err != nil {
return nil, err
}
// Longest prefix first so HasPrefix wins on the most specific match.
sort.Slice(db.byPrefix, func(i, j int) bool {
return len(db.byPrefix[i].prefix) > len(db.byPrefix[j].prefix)
})
return db, nil
}
// Entities returns the parsed entity list (read-only).
func (db *DB) Entities() []*Entity {
db.mu.RLock()
defer db.mu.RUnlock()
return db.entities
}
// Lookup resolves a callsign to its DXCC match using longest-prefix-match.
// Strips operating suffixes (/P, /MM, /5…) and "operating-from" prefixes
// (DL/F4NIE → uses DL). Returns false if no prefix matches.
func (db *DB) Lookup(callsign string) (Match, bool) {
db.mu.RLock()
defer db.mu.RUnlock()
// Maritime and aeronautical mobile belong to NO entity, and that is not a
// technicality — the station is at sea or in the air. RI1FJL/MM resolved to
// Franz Josef Land while the expedition was still on its way there, telling
// the operator they had worked an entity they had not.
//
// The previous choice was deliberate ("the log should still show the
// operator's country") and it is wrong for exactly that reason: a home
// country here is not extra information, it is a false claim about where the
// contact happened.
if IsMobileNoEntity(callsign) {
return Match{}, false
}
call := normalizeCallsign(callsign)
if call == "" {
return Match{}, false
}
if e, ok := db.exact[call]; ok {
m := materialize(e)
m.Exact = true
return m, true
}
// KG4 special case: Guantanamo Bay (DXCC 105) is "KG4" followed by EXACTLY
// two characters (KG4XX). "KG4", "KG4X", "KG4XYZ"… are continental USA.
// cty.dat carries a bare "KG4" prefix for Guantanamo, so for the other
// suffix lengths we must skip it and fall through to the USA prefixes.
skipKG4 := strings.HasPrefix(call, "KG4") && len(call) != len("KG4")+2
for _, p := range db.byPrefix {
if skipKG4 && p.prefix == "KG4" {
continue
}
if strings.HasPrefix(call, p.prefix) {
return materialize(p), true
}
}
return Match{}, false
}
func materialize(e prefixEntry) Match {
m := Match{
Entity: e.entity,
Prefix: e.prefix,
CQZone: e.entity.CQZone,
ITUZone: e.entity.ITUZone,
Continent: e.entity.Continent,
Lat: e.entity.Lat,
Lon: e.entity.Lon,
}
if e.cqOverride != 0 {
m.CQZone = e.cqOverride
}
if e.ituOverride != 0 {
m.ITUZone = e.ituOverride
}
if e.contOverride != "" {
m.Continent = e.contOverride
}
if e.hasLatLon {
m.Lat = e.latOverride
m.Lon = e.lonOverride
}
return m
}
// parseEntityHeader parses the colon-separated entity line:
//
// "France: 14: 27: EU: 46.00: -2.00: -1.0: F:"
func parseEntityHeader(line string) *Entity {
parts := strings.Split(line, ":")
if len(parts) < 8 {
return nil
}
name := strings.TrimSpace(parts[0])
primary := strings.TrimSpace(parts[7])
// cty.dat marks non-DXCC entities (WAE / contest-only zone splits such
// as Sicily *IT9 and African Italy *IG9) with a leading '*' on the
// primary prefix. Those report under their parent DXCC entity. True
// DXCC entities — including Sardinia (IS0) and Corsica (TK) — have no
// '*' and keep their own name. Per-prefix zones/lat-lon are preserved,
// so e.g. IG9 still resolves to CQ 33 / continent AF under "Italy".
if strings.HasPrefix(primary, "*") {
primary = strings.TrimPrefix(primary, "*")
name = CanonicalEntityName(name)
}
e := &Entity{
Name: name,
Continent: strings.TrimSpace(parts[3]),
Primary: primary,
}
e.CQZone, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
e.ITUZone, _ = strconv.Atoi(strings.TrimSpace(parts[2]))
e.Lat, _ = strconv.ParseFloat(strings.TrimSpace(parts[4]), 64)
// cty.dat longitude is "+ for West" (e.g. France 2°E = -2.00, USA 92°W =
// +91.87). Negate it to the standard "+ for East" the rest of the app uses
// (grids, bearing/distance math), otherwise every cty.dat-derived azimuth and
// cluster distance is mirrored east↔west.
if lon, err := strconv.ParseFloat(strings.TrimSpace(parts[5]), 64); err == nil {
e.Lon = -lon
}
e.TZOffset, _ = strconv.ParseFloat(strings.TrimSpace(parts[6]), 64)
if e.Name == "" {
return nil
}
return e
}
// parsePrefix peels off cty.dat per-prefix annotations:
//
// K (5)[7]<35.50/-95.00>{NA}~America/Chicago~
// =W1AW
func parsePrefix(s string, e *Entity) (prefixEntry, bool) {
out := prefixEntry{entity: e}
exact := false
if strings.HasPrefix(s, "=") {
exact = true
s = s[1:]
}
// Strip annotations. Order them roughly so we extract before they appear
// in the prefix slice.
s = stripAnnotation(s, '(', ')', func(v string) {
out.cqOverride, _ = strconv.Atoi(v)
})
s = stripAnnotation(s, '[', ']', func(v string) {
out.ituOverride, _ = strconv.Atoi(v)
})
s = stripAnnotation(s, '<', '>', func(v string) {
if a, b, ok := strings.Cut(v, "/"); ok {
lat, e1 := strconv.ParseFloat(a, 64)
lon, e2 := strconv.ParseFloat(b, 64)
if e1 == nil && e2 == nil {
// Same "+ for West" → "+ for East" flip as the entity header.
out.latOverride, out.lonOverride = lat, -lon
out.hasLatLon = true
}
}
})
s = stripAnnotation(s, '{', '}', func(v string) {
out.contOverride = strings.TrimSpace(v)
})
s = stripAnnotation(s, '~', '~', func(_ string) { /* timezone — ignore */ })
out.prefix = strings.ToUpper(strings.TrimSpace(s))
return out, exact
}
// stripAnnotation removes a single ...X...Y... block and invokes cb with the
// inner text. Used for (CQ), [ITU], <lat/lon>, {cont}, ~tz~ annotations.
func stripAnnotation(s string, open, close rune, cb func(string)) string {
i := strings.IndexRune(s, open)
if i < 0 {
return s
}
j := strings.IndexRune(s[i+1:], close)
if j < 0 {
return s
}
cb(s[i+1 : i+1+j])
return s[:i] + s[i+1+j+1:]
}
// suffixModifiers are non-DXCC-relevant callsign suffixes we strip before
// matching. /P /M /QRP /A and single-digit area changes (/5 …) all keep the
// operator's home DXCC. A TRAILING /MM or /AM (maritime/aeronautical mobile) is
// handled in normalizeCallsign (stripped, home entity kept) so a LEADING "MM"
// (the Scotland prefix) isn't mistaken for it.
var suffixModifiers = map[string]bool{
"P": true, "M": true, "QRP": true, "A": true,
"PM": true, "LH": true,
}
// normalizeCallsign uppercases, trims, and resolves the "active" call when the
// operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). A trailing /MM or /AM
// (maritime/aeronautical mobile) is stripped so the home entity still resolves
// (YB1SCY/AM → YB1SCY → Indonesia) — strict DXCC says no entity, but the log
// should still show the operator's country.
func normalizeCallsign(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
if !strings.ContainsRune(s, '/') {
return s
}
parts := strings.Split(s, "/")
keep := parts[:0]
var areaDigit byte // a single-digit "/N" re-homes the call to call area N
for i, p := range parts {
if p == "" {
continue
}
// A TRAILING /MM (maritime) or /AM (aeronautical) mobile, or /B (beacon), is
// stripped and the operator's home entity is kept, so the contact still
// resolves to a country in the log (e.g. YB1SCY/AM → Indonesia, 4U1UN/B →
// 4U1UN → United Nations HQ). A LEADING "MM"/"B" is a PREFIX (MM = Scotland,
// B = China: B/F4NIE) and must NOT be stripped.
if i > 0 && (p == "MM" || p == "AM" || p == "B") {
continue
}
if suffixModifiers[p] {
continue
}
if len(p) == 1 && p[0] >= '0' && p[0] <= '9' {
areaDigit = p[0]
continue
}
keep = append(keep, p)
}
var main string
switch len(keep) {
case 0:
return s
case 1:
main = keep[0]
default:
// Two non-modifier parts → operating-from prefix wins (shorter one).
// DL/F4NIE: DL is shorter → use DL (Germany). F4NIE/W6: W6 → W6.
if len(keep[0]) <= len(keep[1]) {
main = keep[0]
} else {
main = keep[1]
}
}
// Apply the call-area digit: "/N" replaces the area digit of the base call,
// which can change the DXCC entity (HD5MW/8 → HD8MW → Galápagos, not
// Ecuador). This is the same class of rule as KG4 and /MM.
if areaDigit != 0 {
main = replaceAreaDigit(main, areaDigit)
}
return main
}
// replaceAreaDigit substitutes the CALL-AREA digit of a call with d (used to
// apply a "/N" call-area change). The area digit is the first digit that comes
// AFTER a prefix letter — NOT a leading digit that is part of a digit-first
// prefix (7X, 3A, 4X, 9A, 2E…). So 7X2ARA/4 → 7X4ARA (still Algeria, area 4),
// NOT 4X2ARA (which is Israel — the old first-digit bug). Returns the call
// unchanged if it has no such digit.
func replaceAreaDigit(call string, d byte) string {
b := []byte(call)
seenLetter := false
for i := range b {
switch {
case b[i] >= 'A' && b[i] <= 'Z':
seenLetter = true
case b[i] >= '0' && b[i] <= '9' && seenLetter:
b[i] = d
return string(b)
}
}
return call
}
// IsMobileNoEntity reports a callsign DXCC assigns to no entity: a TRAILING
// /MM (maritime mobile) or /AM (aeronautical mobile).
//
// Trailing only. A LEADING "MM" is the Scotland prefix and "AM" is Spain, so
// MM0ABC and AM5X are ordinary calls — treating those as entity-less would be a
// far larger error than the one this fixes.
func IsMobileNoEntity(callsign string) bool {
s := strings.ToUpper(strings.TrimSpace(callsign))
i := strings.LastIndex(s, "/")
if i < 0 {
return false
}
switch s[i+1:] {
case "MM", "AM":
return true
}
return false
}