|
@@ -53,21 +53,35 @@ def _payload_from_result(result: Any) -> dict[str, Any]:
|
|
|
return {}
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _normalize_datetime(dt_str: str | None) -> str | None:
|
|
|
|
|
|
|
+def _normalize_datetime(dt_str: str | None, tz_name: str | None = None) -> str | None:
|
|
|
"""Normalize a datetime string to UTC ISO format without timezone offset.
|
|
"""Normalize a datetime string to UTC ISO format without timezone offset.
|
|
|
|
|
|
|
|
The ephemeris server expects UTC datetimes as plain ISO strings
|
|
The ephemeris server expects UTC datetimes as plain ISO strings
|
|
|
(e.g. '1965-07-02T22:05:00') without timezone info.
|
|
(e.g. '1965-07-02T22:05:00') without timezone info.
|
|
|
|
|
+
|
|
|
|
|
+ Two 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.
|
|
|
"""
|
|
"""
|
|
|
if dt_str is None:
|
|
if dt_str is None:
|
|
|
return None
|
|
return None
|
|
|
try:
|
|
try:
|
|
|
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
|
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
|
|
if dt.tzinfo is not None:
|
|
if dt.tzinfo is not None:
|
|
|
|
|
+ # 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)
|
|
dt = dt.astimezone(timezone.utc)
|
|
|
|
|
+ # else: naive, no tz_name — assume already UTC
|
|
|
return dt.strftime("%Y-%m-%dT%H:%M:%S")
|
|
return dt.strftime("%Y-%m-%dT%H:%M:%S")
|
|
|
except Exception:
|
|
except Exception:
|
|
|
- logger.warning(f"failed to normalize datetime: {dt_str}")
|
|
|
|
|
|
|
+ logger.warning(f"failed to normalize datetime: {dt_str} tz={tz_name}")
|
|
|
return dt_str
|
|
return dt_str
|
|
|
|
|
|
|
|
|
|
|
|
@@ -78,21 +92,27 @@ async def call_sky_state(
|
|
|
elevation: float = 0.0,
|
|
elevation: float = 0.0,
|
|
|
geocentric: bool = True,
|
|
geocentric: bool = True,
|
|
|
house_system: str | None = None,
|
|
house_system: str | None = None,
|
|
|
|
|
+ tz: str | None = None,
|
|
|
) -> dict[str, Any]:
|
|
) -> dict[str, Any]:
|
|
|
"""Call ephemeris-mcp:get_sky_state and return the result dict.
|
|
"""Call ephemeris-mcp:get_sky_state and return the result dict.
|
|
|
|
|
|
|
|
Datetime is normalized to UTC ISO format (no timezone offset)
|
|
Datetime is normalized to UTC ISO format (no timezone offset)
|
|
|
before sending to the ephemeris server.
|
|
before sending to the ephemeris server.
|
|
|
|
|
|
|
|
- The ephemeris server returns Greenwich sidereal_time regardless of
|
|
|
|
|
- the lon parameter. We correct local_sidereal_time here by adding
|
|
|
|
|
- the longitude offset (15° = 1 hour, east positive).
|
|
|
|
|
-
|
|
|
|
|
When house_system is provided, the response includes house cusps
|
|
When house_system is provided, the response includes house cusps
|
|
|
and angles computed by the Swiss Ephemeris on the server side.
|
|
and angles computed by the Swiss Ephemeris on the server side.
|
|
|
|
|
+ The server-side computation is authoritative — no post-processing
|
|
|
|
|
+ of angles, houses, or sidereal time is done here.
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ datetime: ISO 8601 datetime string. May be naive (no offset) or
|
|
|
|
|
+ offset-aware. If naive, tz must be provided for correct UTC
|
|
|
|
|
+ conversion (handles historical DST).
|
|
|
|
|
+ tz: IANA timezone name (e.g. "America/New_York"). Used only when
|
|
|
|
|
+ datetime is naive. Ignored when datetime has an offset.
|
|
|
"""
|
|
"""
|
|
|
# Normalize datetime to UTC ISO string
|
|
# Normalize datetime to UTC ISO string
|
|
|
- dt_str = _normalize_datetime(datetime)
|
|
|
|
|
|
|
+ dt_str = _normalize_datetime(datetime, tz_name=tz)
|
|
|
url = EPHEMERIS_MCP_URL.strip()
|
|
url = EPHEMERIS_MCP_URL.strip()
|
|
|
if not url.endswith("/mcp/sse"):
|
|
if not url.endswith("/mcp/sse"):
|
|
|
url = url.rstrip("/") + "/mcp/sse"
|
|
url = url.rstrip("/") + "/mcp/sse"
|
|
@@ -121,17 +141,6 @@ async def call_sky_state(
|
|
|
logger.warning("ephemeris-mcp returned empty payload")
|
|
logger.warning("ephemeris-mcp returned empty payload")
|
|
|
return {"error": "empty_response", "url": url}
|
|
return {"error": "empty_response", "url": url}
|
|
|
|
|
|
|
|
- # Fix local sidereal time: ephemeris may return incorrect LST.
|
|
|
|
|
- # Compute it properly from Greenwich ST + longitude.
|
|
|
|
|
- # East longitude positive, 15° = 1 hour.
|
|
|
|
|
- if "sidereal_time" in payload:
|
|
|
|
|
- st = payload["sidereal_time"]
|
|
|
|
|
- if isinstance(st, dict):
|
|
|
|
|
- gst = st.get("greenwich_sidereal_time",
|
|
|
|
|
- st.get("local_sidereal_time", 0.0))
|
|
|
|
|
- if isinstance(gst, (int, float)):
|
|
|
|
|
- st["local_sidereal_time"] = (gst + lon / 15.0) % 24.0
|
|
|
|
|
-
|
|
|
|
|
return payload
|
|
return payload
|
|
|
except Exception as exc:
|
|
except Exception as exc:
|
|
|
logger.error(f"ephemeris-mcp call failed: {exc}")
|
|
logger.error(f"ephemeris-mcp call failed: {exc}")
|