fix(counties): make the cluster and the entry panel agree on a US county

An operator asked why K1SEI showed "Middlesex" in Info (F2) and "Lower
Connecticut River Valley" in the cluster. Two sources: the entry panel has
a callbook answer, a spot carries only a callsign so the cluster derives one
from the FCC licence ZIP through GeoNames — and GeoNames has followed the
Census in replacing Connecticut's counties with the 2022 planning regions.
No award, callbook or log uses those, so every CT station matched nothing:
new county for ever, and counting toward nothing.

Measured against a full ULS import (1 556 444 US callsigns), 23 223 resolved
to a name the USA-CA reference does not contain. Three causes, three fixes:

  - Spelling. "City and County of San Francisco", "Baltimore (city)",
    "Nome (CA)", plus counties renamed since the award list was drawn
    (Kusilvak, Oglala Lakota, the Valdez-Cordova split) and Alaska's four
    "X City and Borough", whose reference codes read "JUNEAUCITYAND" because
    the county-type suffix strip eats the wrong end. Normalised in
    award.USCountyKey, which both sides already go through. 7 515 callsigns,
    no re-download needed.

  - Doña Ana, NM shipped into the reference as "NM/DO̱AANA" — mangled by a
    non-UTF-8 CSV line, a code nothing could ever produce, so that county was
    unwinnable and silent about it. Row repaired, cntygen now refuses such a
    line, and a test makes every one of the 3 102 references reproduce its own
    code from its own name.

  - Connecticut. A planning region is drawn from towns in several counties, so
    no name maps to a name — only the ZIP can resolve it. cmd/ctzipgen builds
    the table from the Census 2020 crosswalk, filling PO-box-only ZIPs from the
    nearest resolved centroid; all 11 ZIPs GeoNames still labels with a real
    county agree with the result. 15 037 callsigns, applied at import, so the
    store now carries a rules version and Settings says when a re-download is
    needed.

