Files
OpsLog/internal/dxcc/dxcc.go
T

306 lines
8.4 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"`
}
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()
call := normalizeCallsign(callsign)
if call == "" {
return Match{}, false
}
if e, ok := db.exact[call]; ok {
return materialize(e), true
}
for _, p := range db.byPrefix {
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)
e.Lon, _ = strconv.ParseFloat(strings.TrimSpace(parts[5]), 64)
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 {
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 /MM /AM /QRP /A and single-digit area changes (/5 …) all
// keep the operator's home DXCC.
var suffixModifiers = map[string]bool{
"P": true, "M": true, "MM": true, "AM": 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).
func normalizeCallsign(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
if !strings.ContainsRune(s, '/') {
return s
}
parts := strings.Split(s, "/")
keep := parts[:0]
for _, p := range parts {
if p == "" {
continue
}
if suffixModifiers[p] {
continue
}
if len(p) == 1 && p >= "0" && p <= "9" {
continue
}
keep = append(keep, p)
}
switch len(keep) {
case 0:
return s
case 1:
return keep[0]
}
// Two non-modifier parts → operating-from prefix wins (shorter one).
// DL/F4NIE: DL is shorter → use DL (Germany). F4NIE/W6: W6 shorter → W6.
if len(keep[0]) <= len(keep[1]) {
return keep[0]
}
return keep[1]
}