1"""Offline city geocoding for turning a show's ``coverage`` string into miles.
2
3Archive.org recordings carry a freeform ``coverage`` like ``"New Haven, CT"`` or
4``"London, England"``. We resolve that to a lat/lon with the bundled
5``geonamescache`` dataset (no network, no per-request API), then approximate the
6driving distance between consecutive shows as the great-circle distance times a
7road-winding factor. Accuracy is "good enough to play" -- legs land within ~10-15%
8of the real mileage on the tours we checked -- not survey-grade.
9"""
10
11import math
12import re
13import threading
14
15import geonamescache
16
17
18class Place:
19 """A resolved city: name, coordinates, and (for US) its USPS state code."""
20
21 __slots__ = ("name", "lat", "lon", "admin1", "country", "population")
22
23 def __init__(
24 self, name: str, lat: float, lon: float, admin1: str, country: str, population: int
25 ) -> None:
26 self.name = name
27 self.lat = lat
28 self.lon = lon
29 self.admin1 = admin1
30 self.country = country
31 self.population = population
32
33
34# Straight-line underestimates real road distance; this factor brings the
35# great-circle miles up to roughly what the odometer would read on a tour.
36_ROAD_FACTOR = 1.2
37
38# geonamescache stores some saint-cities as "Saint X" and others as "St. X"
39# (e.g. "Saint Paul" but "St. Louis"), so we try every spelling of a name.
40_PREFIX_GROUPS = {
41 "st": ("saint ", "st. ", "st "),
42 "st.": ("saint ", "st. ", "st "),
43 "saint": ("saint ", "st. ", "st "),
44 "ft": ("fort ", "ft. ", "ft "),
45 "ft.": ("fort ", "ft. ", "ft "),
46 "fort": ("fort ", "ft. ", "ft "),
47 "mt": ("mount ", "mt. ", "mt "),
48 "mt.": ("mount ", "mt. ", "mt "),
49 "mount": ("mount ", "mt. ", "mt "),
50}
51_ALIASES = {
52 "new york": "new york city",
53 "nyc": "new york city",
54 "n.y.c.": "new york city",
55 "l.a.": "los angeles",
56 "s.f.": "san francisco",
57}
58
59_lock = threading.Lock()
60_index: dict[str, list[Place]] | None = None
61_us_state_codes: frozenset[str] = frozenset()
62_us_state_names: dict[str, str] = {} # lowercased state name -> USPS code
63_country_names: dict[str, str] = {} # lowercased country name -> ISO2 code
64
65# Sub-national names that map to a whole country for our purposes.
66_COUNTRY_ALIASES = {
67 "england": "GB",
68 "scotland": "GB",
69 "wales": "GB",
70 "uk": "GB",
71 "u.k.": "GB",
72 "great britain": "GB",
73 "holland": "NL",
74}
75
76
77def _build_index() -> None:
78 global _index, _us_state_codes, _us_state_names, _country_names
79 gc = geonamescache.GeonamesCache()
80 index: dict[str, list[Place]] = {}
81 for c in gc.get_cities().values():
82 place = Place(
83 name=c["name"],
84 lat=float(c["latitude"]),
85 lon=float(c["longitude"]),
86 admin1=c.get("admin1code") or "",
87 country=c.get("countrycode") or "",
88 population=int(c.get("population") or 0),
89 )
90 index.setdefault(c["name"].lower(), []).append(place)
91 _index = index
92 us_states = gc.get_us_states()
93 _us_state_codes = frozenset(us_states.keys())
94 _us_state_names = {v["name"].lower(): k for k, v in us_states.items()}
95 _country_names = {v["name"].lower(): k for k, v in gc.get_countries().items()}
96 _country_names.update(_COUNTRY_ALIASES)
97
98
99def _region_to_us_state(region: str) -> str | None:
100 """Map a region token to a USPS code: 'CT', 'California', 'Calif', 'Califo'..."""
101 r = region.strip().rstrip(".").lower()
102 if len(r) == 2 and r.upper() in _us_state_codes:
103 return r.upper()
104 if r in _us_state_names:
105 return _us_state_names[r]
106 # Truncated or abbreviated full names ("calif", "califo", "penn").
107 if len(r) >= 4:
108 for name, code in _us_state_names.items():
109 if name.startswith(r):
110 return code
111 return None
112
113
114def _region_to_country(region: str) -> str | None:
115 r = region.strip().rstrip(".").lower()
116 return _country_names.get(r)
117
118
119def _ensure_index() -> dict[str, list[Place]]:
120 global _index
121 if _index is None:
122 with _lock:
123 if _index is None:
124 _build_index()
125 assert _index is not None
126 return _index
127
128
129def _name_variants(city: str) -> list[str]:
130 """Every spelling of a city name worth trying against the dataset."""
131 base = re.sub(r"\s+", " ", city.lower().strip())
132 variants = [base]
133 if base in _ALIASES:
134 variants.append(_ALIASES[base])
135 head, _, rest = base.partition(" ")
136 if rest and head in _PREFIX_GROUPS:
137 for pref in _PREFIX_GROUPS[head]:
138 variants.append(pref + rest)
139 # de-dupe, preserve order
140 seen: set[str] = set()
141 out: list[str] = []
142 for v in variants:
143 if v not in seen:
144 seen.add(v)
145 out.append(v)
146 return out
147
148
149# Rough geographic centre of each US state (lat, lon). A town too small for the
150# city index still gets a believable spot and a drive distance this way.
151_STATE_CENTERS = {
152 "AL": (32.8, -86.8), "AK": (64.0, -150.0), "AZ": (34.3, -111.7), "AR": (34.9, -92.4), "CA": (37.2, -119.5),
153 "CO": (39.0, -105.5), "CT": (41.6, -72.7), "DE": (39.0, -75.5), "DC": (38.9, -77.0), "FL": (28.6, -82.4),
154 "GA": (32.7, -83.4), "HI": (20.8, -156.3), "ID": (44.4, -114.6), "IL": (40.0, -89.2), "IN": (39.9, -86.3),
155 "IA": (42.1, -93.5), "KS": (38.5, -98.4), "KY": (37.5, -85.3), "LA": (31.1, -92.0), "ME": (45.4, -69.2),
156 "MD": (39.0, -76.8), "MA": (42.3, -71.8), "MI": (44.3, -85.4), "MN": (46.3, -94.3), "MS": (32.7, -89.7),
157 "MO": (38.4, -92.5), "MT": (47.0, -109.6), "NE": (41.5, -99.8), "NV": (39.3, -116.6), "NH": (43.7, -71.6),
158 "NJ": (40.2, -74.7), "NM": (34.4, -106.1), "NY": (42.9, -75.5), "NC": (35.6, -79.4), "ND": (47.5, -100.5),
159 "OH": (40.3, -82.8), "OK": (35.6, -97.5), "OR": (43.9, -120.6), "PA": (40.9, -77.8), "RI": (41.7, -71.5),
160 "SC": (33.9, -80.9), "SD": (44.4, -100.2), "TN": (35.9, -86.4), "TX": (31.5, -99.3), "UT": (39.3, -111.7),
161 "VT": (44.1, -72.7), "VA": (37.5, -78.9), "WA": (47.4, -120.5), "WV": (38.6, -80.6), "WI": (44.6, -89.9),
162 "WY": (43.0, -107.6),
163}
164
165
166def resolve(coverage: str | None) -> Place | None:
167 """Resolve a coverage string to a Place, falling back to the state's centre for
168 a US town too small for the city index."""
169 place = _resolve_city(coverage)
170 if place is not None or not coverage:
171 return place
172 parts = [p.strip() for p in coverage.split(",")]
173 st = _region_to_us_state(parts[1]) if len(parts) > 1 and parts[1].strip() else None
174 if st and st in _STATE_CENTERS:
175 lat, lon = _STATE_CENTERS[st]
176 return Place(parts[0], lat, lon, st, "US", 0)
177 return None
178
179
180def _resolve_city(coverage: str | None) -> Place | None:
181 """Resolve a ``coverage`` string ("City, ST" / "City, Country") to a Place."""
182 if not coverage:
183 return None
184 index = _ensure_index()
185 parts = [p.strip() for p in coverage.split(",")]
186 city = parts[0]
187 region = parts[1] if len(parts) > 1 and parts[1].strip() else None
188 us_state = _region_to_us_state(region) if region else None
189 country = _region_to_country(region) if region and not us_state else None
190 for variant in _name_variants(city):
191 cands = index.get(variant)
192 if not cands:
193 continue
194 if us_state:
195 in_state = [c for c in cands if c.country == "US" and c.admin1 == us_state]
196 if in_state:
197 return max(in_state, key=lambda c: c.population)
198 # State named but city not found there: still bias to US.
199 us_cands = [c for c in cands if c.country == "US"]
200 if us_cands:
201 return max(us_cands, key=lambda c: c.population)
202 # A US state was named but no US town has this name in the index (it
203 # only holds towns above ~15k people). Better no place than Alta, WY
204 # landing in Alta, Norway: try the next spelling, else give up.
205 continue
206 if country:
207 in_country = [c for c in cands if c.country == country]
208 if in_country:
209 return max(in_country, key=lambda c: c.population)
210 continue
211 return max(cands, key=lambda c: c.population)
212 return None
213
214
215def road_miles(a: Place | None, b: Place | None) -> int | None:
216 """Approximate driving miles between two places (None if either is unknown)."""
217 if a is None or b is None:
218 return None
219 lat1, lon1, lat2, lon2 = map(math.radians, (a.lat, a.lon, b.lat, b.lon))
220 h = (
221 math.sin((lat2 - lat1) / 2) ** 2
222 + math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2) ** 2
223 )
224 great_circle = 2 * 3958.8 * math.asin(math.sqrt(h))
225 return round(great_circle * _ROAD_FACTOR)
226