fix(counties): make the cluster and the entry panel agree on a US county
An operator asked why K1SEI showed "Middlesex" in Info (F2) and "Lower
Connecticut River Valley" in the cluster. Two sources: the entry panel has
a callbook answer, a spot carries only a callsign so the cluster derives one
from the FCC licence ZIP through GeoNames — and GeoNames has followed the
Census in replacing Connecticut's counties with the 2022 planning regions.
No award, callbook or log uses those, so every CT station matched nothing:
new county for ever, and counting toward nothing.
Measured against a full ULS import (1 556 444 US callsigns), 23 223 resolved
to a name the USA-CA reference does not contain. Three causes, three fixes:
- Spelling. "City and County of San Francisco", "Baltimore (city)",
"Nome (CA)", plus counties renamed since the award list was drawn
(Kusilvak, Oglala Lakota, the Valdez-Cordova split) and Alaska's four
"X City and Borough", whose reference codes read "JUNEAUCITYAND" because
the county-type suffix strip eats the wrong end. Normalised in
award.USCountyKey, which both sides already go through. 7 515 callsigns,
no re-download needed.
- Doña Ana, NM shipped into the reference as "NM/DO̱AANA" — mangled by a
non-UTF-8 CSV line, a code nothing could ever produce, so that county was
unwinnable and silent about it. Row repaired, cntygen now refuses such a
line, and a test makes every one of the 3 102 references reproduce its own
code from its own name.
- Connecticut. A planning region is drawn from towns in several counties, so
no name maps to a name — only the ZIP can resolve it. cmd/ctzipgen builds
the table from the Census 2020 crosswalk, filling PO-box-only ZIPs from the
nearest resolved centroid; all 11 ZIPs GeoNames still labels with a real
county agree with the result. 15 037 callsigns, applied at import, so the
store now carries a rules version and Settings says when a re-download is
needed.
Alignment itself is the last piece: a spot now shows the county the station is
logged with when we have one, and falls back to the ZIP-derived county only for
stations never worked.
This commit is contained in:
@@ -10495,6 +10495,11 @@ func (a *App) applyULSCounty(q *qso.QSO) {
|
|||||||
type ULSStatusResult struct {
|
type ULSStatusResult struct {
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
UpdatedAt string `json:"updated_at"` // RFC3339, empty if never downloaded
|
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.
|
// 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() {
|
if t := a.uls.UpdatedAt(); !t.IsZero() {
|
||||||
updated = t.Format(time.RFC3339)
|
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
|
// DownloadULSCounties downloads and (re)builds the offline US county database in
|
||||||
@@ -17124,7 +17129,11 @@ type clusterStatusCache struct {
|
|||||||
workedCalls map[string]struct{}
|
workedCalls map[string]struct{}
|
||||||
workedCallSlots map[string]struct{} // nil unless the "same slot" option is on
|
workedCallSlots map[string]struct{} // nil unless the "same slot" option is on
|
||||||
workedCounties map[string]struct{}
|
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{}
|
workedPfx map[string]struct{}
|
||||||
workedGrids map[string]struct{} // "GRID|MODE", mode normalised like the rest
|
workedGrids map[string]struct{} // "GRID|MODE", mode normalised like the rest
|
||||||
normMode func(string) string // nil unless digital-mode grouping is on
|
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
|
// Orthogonal dimensions: worked US counties (for the ULS callsign→county
|
||||||
// lookup) and worked POTA parks.
|
// lookup) and worked POTA parks.
|
||||||
c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
|
c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
|
||||||
|
c.callCounties, _ = a.qso.CallCounties(a.ctx)
|
||||||
c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx)
|
c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx)
|
||||||
// One more DISTINCT scan when the snapshot is rebuilt, then pure map lookups
|
// 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
|
// 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
|
// NEW COUNTY. Two sources, better one first:
|
||||||
// store (US only; inert until downloaded) and flag if never worked.
|
//
|
||||||
if a.uls != nil {
|
// 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 {
|
if loc, ok := a.uls.Resolve(q.Call); ok {
|
||||||
out[i].County, out[i].State = loc.County, loc.State
|
out[i].County, out[i].State = loc.County, loc.State
|
||||||
if key := award.USCountyKey(loc.State, loc.County); key != "" {
|
if key := award.USCountyKey(loc.State, loc.County); key != "" {
|
||||||
|
|||||||
+8
-2
@@ -7,14 +7,20 @@
|
|||||||
"Shared CAT: with the wire trace on, every command a client sends and the answer given are logged.",
|
"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.",
|
"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.",
|
"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": [
|
"fr": [
|
||||||
"Amplificateurs : coche ceux qui partagent un combiner et ON, OFF et OPERATE agissent sur tous à la fois. Chacun garde ses mesures.",
|
"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é : 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.",
|
"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.",
|
"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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"hamlog/internal/award"
|
"hamlog/internal/award"
|
||||||
)
|
)
|
||||||
@@ -58,6 +59,15 @@ func main() {
|
|||||||
sc.Scan() // header
|
sc.Scan() // header
|
||||||
for sc.Scan() {
|
for sc.Scan() {
|
||||||
line := sc.Text()
|
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)
|
parts := strings.SplitN(line, ",", 3)
|
||||||
if len(parts) < 3 {
|
if len(parts) < 3 {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1489,7 +1489,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
finally { setScpBusy(false); }
|
finally { setScpBusy(false); }
|
||||||
};
|
};
|
||||||
// US Counties (offline FCC ULS) — download progress arrives via events.
|
// 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 [ulsBusy, setUlsBusy] = useState(false);
|
||||||
const [ulsProgress, setUlsProgress] = useState<{ stage: string; pct: number } | null>(null);
|
const [ulsProgress, setUlsProgress] = useState<{ stage: string; pct: number } | null>(null);
|
||||||
const [ulsMsg, setUlsMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
const [ulsMsg, setUlsMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
@@ -6233,6 +6233,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<div className="text-[11px] rounded border border-warning-border bg-warning-muted text-warning-muted-foreground px-2 py-1.5 leading-relaxed">
|
||||||
|
{t('uscty.stale')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{ulsProgress && (
|
{ulsProgress && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-[11px] text-muted-foreground flex justify-between">
|
<div className="text-[11px] text-muted-foreground flex justify-between">
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ const en: Dict = {
|
|||||||
'uscty.dbStatus': 'County database',
|
'uscty.dbStatus': 'County database',
|
||||||
'uscty.loaded': '{n} callsigns · updated {date}',
|
'uscty.loaded': '{n} callsigns · updated {date}',
|
||||||
'uscty.notLoaded': 'Not downloaded yet.',
|
'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.download': 'Download',
|
||||||
'uscty.update': 'Update',
|
'uscty.update': 'Update',
|
||||||
'uscty.done': 'County database ready — {n} callsigns.',
|
'uscty.done': 'County database ready — {n} callsigns.',
|
||||||
@@ -575,6 +576,7 @@ const fr: Dict = {
|
|||||||
'uscty.dbStatus': 'Base des comtés',
|
'uscty.dbStatus': 'Base des comtés',
|
||||||
'uscty.loaded': '{n} indicatifs · maj {date}',
|
'uscty.loaded': '{n} indicatifs · maj {date}',
|
||||||
'uscty.notLoaded': 'Pas encore téléchargée.',
|
'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.download': 'Télécharger',
|
||||||
'uscty.update': 'Mettre à jour',
|
'uscty.update': 'Mettre à jour',
|
||||||
'uscty.done': 'Base des comtés prête — {n} indicatifs.',
|
'uscty.done': 'Base des comtés prête — {n} indicatifs.',
|
||||||
|
|||||||
@@ -3362,6 +3362,7 @@ export namespace main {
|
|||||||
export class ULSStatusResult {
|
export class ULSStatusResult {
|
||||||
count: number;
|
count: number;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
needs_refresh: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ULSStatusResult(source);
|
return new ULSStatusResult(source);
|
||||||
@@ -3371,6 +3372,7 @@ export namespace main {
|
|||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.count = source["count"];
|
this.count = source["count"];
|
||||||
this.updated_at = source["updated_at"];
|
this.updated_at = source["updated_at"];
|
||||||
|
this.needs_refresh = source["needs_refresh"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class UltrabeamSettings {
|
export class UltrabeamSettings {
|
||||||
|
|||||||
+13
-4
@@ -749,9 +749,14 @@ func EmissionOf(mode string) string { return emissionOf(mode) }
|
|||||||
// abbreviating "Saint"→"St." and hyphenating "Matanuska-Susitna". The rules:
|
// abbreviating "Saint"→"St." and hyphenating "Matanuska-Susitna". The rules:
|
||||||
// - if cnty already carries "ST,County", split on the first comma; else take
|
// - if cnty already carries "ST,County", split on the first comma; else take
|
||||||
// the state from the STATE column;
|
// the state from the STATE column;
|
||||||
// - upper-case; drop periods and apostrophes; hyphens→space; strip a trailing
|
// - upper-case; fold accents (the FIPS list writes "Doña Ana", callbooks and
|
||||||
// County/Parish/Borough/Census Area/Municipality; fold Saint(e)→St(e);
|
// logs write "Dona Ana"); drop periods and apostrophes; hyphens→space;
|
||||||
// collapse whitespace;
|
// 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 ("").
|
// - require a 2-letter state and a non-empty county, else no match ("").
|
||||||
func USCountyKey(state, cnty string) string {
|
func USCountyKey(state, cnty string) string {
|
||||||
s := strings.TrimSpace(cnty)
|
s := strings.TrimSpace(cnty)
|
||||||
@@ -764,10 +769,11 @@ func USCountyKey(state, cnty string) string {
|
|||||||
} else {
|
} else {
|
||||||
st, co = strings.TrimSpace(state), s
|
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 = strings.ReplaceAll(co, "'", "")
|
||||||
co = strings.ReplaceAll(co, "-", " ")
|
co = strings.ReplaceAll(co, "-", " ")
|
||||||
|
co = expandCountyEquivalent(co)
|
||||||
for _, suf := range []string{" COUNTY", " PARISH", " BOROUGH", " CENSUS AREA", " MUNICIPALITY"} {
|
for _, suf := range []string{" COUNTY", " PARISH", " BOROUGH", " CENSUS AREA", " MUNICIPALITY"} {
|
||||||
if strings.HasSuffix(co, suf) {
|
if strings.HasSuffix(co, suf) {
|
||||||
co = strings.TrimSuffix(co, suf)
|
co = strings.TrimSuffix(co, suf)
|
||||||
@@ -788,6 +794,9 @@ func USCountyKey(state, cnty string) string {
|
|||||||
if len(st) != 2 || co == "" {
|
if len(st) != 2 || co == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
if alias, ok := renamed[st+"/"+co]; ok {
|
||||||
|
co = alias
|
||||||
|
}
|
||||||
// Separator is "/", NOT ",": the QSOFIELDS matcher splits a field value on
|
// 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"
|
// 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
|
// into two non-matching tokens. The stored ADIF cnty keeps its comma; only
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -1943,7 +1943,7 @@ func usCounties() []Ref {
|
|||||||
ref("NM/COLFAX", "Colfax County, NM", 291),
|
ref("NM/COLFAX", "Colfax County, NM", 291),
|
||||||
ref("NM/CURRY", "Curry County, NM", 291),
|
ref("NM/CURRY", "Curry County, NM", 291),
|
||||||
ref("NM/DEBACA", "De Baca 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/EDDY", "Eddy County, NM", 291),
|
||||||
ref("NM/GRANT", "Grant County, NM", 291),
|
ref("NM/GRANT", "Grant County, NM", 291),
|
||||||
ref("NM/GUADALUPE", "Guadalupe County, NM", 291),
|
ref("NM/GUADALUPE", "Guadalupe County, NM", 291),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2265,6 +2265,52 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
|
|||||||
return out, rows.Err()
|
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.
|
// 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
|
// It exists so the entry panel can flag a new county without loading the whole
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-2
@@ -34,6 +34,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -114,6 +115,33 @@ func (s *Store) Count() int {
|
|||||||
return n
|
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).
|
// UpdatedAt returns when the store was last imported (zero if never).
|
||||||
func (s *Store) UpdatedAt() time.Time {
|
func (s *Store) UpdatedAt() time.Time {
|
||||||
s.mu.RLock()
|
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 {
|
time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
return err
|
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 {
|
if err := tx.Commit(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -333,9 +365,21 @@ func parseGeoNames(zipPath string) (map[string]zipRow, error) {
|
|||||||
}
|
}
|
||||||
lat := parseFloat(f[9])
|
lat := parseFloat(f[9])
|
||||||
lon := parseFloat(f[10])
|
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{
|
out[zip5] = zipRow{
|
||||||
state: strings.ToUpper(strings.TrimSpace(f[4])),
|
state: state,
|
||||||
county: strings.TrimSpace(f[5]),
|
county: county,
|
||||||
lat: lat,
|
lat: lat,
|
||||||
lon: lon,
|
lon: lon,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user