This commit is contained in:
2026-06-06 01:21:24 +02:00
parent 922a185208
commit b4e104f5a2
9 changed files with 381 additions and 42 deletions
+54
View File
@@ -58,6 +58,60 @@ func NameForDXCC(n int) string {
return strings.Title(name) //nolint:staticcheck // ASCII entity names
}
// ZoneByCallDistrict returns the CQ and ITU zone for a callsign in a country
// that is split across zones by call district (USA, Australia…). cty.dat and
// ClubLog's cty.xml only carry one zone per entity, so loggers apply this
// district→zone convention to get e.g. W6 = CQ3/ITU6 instead of the entity
// default CQ5/ITU8. ok=false means no district rule applies (use the entity
// default). The district is the first digit of the callsign.
func ZoneByCallDistrict(adif int, call string) (cqz, ituz int, ok bool) {
d := districtDigit(call)
if d < 0 {
return 0, 0, false
}
switch adif {
case 291: // United States — standard district defaults (state-level
// exceptions exist, but this matches what Log4OM/DXKeeper default to).
if z, o := usDistrictZones[d]; o {
return z[0], z[1], true
}
case 150: // Australia — VK6/VK8 (west/north) are CQ29/ITU58, rest CQ30/ITU59.
if d == 6 || d == 8 {
return 29, 58, true
}
return 30, 59, true
}
return 0, 0, false
}
// usDistrictZones maps a US call district digit to its {CQ, ITU} zone.
var usDistrictZones = map[int][2]int{
0: {4, 7}, 1: {5, 8}, 2: {5, 8}, 3: {5, 8}, 4: {5, 8},
5: {4, 7}, 6: {3, 6}, 7: {3, 6}, 8: {5, 8}, 9: {4, 8},
}
// firstDigit returns the first 0-9 digit in a callsign, or -1 if none.
func firstDigit(call string) int {
for i := 0; i < len(call); i++ {
if call[i] >= '0' && call[i] <= '9' {
return int(call[i] - '0')
}
}
return -1
}
// districtDigit returns the effective call-area digit: a trailing "/N" (single
// digit) re-homes the call to area N (W6ABC/7 → area 7), otherwise the first
// digit of the call.
func districtDigit(call string) int {
if i := strings.LastIndex(call, "/"); i >= 0 && i == len(call)-2 {
if c := call[len(call)-1]; c >= '0' && c <= '9' {
return int(c - '0')
}
}
return firstDigit(call)
}
// EntityNumberName pairs a DXCC entity number with its display name.
type EntityNumberName struct {
Num int
+54 -11
View File
@@ -139,7 +139,15 @@ func (db *DB) Lookup(callsign string) (Match, bool) {
if e, ok := db.exact[call]; ok {
return materialize(e), 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
}
@@ -262,22 +270,33 @@ func stripAnnotation(s string, open, close rune, cb func(string)) string {
}
// 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.
// matching. /P /M /QRP /A and single-digit area changes (/5 …) all keep the
// operator's home DXCC. NOTE: "MM" and "AM" are NOT here — a TRAILING /MM or
// /AM (maritime/aeronautical mobile) means "no DXCC entity", while a LEADING
// "MM" is the Scotland operating prefix; both are handled in normalizeCallsign.
var suffixModifiers = map[string]bool{
"P": true, "M": true, "MM": true, "AM": true, "QRP": true, "A": true,
"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).
// the operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). Returns "" for
// maritime/aeronautical mobile (.../MM, .../AM), which count for no DXCC.
func normalizeCallsign(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
if !strings.ContainsRune(s, '/') {
return s
}
parts := strings.Split(s, "/")
// A trailing /MM or /AM is maritime/aeronautical mobile → no DXCC entity.
// (A leading "MM" is the Scotland prefix and must NOT trigger this.)
for i, p := range parts {
if i > 0 && (p == "MM" || p == "AM") {
return ""
}
}
keep := parts[:0]
var areaDigit byte // a single-digit "/N" re-homes the call to call area N
for _, p := range parts {
if p == "" {
continue
@@ -285,21 +304,45 @@ func normalizeCallsign(s string) string {
if suffixModifiers[p] {
continue
}
if len(p) == 1 && p >= "0" && p <= "9" {
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:
return keep[0]
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]
}
}
// 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]
// 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 = replaceFirstDigit(main, areaDigit)
}
return keep[1]
return main
}
// replaceFirstDigit substitutes the first 0-9 digit of a call with d (used to
// apply a "/N" call-area change). Returns the call unchanged if it has no digit.
func replaceFirstDigit(call string, d byte) string {
b := []byte(call)
for i := range b {
if b[i] >= '0' && b[i] <= '9' {
b[i] = d
return string(b)
}
}
return call
}
+1 -1
View File
@@ -341,7 +341,7 @@ var dxccByName = map[string]int{
"wake island": 297,
"wales": 294,
"wallis & futuna islands": 298,
"west malaysia": 155,
"west malaysia": 299, // hand-fixed: generation joined it to 155 by mistake (9M2 = ADIF 299)
"western kiribati": 301,
"western sahara": 302,
"willis island": 303,
+97 -2
View File
@@ -54,6 +54,96 @@ func TestLookup(t *testing.T) {
}
}
// A "/N" call-area suffix can change the DXCC entity: HD5MW/8 re-homes to the
// HD8 area (Galápagos), not the base call's Ecuador.
func TestCallAreaSuffix(t *testing.T) {
const cty = `Ecuador: 10: 12: SA: -1.40: 78.40: 5.0: HC:
HC,HD;
Galapagos Islands: 10: 12: SA: 0.00: 91.00: 6.0: HC8:
HC8,HD8;
`
db, err := Load(strings.NewReader(cty))
if err != nil {
t.Fatalf("load: %v", err)
}
cases := map[string]string{
"HD5MW": "Ecuador",
"HD5MW/8": "Galapagos Islands",
"HC2AO": "Ecuador",
"HD8M": "Galapagos Islands",
"HC1WW/8": "Galapagos Islands",
}
for call, want := range cases {
m, ok := db.Lookup(call)
if !ok {
t.Errorf("%s: no match", call)
continue
}
if m.Entity.Name != want {
t.Errorf("%s: got %q, want %q", call, m.Entity.Name, want)
}
}
}
// US/VK call-district zone refinement (W6 = CQ3/ITU6, not the entity default).
func TestZoneByCallDistrict(t *testing.T) {
cases := []struct {
adif int
call string
cqz, ituz int
ok bool
}{
{291, "W6XYZ", 3, 6, true},
{291, "K7AB", 3, 6, true},
{291, "W4ABC", 5, 8, true},
{291, "N0CALL", 4, 7, true},
{291, "AA5XX", 4, 7, true},
{150, "VK6AA", 29, 58, true}, // West Australia
{150, "VK3XY", 30, 59, true}, // Victoria
{230, "DL1ABC", 0, 0, false}, // Germany: no district rule
{291, "WABC", 0, 0, false}, // no digit
}
for _, c := range cases {
cqz, ituz, ok := ZoneByCallDistrict(c.adif, c.call)
if ok != c.ok || (ok && (cqz != c.cqz || ituz != c.ituz)) {
t.Errorf("ZoneByCallDistrict(%d,%q) = %d/%d ok=%v, want %d/%d ok=%v",
c.adif, c.call, cqz, ituz, ok, c.cqz, c.ituz, c.ok)
}
}
}
// KG4 is Guantanamo Bay only with a 2-character suffix (KG4XX); 1- or 3-char
// suffixes (KG4W, KG4ABC) are continental USA. cty.dat carries a bare "KG4"
// prefix, so the resolver must apply the suffix-length rule.
func TestKG4SuffixRule(t *testing.T) {
const cty = `United States: 05: 08: NA: 37.53: 91.67: 5.0: K:
K,N,W;
Guantanamo Bay: 08: 11: NA: 19.92: 75.18: -5.0: KG4:
KG4;
`
db, err := Load(strings.NewReader(cty))
if err != nil {
t.Fatalf("load: %v", err)
}
cases := map[string]string{
"KG4W": "United States", // 1-char suffix
"KG4AA": "Guantanamo Bay", // 2-char suffix
"KG4ABC": "United States", // 3-char suffix
"KG4": "United States", // no suffix
"KG4W/P": "United States", // modifier stripped, still 1-char
}
for call, want := range cases {
m, ok := db.Lookup(call)
if !ok {
t.Errorf("%s: no match", call)
continue
}
if m.Entity.Name != want {
t.Errorf("%s: got %q, want %q", call, m.Entity.Name, want)
}
}
}
// cty.dat marks non-DXCC entities (Sicily *IT9, African Italy *IG9) with a
// leading '*'; the parser must fold those into their parent DXCC entity
// "Italy" while leaving real DXCC entities — Sardinia (IS0), no '*' — alone.
@@ -108,9 +198,14 @@ func TestNormalize(t *testing.T) {
"f4bpo": "F4BPO",
" F4BPO ": "F4BPO",
"F4BPO/P": "F4BPO",
"F4BPO/MM": "F4BPO",
"F4BPO/5": "F4BPO",
"F4BPO/MM": "", // maritime mobile → no DXCC entity
"F4BPO/AM": "", // aeronautical mobile → no DXCC entity
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
"DL/F4BPO": "DL",
"MM/KA9P": "MM", // leading MM = Scotland operating prefix
"MM/LY3X/P": "MM",
"F4BPO/W6": "W6",
"VK9/F4BPO": "VK9",
}