Alignment itself is the last piece: a spot now shows the county the station is
logged with when we have one, and falls back to the ZIP-derived county only for
stations never worked.
This commit is contained in:
2026-08-14 00:42:27 +02:00
parent fbd62fda30
commit 30143b01bf
15 changed files with 1122 additions and 15 deletions
+1 -1
View File
@@ -1943,7 +1943,7 @@ func usCounties() []Ref {
ref("NM/COLFAX", "Colfax County, NM", 291),
ref("NM/CURRY", "Curry County, NM", 291),
ref("NM/DEBACA", "De Baca County, NM", 291),
ref("NM/DO̱AANA", "Do̱a Ana County, NM", 291),
ref("NM/DONAANA", "Doña Ana County, NM", 291),
ref("NM/EDDY", "Eddy County, NM", 291),
ref("NM/GRANT", "Grant County, NM", 291),
ref("NM/GUADALUPE", "Guadalupe County, NM", 291),
+118
View File
@@ -0,0 +1,118 @@
package awardref
import (
"strings"
"testing"
"unicode/utf8"
"hamlog/internal/award"
)
// TestUSCountyRefsSelfConsistent is the guard that was missing when the Doña Ana
// row shipped mangled: its code was "NM/DO̱AANA", which no log, no callbook and
// no county database could ever produce, so that county was unwinnable and
// nothing said so.
//
// Every reference must reproduce its own code from its own display name through
// award.USCountyKey — the same function cmd/cntygen used to build it. That ties
// the generated file to the matcher: change the normalisation rules and this
// fails the moment one of the 3 102 references stops matching itself.
func TestUSCountyRefsSelfConsistent(t *testing.T) {
refs := usCounties()
if len(refs) < 3000 {
t.Fatalf("only %d counties — the generated list looks truncated", len(refs))
}
for _, r := range refs {
if !utf8.ValidString(r.Name) || !utf8.ValidString(r.Code) {
t.Errorf("%s (%q): not valid UTF-8", r.Code, r.Name)
continue
}
// Name is "County Name, ST".
i := strings.LastIndex(r.Name, ", ")
if i < 0 {
t.Errorf("%s: name %q is not \"County, ST\"", r.Code, r.Name)
continue
}
name, st := r.Name[:i], r.Name[i+2:]
if got := award.USCountyKey(st, name); got != r.Code {
t.Errorf("USCountyKey(%q, %q) = %q, want %q", st, name, got, r.Code)
}
}
}
// TestUSCountyRefsUnique catches a normalisation rule loose enough to collapse
// two real counties onto one key — the failure mode that would silently merge
// them in every award total.
func TestUSCountyRefsUnique(t *testing.T) {
seen := map[string]string{}
for _, r := range usCounties() {
if prev, dup := seen[r.Code]; dup {
t.Errorf("code %s is shared by %q and %q", r.Code, prev, r.Name)
continue
}
seen[r.Code] = r.Name
}
}
// TestUSCountyResolverShapes pins the spellings OpsLog's own offline resolver
// produces (GeoNames, via internal/uls) against the reference. These are the
// real strings that were failing to match, with the number of US callsigns each
// covers in a full FCC ULS import.
func TestUSCountyResolverShapes(t *testing.T) {
valid := map[string]bool{}
for _, r := range usCounties() {
valid[r.Code] = true
}
cases := []struct {
st, county, want string
}{
{"CA", "City and County of San Francisco", "CA/SANFRANCISCO"}, // 4 217 calls
{"NM", "Doña Ana", "NM/DONAANA"}, // 1 214
{"NM", "Dona Ana", "NM/DONAANA"}, // what logs hold
{"MD", "Baltimore (city)", "MD/BALTIMORECITY"}, // 885
{"MO", "St. Louis (city)", "MO/STLOUISCITY"}, // 574
{"MO", "St. Louis", "MO/STLOUIS"}, // the county, distinct
{"AK", "Nome (CA)", "AK/NOME"}, // 252
{"AK", "Yukon-Koyukuk (CA)", "AK/YUKONKOYUKUK"}, // 84
{"AK", "City and Borough of Wrangell", "AK/WRANGELLCITYAND"}, // 27
{"AK", "Juneau", "AK/JUNEAUCITYAND"}, // a log that just says Juneau
{"AK", "Kusilvak", "AK/WADEHAMPTON"}, // renamed 2015
{"SD", "Oglala Lakota County", "SD/SHANNON"}, // renamed 2015
{"AK", "Chugach Census Area", "AK/VALDEZCORDOVA"}, // split 2019
{"AK", "Copper River Census Area", "AK/VALDEZCORDOVA"},
{"CO", "City and County of Denver", "CO/DENVER"},
{"HI", "City and County of Honolulu", "HI/HONOLULU"},
}
for _, c := range cases {
got := award.USCountyKey(c.st, c.county)
if got != c.want {
t.Errorf("USCountyKey(%q, %q) = %q, want %q", c.st, c.county, got, c.want)
continue
}
if !valid[got] {
t.Errorf("USCountyKey(%q, %q) = %q, which is not in the reference list", c.st, c.county, got)
}
}
}
// TestUSCountyKeepsVirginiaCitiesOut guards the other direction: cmd/cntygen
// drops Virginia's independent cities and Carson City on purpose, because CQ's
// USA-CA does not count them. Normalising their names must not smuggle them
// back in as some neighbouring county.
func TestUSCountyKeepsVirginiaCitiesOut(t *testing.T) {
valid := map[string]bool{}
for _, r := range usCounties() {
valid[r.Code] = true
}
for _, c := range []string{"Virginia Beach (city)", "City of Alexandria", "Lynchburg (city)"} {
if k := award.USCountyKey("VA", c); valid[k] {
t.Errorf("%q resolved to %s, which USA-CA does not count", c, k)
}
}
// Charles City and James City ARE counties, despite the name.
for _, c := range []string{"Charles City", "James City"} {
if k := award.USCountyKey("VA", c); !valid[k] {
t.Errorf("%q resolved to %s, which is missing from the reference", c, k)
}
}
}