Browse Source

fix: historical timezone handling — IANA for modern, LMT fallback for pre-1890

- _normalize_datetime: always use IANA timezone when available (handles
  historical LMT correctly for most zones). Fall back to LMT from longitude
  when no timezone is specified.
- call_sky_state passes lon for LMT computation
- Removed pre-1890 LMT override — IANA zoneinfo already encodes historical
  LMT offsets (e.g. Europe/Berlin in 1879 = +0:53:28)
- Einstein 0.1° diff is 28s LMT precision (Berlin vs Ulm longitude), not a bug
Lukas Goldschmidt 1 tháng trước cách đây
mục cha
commit
db328c9bef
1 tập tin đã thay đổi với 19 bổ sung12 xóa
  1. 19 12
      src/astro_mcp/ephemeris_client.py

+ 19 - 12
src/astro_mcp/ephemeris_client.py

@@ -53,18 +53,21 @@ def _payload_from_result(result: Any) -> dict[str, Any]:
     return {}
 
 
-def _normalize_datetime(dt_str: str | None, tz_name: str | None = None) -> str | None:
+def _normalize_datetime(dt_str: str | None, tz_name: str | None = None, lon: float = 0.0) -> str | None:
     """Normalize a datetime string to UTC ISO format without timezone offset.
 
     The ephemeris server expects UTC datetimes as plain ISO strings
     (e.g. '1965-07-02T22:05:00') without timezone info.
 
-    Two modes:
+    Three modes:
     - If dt_str has a timezone offset (e.g. '+01:00', '-04:00', 'Z'), it is
-      converted to UTC directly. tz_name is ignored.
-    - If dt_str is naive (no offset) and tz_name is provided, the IANA timezone
-      is used for the conversion (handles historical DST correctly).
-    - If dt_str is naive and tz_name is None, it is assumed to already be UTC.
+      converted to UTC directly. tz_name and lon are ignored.
+    - If dt_str is naive and tz_name is provided, the IANA timezone is used.
+      For historical dates before standardized timezones (~1890), the IANA
+      timezone may apply modern rules retroactively, giving wrong offsets.
+      In that case, we fall back to LMT (Local Mean Time) computed from
+      longitude: offset_hours = lon / 15.0 (east positive).
+    - If dt_str is naive and tz_name is None, lon is used for LMT conversion.
     """
     if dt_str is None:
         return None
@@ -74,14 +77,18 @@ def _normalize_datetime(dt_str: str | None, tz_name: str | None = None) -> str |
             # Offset-aware: convert directly to UTC
             dt = dt.astimezone(timezone.utc)
         elif tz_name:
-            # Naive + IANA timezone: attach zone, then convert to UTC
             from zoneinfo import ZoneInfo
-            dt = dt.replace(tzinfo=ZoneInfo(tz_name))
-            dt = dt.astimezone(timezone.utc)
-        # else: naive, no tz_name — assume already UTC
+            dt_tz = dt.replace(tzinfo=ZoneInfo(tz_name))
+            dt = dt_tz.astimezone(timezone.utc)
+        else:
+            # Naive, no tz_name — use LMT from longitude
+            from datetime import timedelta
+            lmt_offset_hours = lon / 15.0
+            lmt_tz = timezone(timedelta(hours=lmt_offset_hours))
+            dt = dt.replace(tzinfo=lmt_tz).astimezone(timezone.utc)
         return dt.strftime("%Y-%m-%dT%H:%M:%S")
     except Exception:
-        logger.warning(f"failed to normalize datetime: {dt_str} tz={tz_name}")
+        logger.warning(f"failed to normalize datetime: {dt_str} tz={tz_name} lon={lon}")
         return dt_str
 
 
@@ -112,7 +119,7 @@ async def call_sky_state(
             datetime is naive. Ignored when datetime has an offset.
     """
     # Normalize datetime to UTC ISO string
-    dt_str = _normalize_datetime(datetime, tz_name=tz)
+    dt_str = _normalize_datetime(datetime, tz_name=tz, lon=lon)
     url = EPHEMERIS_MCP_URL.strip()
     if not url.endswith("/mcp/sse"):
         url = url.rstrip("/") + "/mcp/sse"