nategodd/geo.py
type-two c5f848733a Custom city supplement: add Ipswich QLD, label cities with region
custom_cities.json is merged into the search index for towns missing
from the geonames 15k+ dataset; labels now include the region code
when available to disambiguate same-named cities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 16:46:09 +10:00

95 lines
3.3 KiB
Python

"""City search (offline, geonamescache + custom_cities.json) + historical timezone resolution."""
import json
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import geonamescache
from timezonefinder import TimezoneFinder
_gc = geonamescache.GeonamesCache()
_tf = TimezoneFinder()
_countries = {c["iso"]: c["name"] for c in _gc.get_countries().values()}
_CITY_INDEX = None
def _custom_cities():
"""Supplemental cities missing from the geonames 15k+ dataset (custom_cities.json)."""
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "custom_cities.json")
if not os.path.exists(path):
return []
with open(path) as f:
rows = []
for c in json.load(f):
rows.append({
"name": c["name"],
"search": c["name"].lower(),
"alt": [a.lower() for a in c.get("alt", [])],
"country": c["country"],
"cc": c["cc"],
"admin": c.get("admin", ""),
"lat": float(c["lat"]),
"lon": float(c["lon"]),
"pop": int(c.get("pop", 0)),
"tz": c["tz"],
})
return rows
def _index():
global _CITY_INDEX
if _CITY_INDEX is None:
rows = _custom_cities()
for c in _gc.get_cities().values():
rows.append({
"name": c["name"],
"search": c["name"].lower(),
"alt": [a.lower() for a in c.get("alternatenames", [])[:20]],
"country": _countries.get(c["countrycode"], c["countrycode"]),
"cc": c["countrycode"],
"admin": c.get("admin1code", ""),
"lat": c["latitude"],
"lon": c["longitude"],
"pop": c["population"],
"tz": c["timezone"],
})
rows.sort(key=lambda r: -r["pop"])
_CITY_INDEX = rows
return _CITY_INDEX
def search_cities(q, limit=12):
q = q.strip().lower()
if len(q) < 2:
return []
starts, contains = [], []
for r in _index():
if r["search"].startswith(q):
starts.append(r)
elif q in r["search"] or any(a.startswith(q) for a in r["alt"]):
contains.append(r)
if len(starts) >= limit:
break
rows = (starts + contains)[:limit]
return [{"name": r["name"], "country": r["country"], "cc": r["cc"],
"admin": r["admin"], "lat": r["lat"], "lon": r["lon"], "tz": r["tz"],
"label": (f'{r["name"]}, {r["admin"]}, {r["country"]}'
if r["admin"] and not r["admin"].isdigit()
else f'{r["name"]}, {r["country"]}')} for r in rows]
def resolve_offset(lat, lon, year, month, day, hour, minute, tz_hint=None):
"""Return (utc_offset_hours, tz_name) for the LOCAL wall-clock birth time.
Uses the IANA tz database, which carries historical offsets and DST rules —
the same thing the 90s programs shipped as their 'atlas'.
"""
tzname = tz_hint or _tf.timezone_at(lat=lat, lng=lon)
if not tzname:
tzname = _tf.closest_timezone_at(lat=lat, lng=lon) if hasattr(_tf, "closest_timezone_at") else "UTC"
tz = ZoneInfo(tzname)
local = datetime(year, month, day, hour, minute, tzinfo=tz)
off = local.utcoffset().total_seconds() / 3600.0
return off, tzname