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