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.
204 lines
6.4 KiB
Go
204 lines
6.4 KiB
Go
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)
|
|
}
|
|
}
|