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
+13 -4
View File
@@ -749,9 +749,14 @@ func EmissionOf(mode string) string { return emissionOf(mode) }
// abbreviating "Saint"→"St." and hyphenating "Matanuska-Susitna". The rules:
// - if cnty already carries "ST,County", split on the first comma; else take
// the state from the STATE column;
// - upper-case; drop periods and apostrophes; hyphens→space; strip a trailing
// County/Parish/Borough/Census Area/Municipality; fold Saint(e)→St(e);
// collapse whitespace;
// - upper-case; fold accents (the FIPS list writes "Doña Ana", callbooks and
// logs write "Dona Ana"); drop periods and apostrophes; hyphens→space;
// expand the parenthetical shapes GeoNames uses for county-equivalents
// ("Baltimore (city)", "Nome (CA)") and drop its "City and County of"
// style prefixes; strip a trailing County/Parish/Borough/Census Area/
// Municipality; fold Saint(e)→St(e); collapse whitespace;
// - fold the counties renamed since the USA-CA list was drawn onto the name
// the award still uses, so a QSO there counts for something (see renamed);
// - require a 2-letter state and a non-empty county, else no match ("").
func USCountyKey(state, cnty string) string {
s := strings.TrimSpace(cnty)
@@ -764,10 +769,11 @@ func USCountyKey(state, cnty string) string {
} else {
st, co = strings.TrimSpace(state), s
}
co = strings.ToUpper(co)
co = strings.ToUpper(foldAccents(co))
co = strings.ReplaceAll(co, ".", "")
co = strings.ReplaceAll(co, "'", "")
co = strings.ReplaceAll(co, "-", " ")
co = expandCountyEquivalent(co)
for _, suf := range []string{" COUNTY", " PARISH", " BOROUGH", " CENSUS AREA", " MUNICIPALITY"} {
if strings.HasSuffix(co, suf) {
co = strings.TrimSuffix(co, suf)
@@ -788,6 +794,9 @@ func USCountyKey(state, cnty string) string {
if len(st) != 2 || co == "" {
return ""
}
if alias, ok := renamed[st+"/"+co]; ok {
co = alias
}
// Separator is "/", NOT ",": the QSOFIELDS matcher splits a field value on
// commas/semicolons (n-fer POTA "US-1,US-2"), which would shatter "AL,AUTAUGA"
// into two non-matching tokens. The stored ADIF cnty keeps its comma; only
+120
View File
@@ -0,0 +1,120 @@
package award
import "strings"
// Name normalisation for USCountyKey. It exists because the county a QSO
// carries and the county the USA-CA reference list carries come from different
// hands and rarely agree letter for letter:
//
// - the reference is the FIPS list ("Doña Ana County", "Baltimore city");
// - a callbook writes what the licensee typed;
// - and OpsLog's own offline resolver reads GeoNames, which spells the
// county-equivalents its own way ("Baltimore (city)", "Nome (CA)",
// "City and County of San Francisco").
//
// Measured against a full FCC ULS import (1.56 M US callsigns), the shapes
// handled here account for ~7 300 stations whose county matched nothing at all
// — they showed as a new county for ever and counted for no award.
//
// Everything here is SPELLING. A county that genuinely no longer exists under
// that name is a different problem, handled by renamed below.
// foldAccents strips the diacritics that separate a FIPS name from the ASCII
// every log and callbook actually holds. Only the letters that occur in US
// place names are listed — this is not a general Unicode folder, and it must
// not become one: silently folding arbitrary marks would let two distinct
// references collapse onto one key.
var accents = strings.NewReplacer(
"ñ", "n", "Ñ", "N",
"á", "a", "Á", "A",
"é", "e", "É", "E",
"í", "i", "Í", "I",
"ó", "o", "Ó", "O",
"ú", "u", "Ú", "U",
"ü", "u", "Ü", "U",
"ç", "c", "Ç", "C",
)
func foldAccents(s string) string {
// Fast path: US county names are ASCII with a single exception (Doña Ana),
// so the common case must not pay for the replacer.
for i := 0; i < len(s); i++ {
if s[i] >= 0x80 {
return accents.Replace(s)
}
}
return s
}
// expandCountyEquivalent rewrites GeoNames' parenthetical and prefixed spellings
// of county-equivalents into the plain FIPS form, which the rest of USCountyKey
// then normalises as usual.
//
// Input is already upper-cased with periods and hyphens removed.
//
// BALTIMORE (CITY) → BALTIMORE CITY (FIPS "Baltimore city")
// NOME (CA) → NOME CENSUS AREA (suffix stripped after)
// CITY AND COUNTY OF SAN FRANCISCO → SAN FRANCISCO
//
// The truncated "(CITY" is not a typo here: GeoNames really does ship
// "Colonial Heights (city" with the closing parenthesis missing.
func expandCountyEquivalent(co string) string {
switch {
case strings.HasSuffix(co, " (CITY)"):
co = strings.TrimSuffix(co, " (CITY)") + " CITY"
case strings.HasSuffix(co, " (CITY"):
co = strings.TrimSuffix(co, " (CITY") + " CITY"
case strings.HasSuffix(co, " (CA)"):
co = strings.TrimSuffix(co, " (CA)") + " CENSUS AREA"
}
// "City and County of X" and "City and Borough of X" are the same place as
// FIPS's bare "X" (San Francisco, Denver, Honolulu, Juneau, Sitka). Note the
// order: the longer prefixes must be tried before "CITY OF ".
for _, p := range []string{
"CITY AND COUNTY OF ",
"CITY AND BOROUGH OF ",
"MUNICIPALITY OF ",
"BOROUGH OF ",
"CITY OF ",
} {
if strings.HasPrefix(co, p) {
return strings.TrimPrefix(co, p)
}
}
return co
}
// renamed folds a county renamed or re-drawn since the CQ USA-CA list was
// published onto the name the award still counts. Keys and values are finished
// USCountyKey county parts (upper-case, no spaces), looked up as "ST/COUNTY".
//
// This deliberately does NOT track the FIPS list. USA-CA is a fixed target of
// ~3 100 counties; when a state renames one, the award does not reissue its
// list, so a QSO with the new name would otherwise match nothing and count for
// nothing. Folding it onto the old name is the only outcome that scores.
//
// Connecticut is absent on purpose. Its 2022 planning regions are not renamed
// counties: a region is drawn from towns belonging to several different
// counties, so there is no name that maps to another name. That one is resolved
// from the station's ZIP, in internal/uls.
var renamed = map[string]string{
// Wade Hampton Census Area, AK → Kusilvak Census Area (2015).
"AK/KUSILVAK": "WADEHAMPTON",
// Shannon County, SD → Oglala Lakota County (2015).
"SD/OGLALALAKOTA": "SHANNON",
// Valdez-Cordova Census Area, AK was split in two (2019). Both halves fold
// back to the parent the award still lists.
"AK/CHUGACH": "VALDEZCORDOVA",
"AK/COPPERRIVER": "VALDEZCORDOVA",
// Alaska's four "X City and Borough". The reference codes for these look
// wrong and are not: the trailing " BOROUGH" is stripped as a county type,
// leaving "JUNEAU CITY AND". Nothing else in the chain produces that shape
// — a log that simply says "Juneau", and GeoNames' "City and Borough of
// Juneau", both arrive here as "JUNEAU" and would match nothing. Rather
// than re-key four published references, bend the bare name onto them.
"AK/JUNEAU": "JUNEAUCITYAND",
"AK/SITKA": "SITKACITYAND",
"AK/WRANGELL": "WRANGELLCITYAND",
"AK/YAKUTAT": "YAKUTATCITYAND",
}