diff --git a/app.go b/app.go
index b93f283..32c082d 100644
--- a/app.go
+++ b/app.go
@@ -10495,6 +10495,11 @@ func (a *App) applyULSCounty(q *qso.QSO) {
type ULSStatusResult struct {
Count int `json:"count"`
UpdatedAt string `json:"updated_at"` // RFC3339, empty if never downloaded
+ // NeedsRefresh is set when the stored data predates a correction to how a
+ // callsign's county is derived. The download date alone cannot show this —
+ // a database fetched yesterday by an older OpsLog still holds the wrong
+ // Connecticut counties — so it is reported as its own flag.
+ NeedsRefresh bool `json:"needs_refresh"`
}
// ULSStatus returns the state of the offline US county database.
@@ -10506,7 +10511,7 @@ func (a *App) ULSStatus() ULSStatusResult {
if t := a.uls.UpdatedAt(); !t.IsZero() {
updated = t.Format(time.RFC3339)
}
- return ULSStatusResult{Count: a.uls.Count(), UpdatedAt: updated}
+ return ULSStatusResult{Count: a.uls.Count(), UpdatedAt: updated, NeedsRefresh: a.uls.NeedsRefresh()}
}
// DownloadULSCounties downloads and (re)builds the offline US county database in
@@ -17124,7 +17129,11 @@ type clusterStatusCache struct {
workedCalls map[string]struct{}
workedCallSlots map[string]struct{} // nil unless the "same slot" option is on
workedCounties map[string]struct{}
- workedPOTA map[string]struct{}
+ // callCounties holds callsign → "STATE,County" for stations already logged
+ // with a county, so a spot shows the county the entry panel showed rather
+ // than the one derived from the licence ZIP. See qso.CallCounties.
+ callCounties map[string]string
+ workedPOTA map[string]struct{}
workedPfx map[string]struct{}
workedGrids map[string]struct{} // "GRID|MODE", mode normalised like the rest
normMode func(string) string // nil unless digital-mode grouping is on
@@ -17192,6 +17201,7 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
// Orthogonal dimensions: worked US counties (for the ULS callsign→county
// lookup) and worked POTA parks.
c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
+ c.callCounties, _ = a.qso.CallCounties(a.ctx)
c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx)
// One more DISTINCT scan when the snapshot is rebuilt, then pure map lookups
// per spot — the same shape as the county and POTA sets beside it, which is
@@ -17468,9 +17478,27 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
}
}
}
- // NEW COUNTY: resolve the callsign's home county from the offline ULS
- // store (US only; inert until downloaded) and flag if never worked.
- if a.uls != nil {
+ // NEW COUNTY. Two sources, better one first:
+ //
+ // 1. the county this station was logged with — a callbook's answer,
+ // the same one the entry panel shows;
+ // 2. the offline ULS store, which derives a county from the licence
+ // ZIP (US only; inert until downloaded, ~98% for fixed stations).
+ //
+ // Preferring the log is what keeps the cluster column and the entry
+ // panel from disagreeing about the same callsign, which was impossible
+ // to explain and made both look wrong.
+ if cnty, ok := idx.callCounties[q.Call]; ok {
+ st, name, _ := strings.Cut(cnty, ",")
+ out[i].State, out[i].County = st, name
+ // Logged means worked, so this can never be a new county — but say
+ // so through the same key the flag below uses, not by assumption.
+ if key := award.USCountyKey(st, name); key != "" {
+ if _, done := workedCounties[key]; !done {
+ out[i].NewCounty = true
+ }
+ }
+ } else if a.uls != nil {
if loc, ok := a.uls.Resolve(q.Call); ok {
out[i].County, out[i].State = loc.County, loc.State
if key := award.USCountyKey(loc.State, loc.County); key != "" {
diff --git a/changelog.json b/changelog.json
index 71564d6..65ec540 100644
--- a/changelog.json
+++ b/changelog.json
@@ -7,14 +7,20 @@
"Shared CAT: with the wire trace on, every command a client sends and the answer given are logged.",
"Shared CAT: JTDX and WSJT-X no longer get an error for turning split off on a rig that has none, or for setting the transmit mode — which made JTDX abandon a transmission.",
"QSL designer: a card design can be copied from another profile, pictures included — a second profile for a different rig no longer means redrawing it.",
- "Appearance: new Sahara theme, warm sand tones for long sessions in daylight."
+ "Appearance: new Sahara theme, warm sand tones for long sessions in daylight.",
+ "US counties: Connecticut resolved to the census planning regions, so every CT station showed as a new county. Re-download the database.",
+ "US counties: San Francisco, Doña Ana, Baltimore city, St. Louis city and several Alaska boroughs matched no county at all.",
+ "DX cluster: the US county column now shows the county the station is logged with, so it agrees with the Info panel."
],
"fr": [
"Amplificateurs : coche ceux qui partagent un combiner et ON, OFF et OPERATE agissent sur tous à la fois. Chacun garde ses mesures.",
"CAT partagé : avec la trace activée, chaque commande envoyée par un client et la réponse donnée sont journalisées.",
"CAT partagé : JTDX et WSJT-X ne reçoivent plus d erreur en désactivant un split inexistant ni en réglant le mode d émission — ce qui faisait abandonner une émission à JTDX.",
"Concepteur QSL : un modèle de carte peut être copié depuis un autre profil, images comprises — un second profil pour une autre radio n oblige plus à le redessiner.",
- "Apparence : nouveau thème Sahara, tons sable chauds pour les longues sessions en plein jour."
+ "Apparence : nouveau thème Sahara, tons sable chauds pour les longues sessions en plein jour.",
+ "Comtés US : le Connecticut renvoyait les planning regions du recensement, toute station CT semblait un nouveau comté. Rechargez la base.",
+ "Comtés US : San Francisco, Doña Ana, Baltimore city, St. Louis city et plusieurs districts d’Alaska ne correspondaient à aucun comté.",
+ "Cluster DX : la colonne comté US affiche le comté du log quand la station y figure, donc identique au panneau Info."
]
},
{
diff --git a/cmd/cntygen/main.go b/cmd/cntygen/main.go
index e6ab669..d710d28 100644
--- a/cmd/cntygen/main.go
+++ b/cmd/cntygen/main.go
@@ -9,6 +9,7 @@ import (
"os"
"sort"
"strings"
+ "unicode/utf8"
"hamlog/internal/award"
)
@@ -58,6 +59,15 @@ func main() {
sc.Scan() // header
for sc.Scan() {
line := sc.Text()
+ // The FIPS CSV is not reliably UTF-8, and the one county whose name is
+ // not ASCII (Doña Ana, NM) came through mangled once already — it landed
+ // in the reference as "NM/DO̱AANA", a code no log could ever match,
+ // silently costing that county for every operator. Refuse the row rather
+ // than emit a broken one.
+ if !utf8.ValidString(line) {
+ fmt.Fprintf(os.Stderr, "cntygen: skipping non-UTF-8 line: %q\n", line)
+ continue
+ }
parts := strings.SplitN(line, ",", 3)
if len(parts) < 3 {
continue
diff --git a/cmd/ctzipgen/main.go b/cmd/ctzipgen/main.go
new file mode 100644
index 0000000..97455a5
--- /dev/null
+++ b/cmd/ctzipgen/main.go
@@ -0,0 +1,203 @@
+package main
+
+// One-shot generator: emits internal/uls/ctcounty_gen.go, the Connecticut
+// ZIP → legal county table. Not part of the build.
+//
+// Why it has to exist
+// -------------------
+// internal/uls resolves a US callsign to a county through GeoNames' ZIP table.
+// The Census replaced Connecticut's eight counties with nine PLANNING REGIONS
+// as county-equivalents in 2022, and GeoNames followed: 418 of Connecticut's
+// 429 ZIPs now report "Capitol Region", "Lower Connecticut River Valley" and
+// so on. CQ's USA-CA award did not follow, callbooks did not follow, and no
+// operator's log did either — so every Connecticut station resolved to a name
+// nothing could match, showed as a new county for ever, and counted for no
+// award. Roughly 15 000 US callsigns.
+//
+// A planning region is NOT a renamed county — it is built from towns drawn
+// from several different counties — so there is no name-to-name mapping to be
+// had. The ZIP is the only handle, hence this table.
+//
+// Sources (both public, both free)
+// --------------------------------
+// geonames US.txt from https://download.geonames.org/export/zip/US.zip
+// — the ZIP list itself, and each ZIP's centroid.
+// crosswalk https://www2.census.gov/geo/docs/maps-data/data/rel2020/
+// zcta520/tab20_zcta520_county20_natl.txt
+// — the 2020 ZCTA↔county relationship file, which predates the
+// change and therefore still carries the eight real counties.
+//
+// The crosswalk covers 278 of the 418 affected ZIPs. The rest are PO-box-only
+// ZIPs, which have no ZCTA at all; each is given the county of the nearest
+// resolved ZIP centroid. That is sound here because a PO-box ZIP sits inside
+// the town it serves, and Connecticut's counties are tens of kilometres across
+// — the fallback can only err on a ZIP that straddles a county line, which the
+// ZIP-to-county approach is already documented as accepting (~98%).
+//
+// Usage:
+//
+// go run ./cmd/ctzipgen US.txt tab20_zcta520_county20_natl.txt > internal/uls/ctcounty_gen.go
+
+import (
+ "bufio"
+ "fmt"
+ "math"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+type zipPt struct {
+ zip string
+ lat, lon float64
+ county string // from the crosswalk, "" if the ZIP has no ZCTA
+ geoName string // what GeoNames says today
+}
+
+func main() {
+ if len(os.Args) < 3 {
+ fmt.Fprintln(os.Stderr, "usage: ctzipgen US.txt tab20_zcta520_county20_natl.txt")
+ os.Exit(2)
+ }
+
+ // 1. Every Connecticut ZIP, with its centroid, from GeoNames.
+ var pts []*zipPt
+ byZip := map[string]*zipPt{}
+ f, err := os.Open(os.Args[1])
+ must(err)
+ sc := bufio.NewScanner(f)
+ sc.Buffer(make([]byte, 0, 64*1024), 256*1024)
+ for sc.Scan() {
+ c := strings.Split(sc.Text(), "\t")
+ if len(c) < 11 || strings.ToUpper(strings.TrimSpace(c[4])) != "CT" {
+ continue
+ }
+ z := strings.TrimSpace(c[1])
+ if z == "" || byZip[z] != nil {
+ continue
+ }
+ p := &zipPt{zip: z, lat: atof(c[9]), lon: atof(c[10]), geoName: strings.TrimSpace(c[5])}
+ byZip[z] = p
+ pts = append(pts, p)
+ }
+ must(sc.Err())
+ f.Close()
+
+ // 2. The 2020 county for each ZCTA. A ZCTA can straddle a county line, so
+ // keep the county holding the largest share of its land area.
+ best := map[string]float64{}
+ f2, err := os.Open(os.Args[2])
+ must(err)
+ sc2 := bufio.NewScanner(f2)
+ sc2.Buffer(make([]byte, 0, 64*1024), 1<<20)
+ sc2.Scan() // header
+ for sc2.Scan() {
+ c := strings.Split(sc2.Text(), "|")
+ if len(c) < 18 || !strings.HasPrefix(c[9], "09") {
+ continue
+ }
+ p := byZip[strings.TrimSpace(c[1])]
+ if p == nil {
+ continue
+ }
+ area := atof(c[16])
+ if p.county != "" && area <= best[p.zip] {
+ continue
+ }
+ best[p.zip] = area
+ p.county = strings.TrimSuffix(strings.TrimSpace(c[10]), " County")
+ }
+ must(sc2.Err())
+ f2.Close()
+
+ // 3. Fill the PO-box-only ZIPs from the nearest ZIP the crosswalk resolved.
+ var anchors []*zipPt
+ for _, p := range pts {
+ if p.county != "" {
+ anchors = append(anchors, p)
+ }
+ }
+ if len(anchors) == 0 {
+ fmt.Fprintln(os.Stderr, "ctzipgen: crosswalk resolved nothing — wrong file?")
+ os.Exit(1)
+ }
+ filled := 0
+ for _, p := range pts {
+ if p.county != "" {
+ continue
+ }
+ nearest, bestD := "", math.MaxFloat64
+ for _, a := range anchors {
+ if d := haversine(p.lat, p.lon, a.lat, a.lon); d < bestD {
+ bestD, nearest = d, a.county
+ }
+ }
+ p.county = nearest
+ filled++
+ }
+
+ // 4. Self-check: the ZIPs GeoNames still labels with a real county must
+ // agree with what we derived, or the derivation is wrong.
+ real := map[string]bool{
+ "Fairfield": true, "Hartford": true, "Litchfield": true, "Middlesex": true,
+ "New Haven": true, "New London": true, "Tolland": true, "Windham": true,
+ }
+ checked, bad := 0, 0
+ for _, p := range pts {
+ if !real[p.geoName] {
+ continue
+ }
+ checked++
+ if p.geoName != p.county {
+ bad++
+ fmt.Fprintf(os.Stderr, "MISMATCH %s: geonames %q, derived %q\n", p.zip, p.geoName, p.county)
+ }
+ }
+ fmt.Fprintf(os.Stderr, "ctzipgen: %d zips, %d from crosswalk, %d by nearest; cross-check %d/%d agree\n",
+ len(pts), len(pts)-filled, filled, checked-bad, checked)
+ if bad > 0 {
+ os.Exit(1)
+ }
+
+ sort.Slice(pts, func(i, j int) bool { return pts[i].zip < pts[j].zip })
+
+ var b strings.Builder
+ b.WriteString("// Code generated by cmd/ctzipgen. DO NOT EDIT.\n\n")
+ b.WriteString("package uls\n\n")
+ b.WriteString("// ctCounty maps a Connecticut ZIP to its legal county.\n")
+ b.WriteString("//\n")
+ b.WriteString("// GeoNames reports Connecticut's 2022 planning regions instead, which no\n")
+ b.WriteString("// award, callbook or log uses. See cmd/ctzipgen for how this was built and\n")
+ b.WriteString("// why a name-to-name mapping cannot work.\n")
+ fmt.Fprintf(&b, "var ctCounty = map[string]string{\n")
+ for _, p := range pts {
+ fmt.Fprintf(&b, "\t%q: %q,\n", p.zip, p.county)
+ }
+ b.WriteString("}\n")
+ fmt.Print(b.String())
+}
+
+func atof(s string) float64 {
+ v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
+ return v
+}
+
+// haversine returns the great-circle distance in km. Connecticut is small
+// enough that a flat approximation would do, but this costs nothing and cannot
+// be wrong near the state's edges.
+func haversine(lat1, lon1, lat2, lon2 float64) float64 {
+ const r = 6371.0
+ dLat := (lat2 - lat1) * math.Pi / 180
+ dLon := (lon2 - lon1) * math.Pi / 180
+ a := math.Sin(dLat/2)*math.Sin(dLat/2) +
+ math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Sin(dLon/2)*math.Sin(dLon/2)
+ return 2 * r * math.Asin(math.Sqrt(a))
+}
+
+func must(err error) {
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "ctzipgen:", err)
+ os.Exit(1)
+ }
+}
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index af47f85..b458683 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -1489,7 +1489,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
finally { setScpBusy(false); }
};
// US Counties (offline FCC ULS) — download progress arrives via events.
- const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string }>({ count: 0 });
+ const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string; needs_refresh?: boolean }>({ count: 0 });
const [ulsBusy, setUlsBusy] = useState(false);
const [ulsProgress, setUlsProgress] = useState<{ stage: string; pct: number } | null>(null);
const [ulsMsg, setUlsMsg] = useState<{ ok: boolean; text: string } | null>(null);
@@ -6233,6 +6233,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
+ {/* A database downloaded by an older OpsLog still holds Connecticut's
+ planning regions instead of its counties. The download date cannot
+ show that, so say it outright. */}
+ {loaded && ulsStatus.needs_refresh && !ulsBusy && (
+
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx
index 1a35bb7..07fe35b 100644
--- a/frontend/src/lib/i18n.tsx
+++ b/frontend/src/lib/i18n.tsx
@@ -148,6 +148,7 @@ const en: Dict = {
'uscty.dbStatus': 'County database',
'uscty.loaded': '{n} callsigns · updated {date}',
'uscty.notLoaded': 'Not downloaded yet.',
+ 'uscty.stale': 'This database was built before Connecticut counties were corrected. Download it again to fix them.',
'uscty.download': 'Download',
'uscty.update': 'Update',
'uscty.done': 'County database ready — {n} callsigns.',
@@ -575,6 +576,7 @@ const fr: Dict = {
'uscty.dbStatus': 'Base des comtés',
'uscty.loaded': '{n} indicatifs · maj {date}',
'uscty.notLoaded': 'Pas encore téléchargée.',
+ 'uscty.stale': 'Cette base a été construite avant la correction des comtés du Connecticut. Téléchargez-la à nouveau pour les corriger.',
'uscty.download': 'Télécharger',
'uscty.update': 'Mettre à jour',
'uscty.done': 'Base des comtés prête — {n} indicatifs.',
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 83edbe6..582e06e 100644
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -3362,6 +3362,7 @@ export namespace main {
export class ULSStatusResult {
count: number;
updated_at: string;
+ needs_refresh: boolean;
static createFrom(source: any = {}) {
return new ULSStatusResult(source);
@@ -3371,6 +3372,7 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source);
this.count = source["count"];
this.updated_at = source["updated_at"];
+ this.needs_refresh = source["needs_refresh"];
}
}
export class UltrabeamSettings {
diff --git a/internal/award/award.go b/internal/award/award.go
index 4d0cd21..e98befb 100644
--- a/internal/award/award.go
+++ b/internal/award/award.go
@@ -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
diff --git a/internal/award/uscounty_names.go b/internal/award/uscounty_names.go
new file mode 100644
index 0000000..729c1e4
--- /dev/null
+++ b/internal/award/uscounty_names.go
@@ -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",
+}
diff --git a/internal/awardref/uscounties_gen.go b/internal/awardref/uscounties_gen.go
index f2e1197..c98c08e 100644
--- a/internal/awardref/uscounties_gen.go
+++ b/internal/awardref/uscounties_gen.go
@@ -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),
diff --git a/internal/awardref/uscounties_test.go b/internal/awardref/uscounties_test.go
new file mode 100644
index 0000000..ee2df68
--- /dev/null
+++ b/internal/awardref/uscounties_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/qso/qso.go b/internal/qso/qso.go
index fe3dfea..424f2cc 100644
--- a/internal/qso/qso.go
+++ b/internal/qso/qso.go
@@ -2265,6 +2265,52 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
return out, rows.Err()
}
+// CallCounties returns callsign → "STATE,County" for every US station already
+// logged with a county, newest QSO winning.
+//
+// It exists so the cluster shows the SAME county the entry panel does. The
+// entry panel gets its county from a callbook, which knows where the licensee
+// actually lives; a spot carries only a callsign, so the cluster falls back to
+// deriving one from the FCC licence address, which is right about 98% of the
+// time. Where the log already holds a callbook's answer for that station, the
+// two panels disagreeing is pure noise — this is what lets the better answer
+// win in both.
+//
+// One DISTINCT scan when the cluster snapshot is rebuilt, then map lookups per
+// spot, like the worked-county and worked-grid sets beside it.
+func (r *Repo) CallCounties(ctx context.Context) (map[string]string, error) {
+ rows, err := r.db.QueryContext(ctx,
+ `SELECT callsign, COALESCE(state,''), COALESCE(cnty,'') FROM qso
+ WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''
+ ORDER BY qso_date, time_on`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := make(map[string]string, 1024)
+ for rows.Next() {
+ var call, state, cnty string
+ if err := rows.Scan(&call, &state, &cnty); err != nil {
+ return nil, err
+ }
+ call = strings.ToUpper(strings.TrimSpace(call))
+ cnty = strings.TrimSpace(cnty)
+ if call == "" || cnty == "" {
+ continue
+ }
+ // Store the ADIF "STATE,County" shape whatever the log holds, so the
+ // caller has both halves without a second column.
+ if !strings.Contains(cnty, ",") {
+ if state = strings.TrimSpace(state); state == "" {
+ continue
+ }
+ cnty = state + "," + cnty
+ }
+ out[call] = cnty
+ }
+ return out, rows.Err()
+}
+
// CountyWorked reports whether one US county has already been worked.
//
// It exists so the entry panel can flag a new county without loading the whole
diff --git a/internal/uls/ctcounty_gen.go b/internal/uls/ctcounty_gen.go
new file mode 100644
index 0000000..fe67cf6
--- /dev/null
+++ b/internal/uls/ctcounty_gen.go
@@ -0,0 +1,440 @@
+// Code generated by cmd/ctzipgen. DO NOT EDIT.
+
+package uls
+
+// ctCounty maps a Connecticut ZIP to its legal county.
+//
+// GeoNames reports Connecticut's 2022 planning regions instead, which no
+// award, callbook or log uses. See cmd/ctzipgen for how this was built and
+// why a name-to-name mapping cannot work.
+var ctCounty = map[string]string{
+ "06001": "Hartford",
+ "06002": "Hartford",
+ "06006": "Hartford",
+ "06010": "Hartford",
+ "06011": "Hartford",
+ "06013": "Hartford",
+ "06016": "Hartford",
+ "06018": "Litchfield",
+ "06019": "Hartford",
+ "06020": "Hartford",
+ "06021": "Litchfield",
+ "06022": "Hartford",
+ "06023": "Hartford",
+ "06024": "Litchfield",
+ "06025": "Hartford",
+ "06026": "Hartford",
+ "06027": "Hartford",
+ "06028": "Hartford",
+ "06029": "Tolland",
+ "06030": "Hartford",
+ "06031": "Litchfield",
+ "06032": "Hartford",
+ "06033": "Hartford",
+ "06034": "Hartford",
+ "06035": "Hartford",
+ "06037": "Hartford",
+ "06039": "Litchfield",
+ "06040": "Hartford",
+ "06041": "Hartford",
+ "06042": "Hartford",
+ "06043": "Tolland",
+ "06045": "Hartford",
+ "06050": "Hartford",
+ "06051": "Hartford",
+ "06052": "Hartford",
+ "06053": "Hartford",
+ "06057": "Litchfield",
+ "06058": "Litchfield",
+ "06059": "Hartford",
+ "06060": "Hartford",
+ "06061": "Litchfield",
+ "06062": "Hartford",
+ "06063": "Litchfield",
+ "06064": "Hartford",
+ "06065": "Hartford",
+ "06066": "Tolland",
+ "06067": "Hartford",
+ "06068": "Litchfield",
+ "06069": "Litchfield",
+ "06070": "Hartford",
+ "06071": "Tolland",
+ "06072": "Tolland",
+ "06073": "Hartford",
+ "06074": "Hartford",
+ "06075": "Tolland",
+ "06076": "Tolland",
+ "06077": "Tolland",
+ "06078": "Hartford",
+ "06079": "Litchfield",
+ "06080": "Hartford",
+ "06081": "Hartford",
+ "06082": "Hartford",
+ "06083": "Hartford",
+ "06084": "Tolland",
+ "06085": "Hartford",
+ "06087": "Hartford",
+ "06088": "Hartford",
+ "06089": "Hartford",
+ "06090": "Hartford",
+ "06091": "Hartford",
+ "06092": "Hartford",
+ "06093": "Hartford",
+ "06094": "Litchfield",
+ "06095": "Hartford",
+ "06096": "Hartford",
+ "06098": "Litchfield",
+ "06101": "Hartford",
+ "06102": "Hartford",
+ "06103": "Hartford",
+ "06104": "Hartford",
+ "06105": "Hartford",
+ "06106": "Hartford",
+ "06107": "Hartford",
+ "06108": "Hartford",
+ "06109": "Hartford",
+ "06110": "Hartford",
+ "06111": "Hartford",
+ "06112": "Hartford",
+ "06114": "Hartford",
+ "06115": "Hartford",
+ "06117": "Hartford",
+ "06118": "Hartford",
+ "06119": "Hartford",
+ "06120": "Hartford",
+ "06123": "Hartford",
+ "06126": "Hartford",
+ "06127": "Hartford",
+ "06128": "Hartford",
+ "06129": "Hartford",
+ "06131": "Hartford",
+ "06132": "Hartford",
+ "06133": "Hartford",
+ "06134": "Hartford",
+ "06137": "Hartford",
+ "06138": "Hartford",
+ "06140": "Hartford",
+ "06141": "Hartford",
+ "06142": "Hartford",
+ "06143": "Hartford",
+ "06144": "Hartford",
+ "06145": "Hartford",
+ "06146": "Hartford",
+ "06147": "Hartford",
+ "06150": "Hartford",
+ "06151": "Hartford",
+ "06152": "Hartford",
+ "06153": "Hartford",
+ "06154": "Hartford",
+ "06155": "Hartford",
+ "06156": "Hartford",
+ "06160": "Hartford",
+ "06161": "Hartford",
+ "06167": "Hartford",
+ "06176": "Hartford",
+ "06180": "Hartford",
+ "06183": "Hartford",
+ "06199": "Hartford",
+ "06226": "Windham",
+ "06230": "Windham",
+ "06231": "Tolland",
+ "06232": "Tolland",
+ "06233": "Windham",
+ "06234": "Windham",
+ "06235": "Windham",
+ "06237": "Tolland",
+ "06238": "Tolland",
+ "06239": "Windham",
+ "06241": "Windham",
+ "06242": "Windham",
+ "06243": "Windham",
+ "06244": "Windham",
+ "06245": "Windham",
+ "06246": "Windham",
+ "06247": "Windham",
+ "06248": "Tolland",
+ "06249": "New London",
+ "06250": "Tolland",
+ "06251": "Tolland",
+ "06254": "New London",
+ "06255": "Windham",
+ "06256": "Windham",
+ "06258": "Windham",
+ "06259": "Windham",
+ "06260": "Windham",
+ "06262": "Windham",
+ "06263": "Windham",
+ "06264": "Windham",
+ "06265": "Tolland",
+ "06266": "Windham",
+ "06267": "Windham",
+ "06268": "Tolland",
+ "06269": "Tolland",
+ "06277": "Windham",
+ "06278": "Windham",
+ "06279": "Tolland",
+ "06280": "Windham",
+ "06281": "Windham",
+ "06282": "Windham",
+ "06320": "New London",
+ "06330": "New London",
+ "06331": "Windham",
+ "06332": "Windham",
+ "06333": "New London",
+ "06334": "New London",
+ "06335": "New London",
+ "06336": "New London",
+ "06338": "New London",
+ "06339": "New London",
+ "06340": "New London",
+ "06349": "New London",
+ "06350": "New London",
+ "06351": "New London",
+ "06353": "New London",
+ "06354": "Windham",
+ "06355": "New London",
+ "06357": "New London",
+ "06359": "New London",
+ "06360": "New London",
+ "06365": "New London",
+ "06370": "New London",
+ "06371": "New London",
+ "06372": "New London",
+ "06373": "Windham",
+ "06374": "Windham",
+ "06375": "New London",
+ "06376": "New London",
+ "06377": "Windham",
+ "06378": "New London",
+ "06379": "New London",
+ "06380": "New London",
+ "06382": "New London",
+ "06383": "New London",
+ "06384": "New London",
+ "06385": "New London",
+ "06387": "Windham",
+ "06388": "New London",
+ "06389": "New London",
+ "06401": "New Haven",
+ "06403": "New Haven",
+ "06404": "Fairfield",
+ "06405": "New Haven",
+ "06408": "New Haven",
+ "06409": "Middlesex",
+ "06410": "New Haven",
+ "06411": "New Haven",
+ "06412": "Middlesex",
+ "06413": "Middlesex",
+ "06414": "Middlesex",
+ "06415": "New London",
+ "06416": "Middlesex",
+ "06417": "Middlesex",
+ "06418": "New Haven",
+ "06419": "Middlesex",
+ "06420": "New London",
+ "06422": "Middlesex",
+ "06423": "Middlesex",
+ "06424": "Middlesex",
+ "06426": "Middlesex",
+ "06437": "New Haven",
+ "06438": "Middlesex",
+ "06439": "New London",
+ "06440": "Fairfield",
+ "06441": "Middlesex",
+ "06442": "Middlesex",
+ "06443": "New Haven",
+ "06444": "Hartford",
+ "06447": "Hartford",
+ "06450": "New Haven",
+ "06451": "New Haven",
+ "06455": "Middlesex",
+ "06456": "Middlesex",
+ "06457": "Middlesex",
+ "06459": "Middlesex",
+ "06460": "New Haven",
+ "06461": "New Haven",
+ "06467": "Hartford",
+ "06468": "Fairfield",
+ "06469": "Middlesex",
+ "06470": "Fairfield",
+ "06471": "New Haven",
+ "06472": "New Haven",
+ "06473": "New Haven",
+ "06474": "New London",
+ "06475": "Middlesex",
+ "06477": "New Haven",
+ "06478": "New Haven",
+ "06479": "Hartford",
+ "06480": "Middlesex",
+ "06481": "Middlesex",
+ "06482": "Fairfield",
+ "06483": "New Haven",
+ "06484": "Fairfield",
+ "06487": "New Haven",
+ "06488": "New Haven",
+ "06489": "Hartford",
+ "06491": "Fairfield",
+ "06492": "New Haven",
+ "06493": "New Haven",
+ "06494": "New Haven",
+ "06495": "New Haven",
+ "06498": "Middlesex",
+ "06501": "New Haven",
+ "06502": "New Haven",
+ "06503": "New Haven",
+ "06504": "New Haven",
+ "06505": "New Haven",
+ "06506": "New Haven",
+ "06507": "New Haven",
+ "06508": "New Haven",
+ "06509": "New Haven",
+ "06510": "New Haven",
+ "06511": "New Haven",
+ "06512": "New Haven",
+ "06513": "New Haven",
+ "06514": "New Haven",
+ "06515": "New Haven",
+ "06516": "New Haven",
+ "06517": "New Haven",
+ "06518": "New Haven",
+ "06519": "New Haven",
+ "06520": "New Haven",
+ "06521": "New Haven",
+ "06524": "New Haven",
+ "06525": "New Haven",
+ "06530": "New Haven",
+ "06531": "New Haven",
+ "06532": "New Haven",
+ "06533": "New Haven",
+ "06534": "New Haven",
+ "06535": "New Haven",
+ "06536": "New Haven",
+ "06537": "New Haven",
+ "06538": "New Haven",
+ "06540": "New Haven",
+ "06601": "Fairfield",
+ "06602": "Fairfield",
+ "06604": "Fairfield",
+ "06605": "Fairfield",
+ "06606": "Fairfield",
+ "06607": "Fairfield",
+ "06608": "Fairfield",
+ "06610": "Fairfield",
+ "06611": "Fairfield",
+ "06612": "Fairfield",
+ "06614": "Fairfield",
+ "06615": "Fairfield",
+ "06673": "Fairfield",
+ "06699": "Fairfield",
+ "06701": "New Haven",
+ "06702": "New Haven",
+ "06703": "New Haven",
+ "06704": "New Haven",
+ "06705": "New Haven",
+ "06706": "New Haven",
+ "06708": "New Haven",
+ "06710": "New Haven",
+ "06712": "New Haven",
+ "06716": "New Haven",
+ "06720": "New Haven",
+ "06721": "New Haven",
+ "06722": "New Haven",
+ "06723": "New Haven",
+ "06724": "New Haven",
+ "06725": "New Haven",
+ "06726": "New Haven",
+ "06749": "New Haven",
+ "06750": "Litchfield",
+ "06751": "Litchfield",
+ "06752": "Litchfield",
+ "06753": "Litchfield",
+ "06754": "Litchfield",
+ "06755": "Litchfield",
+ "06756": "Litchfield",
+ "06757": "Litchfield",
+ "06758": "Litchfield",
+ "06759": "Litchfield",
+ "06762": "New Haven",
+ "06763": "Litchfield",
+ "06770": "New Haven",
+ "06776": "Litchfield",
+ "06777": "Litchfield",
+ "06778": "Litchfield",
+ "06779": "Litchfield",
+ "06781": "Litchfield",
+ "06782": "Litchfield",
+ "06783": "Litchfield",
+ "06784": "Fairfield",
+ "06785": "Litchfield",
+ "06786": "Litchfield",
+ "06787": "Litchfield",
+ "06790": "Litchfield",
+ "06791": "Litchfield",
+ "06792": "Litchfield",
+ "06793": "Litchfield",
+ "06794": "Litchfield",
+ "06795": "Litchfield",
+ "06796": "Litchfield",
+ "06798": "Litchfield",
+ "06801": "Fairfield",
+ "06804": "Fairfield",
+ "06807": "Fairfield",
+ "06810": "Fairfield",
+ "06811": "Fairfield",
+ "06812": "Fairfield",
+ "06813": "Fairfield",
+ "06814": "Fairfield",
+ "06816": "Fairfield",
+ "06817": "Fairfield",
+ "06820": "Fairfield",
+ "06824": "Fairfield",
+ "06825": "Fairfield",
+ "06828": "Fairfield",
+ "06829": "Fairfield",
+ "06830": "Fairfield",
+ "06831": "Fairfield",
+ "06836": "Fairfield",
+ "06838": "Fairfield",
+ "06840": "Fairfield",
+ "06850": "Fairfield",
+ "06851": "Fairfield",
+ "06852": "Fairfield",
+ "06853": "Fairfield",
+ "06854": "Fairfield",
+ "06855": "Fairfield",
+ "06856": "Fairfield",
+ "06857": "Fairfield",
+ "06858": "Fairfield",
+ "06860": "Fairfield",
+ "06870": "Fairfield",
+ "06875": "Fairfield",
+ "06876": "Fairfield",
+ "06877": "Fairfield",
+ "06878": "Fairfield",
+ "06879": "Fairfield",
+ "06880": "Fairfield",
+ "06881": "Fairfield",
+ "06883": "Fairfield",
+ "06888": "Fairfield",
+ "06889": "Fairfield",
+ "06890": "Fairfield",
+ "06896": "Fairfield",
+ "06897": "Fairfield",
+ "06901": "Fairfield",
+ "06902": "Fairfield",
+ "06903": "Fairfield",
+ "06904": "Fairfield",
+ "06905": "Fairfield",
+ "06906": "Fairfield",
+ "06907": "Fairfield",
+ "06910": "Fairfield",
+ "06911": "Fairfield",
+ "06912": "Fairfield",
+ "06913": "Fairfield",
+ "06914": "Fairfield",
+ "06920": "Fairfield",
+ "06921": "Fairfield",
+ "06922": "Fairfield",
+ "06926": "Fairfield",
+ "06927": "Fairfield",
+}
diff --git a/internal/uls/ctcounty_test.go b/internal/uls/ctcounty_test.go
new file mode 100644
index 0000000..548b8a7
--- /dev/null
+++ b/internal/uls/ctcounty_test.go
@@ -0,0 +1,70 @@
+package uls
+
+import (
+ "testing"
+
+ "hamlog/internal/award"
+)
+
+// The eight counties Connecticut still has for every purpose except the Census.
+var ctLegalCounties = map[string]bool{
+ "Fairfield": true, "Hartford": true, "Litchfield": true, "Middlesex": true,
+ "New Haven": true, "New London": true, "Tolland": true, "Windham": true,
+}
+
+// TestCTCountyOnlyLegalCounties makes sure no planning region ever creeps back
+// into the table — a regenerated file that quietly picked up "Capitol Region"
+// would put every Hartford-area station back out of reach of the award, which
+// is exactly the failure this table exists to end.
+func TestCTCountyOnlyLegalCounties(t *testing.T) {
+ if len(ctCounty) < 400 {
+ t.Fatalf("only %d Connecticut ZIPs — the table looks truncated", len(ctCounty))
+ }
+ for zip, county := range ctCounty {
+ if !ctLegalCounties[county] {
+ t.Errorf("ZIP %s → %q, which is not one of Connecticut's eight counties", zip, county)
+ }
+ if len(zip) != 5 {
+ t.Errorf("ZIP %q is not five digits", zip)
+ }
+ }
+}
+
+// TestCTCountyMatchesAward closes the loop: every county in the table has to
+// produce a key the USA-CA reference actually lists. A county the resolver can
+// name but the award cannot match is worth no more than the planning region it
+// replaced.
+func TestCTCountyMatchesAward(t *testing.T) {
+ want := map[string]bool{
+ "CT/FAIRFIELD": true, "CT/HARTFORD": true, "CT/LITCHFIELD": true,
+ "CT/MIDDLESEX": true, "CT/NEWHAVEN": true, "CT/NEWLONDON": true,
+ "CT/TOLLAND": true, "CT/WINDHAM": true,
+ }
+ for zip, county := range ctCounty {
+ if k := award.USCountyKey("CT", county); !want[k] {
+ t.Errorf("ZIP %s → %q → key %q, not a USA-CA county", zip, county, k)
+ }
+ }
+}
+
+// TestCTCountyKnownZips pins a ZIP in each county against ground truth, so a
+// regeneration that silently shifted the whole table is caught. 06419 is the
+// case that started this: Killingworth is in Middlesex, and GeoNames calls it
+// "Lower Connecticut River Valley".
+func TestCTCountyKnownZips(t *testing.T) {
+ for zip, want := range map[string]string{
+ "06419": "Middlesex", // Killingworth
+ "06001": "Hartford", // Avon
+ "06106": "Hartford", // Hartford
+ "06510": "New Haven", // New Haven
+ "06880": "Fairfield", // Westport
+ "06340": "New London", // Groton
+ "06226": "Windham", // Willimantic
+ "06238": "Tolland", // Coventry
+ "06759": "Litchfield", // Litchfield
+ } {
+ if got := ctCounty[zip]; got != want {
+ t.Errorf("ctCounty[%s] = %q, want %q", zip, got, want)
+ }
+ }
+}
diff --git a/internal/uls/uls.go b/internal/uls/uls.go
index 36dc48d..5086adb 100644
--- a/internal/uls/uls.go
+++ b/internal/uls/uls.go
@@ -34,6 +34,7 @@ import (
"path/filepath"
"regexp"
"sort"
+ "strconv"
"strings"
"sync"
"time"
@@ -114,6 +115,33 @@ func (s *Store) Count() int {
return n
}
+// RulesVersion is bumped whenever a correction applied DURING import changes
+// the county a callsign resolves to. The value is stamped into uls_meta, so a
+// database built by an older OpsLog can be told apart from one built by this
+// one — the rows themselves carry no clue, and the fix cannot be applied
+// retroactively because the ZIP each county was derived from is not stored.
+//
+// 1 — original.
+// 2 — Connecticut ZIPs mapped back to their legal county instead of the
+// 2022 planning region GeoNames now reports (see ctcounty_gen.go).
+const RulesVersion = 2
+
+// NeedsRefresh reports that the store was built before a correction that
+// changes its answers, so the operator should download it again. False for an
+// empty store: there is nothing stale about data that was never fetched.
+func (s *Store) NeedsRefresh() bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ var n int
+ if s.db.QueryRow(`SELECT COUNT(*) FROM uls_callsign`).Scan(&n); n == 0 {
+ return false
+ }
+ var v string
+ s.db.QueryRow(`SELECT value FROM uls_meta WHERE key='rules_version'`).Scan(&v)
+ got, _ := strconv.Atoi(v)
+ return got < RulesVersion
+}
+
// UpdatedAt returns when the store was last imported (zero if never).
func (s *Store) UpdatedAt() time.Time {
s.mu.RLock()
@@ -285,6 +313,10 @@ func (s *Store) rebuild(ctx context.Context, amatZip string, zipmap map[string]z
time.Now().UTC().Format(time.RFC3339)); err != nil {
return err
}
+ if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO uls_meta(key,value) VALUES('rules_version',?)`,
+ strconv.Itoa(RulesVersion)); err != nil {
+ return err
+ }
if err := tx.Commit(); err != nil {
return err
}
@@ -333,9 +365,21 @@ func parseGeoNames(zipPath string) (map[string]zipRow, error) {
}
lat := parseFloat(f[9])
lon := parseFloat(f[10])
+ state := strings.ToUpper(strings.TrimSpace(f[4]))
+ county := strings.TrimSpace(f[5])
+ // Connecticut: GeoNames reports the 2022 planning regions, which are
+ // county-equivalents for the Census and nothing at all for amateur
+ // radio — no award, no callbook and no log uses them, so every CT
+ // station resolved to a name that matched nothing. Substitute the legal
+ // county for the ZIP. See cmd/ctzipgen.
+ if state == "CT" {
+ if c, ok := ctCounty[zip5]; ok {
+ county = c
+ }
+ }
out[zip5] = zipRow{
- state: strings.ToUpper(strings.TrimSpace(f[4])),
- county: strings.TrimSpace(f[5]),
+ state: state,
+ county: county,
lat: lat,
lon: lon,
}