From 30143b01bf75ca98867ec13e6bcd75c9365f1655 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Fri, 14 Aug 2026 00:42:27 +0200 Subject: [PATCH] fix(counties): make the cluster and the entry panel agree on a US county MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.go | 38 +- changelog.json | 10 +- cmd/cntygen/main.go | 10 + cmd/ctzipgen/main.go | 203 ++++++++++ frontend/src/components/SettingsModal.tsx | 11 +- frontend/src/lib/i18n.tsx | 2 + frontend/wailsjs/go/models.ts | 2 + internal/award/award.go | 17 +- internal/award/uscounty_names.go | 120 ++++++ internal/awardref/uscounties_gen.go | 2 +- internal/awardref/uscounties_test.go | 118 ++++++ internal/qso/qso.go | 46 +++ internal/uls/ctcounty_gen.go | 440 ++++++++++++++++++++++ internal/uls/ctcounty_test.go | 70 ++++ internal/uls/uls.go | 48 ++- 15 files changed, 1122 insertions(+), 15 deletions(-) create mode 100644 cmd/ctzipgen/main.go create mode 100644 internal/award/uscounty_names.go create mode 100644 internal/awardref/uscounties_test.go create mode 100644 internal/uls/ctcounty_gen.go create mode 100644 internal/uls/ctcounty_test.go 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 && ( +
+ {t('uscty.stale')} +
+ )} + {ulsProgress && (
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, }