nategodd/geo.py
type-two 1be39727f8 NATEGODD: Swiss Ephemeris natal chart generator
Full-detail natal charts: 21 bodies/points, 7 house systems, 11 aspect
types with applying/separating, dignities, declination parallels,
sect/balances/moon phase, offline city search with historical IANA
time atlas, SVG wheel, and a written interpretation report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:52:08 +10:00

68 lines
2.3 KiB
Python

"""City search (offline, geonamescache) + historical timezone resolution."""
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 _index():
global _CITY_INDEX
if _CITY_INDEX is None:
rows = []
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["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