Sfoglia il codice sorgente

fix: timezone handling — naive datetime + IANA timezone, strip bogus DB offsets

- _normalize_datetime now accepts optional IANA timezone name for UTC conversion
- call_sky_state passes tz through to _normalize_datetime
- Removed leftover sidereal time correction (ephemeris is authoritative)
- calculate_natal_chart and calculate_natal_chart_by_id thread tz from DB
- _get_person_birth_data now returns timezone field
- DB migration: stripped bogus offsets from birth_datetime (e.g. T00:05-00:05)
- Added missing IANA timezones for chaka, einstein, gaga
- All 55 unit tests pass
Lukas Goldschmidt 1 mese fa
parent
commit
031aaafa4a

+ 148 - 0
gen_trump_chart.py

@@ -0,0 +1,148 @@
+#!/usr/bin/env python3
+"""Generate a natal chart PNG for Donald Trump."""
+
+import asyncio
+import sys
+import os
+import traceback
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
+
+from astro_mcp.ephemeris_client import call_sky_state, extract_bodies, extract_houses, extract_angles
+from astro_mcp import astrology
+from astro_mcp.chart_renderer import render_natal_wheel
+
+# Trump's birth data
+BIRTH_DATETIME = "1946-06-14T10:54:00-04:00"
+LATITUDE = 40.6501
+LONGITUDE = -73.7936
+ELEVATION = 10.0
+HOUSE_SYSTEM = "placidus"
+
+
+async def main():
+    print("Calling ephemeris for Trump's birth data...")
+    sys.stdout.flush()
+
+    try:
+        sky = await call_sky_state(
+            datetime=BIRTH_DATETIME,
+            lat=LATITUDE,
+            lon=LONGITUDE,
+            elevation=ELEVATION,
+            geocentric=True,
+            house_system=HOUSE_SYSTEM,
+        )
+    except Exception as e:
+        traceback.print_exc()
+        sys.exit(1)
+
+    if "error" in sky:
+        print(f"ERROR: {sky['error']}")
+        sys.exit(1)
+
+    raw_bodies = extract_bodies(sky)
+    houses = extract_houses(sky)
+    angles = extract_angles(sky)
+
+    print(f"Got {len(raw_bodies)} bodies, {len(houses)} houses")
+    sys.stdout.flush()
+
+    # Build planet list
+    planets = []
+    for body in raw_bodies:
+        ecl_lon = body.get("ecliptic_lon", 0.0)
+        ecl_lat = body.get("ecliptic_lat", 0.0)
+        speed_lon = body.get("speed_lon")
+        zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
+        house = astrology.get_house_placement(ecl_lon, houses)
+
+        planets.append({
+            "body": body["body"],
+            "sign": zodiac["sign"],
+            "sign_abbreviation": zodiac["abbreviation"],
+            "degree_within_sign": zodiac["degree"],
+            "absolute_lon": zodiac["absolute_lon"],
+            "ecliptic_lat": ecl_lat,
+            "distance_au": body.get("distance_au", 0.0),
+            "house": house,
+            "retrograde": astrology.is_retrograde(speed_lon),
+        })
+
+    # Calculate aspects
+    speed_lookup = {b["body"]: b.get("speed_lon") for b in raw_bodies}
+    aspect_bodies = [
+        {"name": p["body"], "lon": p["absolute_lon"], "speed_lon": speed_lookup.get(p["body"])}
+        for p in planets
+    ]
+    aspects = astrology.compute_aspects(aspect_bodies, None)
+
+    formatted_aspects = []
+    for asp in aspects:
+        formatted_aspects.append({
+            "body1": asp["body1"],
+            "body2": asp["body2"],
+            "aspect": asp["aspect"],
+            "orb": asp["orb"],
+            "applying": asp["applying"],
+            "exactness": asp["exactness"],
+        })
+
+    chart_data = {
+        "input": {
+            "birth_datetime": BIRTH_DATETIME,
+            "latitude": LATITUDE,
+            "longitude": LONGITUDE,
+            "elevation": ELEVATION,
+            "house_system": HOUSE_SYSTEM,
+            "name": "Donald Trump",
+            "birthplace": "New York, NY",
+        },
+        "chart_type": "natal",
+        "planets": planets,
+        "houses": houses,
+        "aspects": formatted_aspects,
+        "angles": angles,
+    }
+
+    print("Rendering natal wheel (bw, size=700)...")
+    sys.stdout.flush()
+
+    svg = render_natal_wheel(
+        chart_data,
+        style="modern",
+        color_mode="bw",
+        size=700,
+        table_position="none",
+        include_planets=False,
+        include_houses=False,
+        title="Donald Trump",
+    )
+
+    # Write SVG
+    svg_path = "/home/shared/trump_natal_bw.svg"
+    with open(svg_path, "w") as f:
+        f.write(svg)
+    print(f"SVG written to {svg_path}")
+
+    # Convert to PNG
+    import cairosvg
+    png_path = "/home/shared/trump_natal_bw.png"
+    cairosvg.svg2png(url=svg_path, write_to=png_path, scale=2)
+    print(f"PNG written to {png_path}")
+
+    # Print chart info
+    asc = angles.get("ascendant", {})
+    mc = angles.get("midheaven", {})
+    print(f"\nASC: {asc.get('sign')} {asc.get('degree', 0):.2f}°")
+    print(f"MC:  {mc.get('sign')} {mc.get('degree', 0):.2f}°")
+    print(f"\nPlanets:")
+    for p in planets:
+        retro = " Rx" if p["retrograde"] else ""
+        print(f"  {p['body']:12s} {p['sign']:12s} {p['degree_within_sign']:6.2f}°  H{p['house']}{retro}")
+    print(f"\nAspects ({len(formatted_aspects)}):")
+    for a in formatted_aspects[:20]:
+        print(f"  {a['body1']:10s} {a['aspect']:12s} {a['body2']:10s}  orb={a['orb']:.2f}°")
+
+
+asyncio.run(main())

+ 27 - 18
src/astro_mcp/ephemeris_client.py

@@ -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}")

+ 9 - 3
src/astro_mcp/tools.py

@@ -120,6 +120,7 @@ async def calculate_natal_chart(
     include_patterns: bool = False,
     include_patterns: bool = False,
     include_karmic: bool = False,
     include_karmic: bool = False,
     top_n_aspects: int | None = None,
     top_n_aspects: int | None = None,
+    tz: str | None = None,
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """Calculate a complete natal chart from birth data.
     """Calculate a complete natal chart from birth data.
 
 
@@ -162,6 +163,7 @@ Returns:
         elevation=elevation,
         elevation=elevation,
         geocentric=True,
         geocentric=True,
         house_system=house_system,
         house_system=house_system,
+        tz=tz,
     )
     )
 
 
     if "error" in sky:
     if "error" in sky:
@@ -233,10 +235,12 @@ Returns:
 
 
     # Add lunar phase from ephemeris
     # Add lunar phase from ephemeris
     lunar_state = sky.get("lunar_state", {})
     lunar_state = sky.get("lunar_state", {})
-    if lunar_state:
+    lunar = lunar_state.get("lunar_state", {}) if isinstance(lunar_state, dict) else {}
+    if lunar:
         result["lunar_phase"] = {
         result["lunar_phase"] = {
-            "phase_name": lunar_state.get("phase_name"),
-            "illumination_fraction": lunar_state.get("illumination_fraction"),
+            "phase_name": lunar.get("phase_name"),
+            "illumination_fraction": lunar.get("illumination_fraction"),
+            "age_days": lunar.get("age_days"),
         }
         }
 
 
     # Limit aspects if requested
     # Limit aspects if requested
@@ -1581,6 +1585,7 @@ async def _get_person_birth_data(person_id: str) -> dict[str, Any]:
         "latitude": person["latitude"],
         "latitude": person["latitude"],
         "longitude": person["longitude"],
         "longitude": person["longitude"],
         "elevation": person.get("elevation", 0.0),
         "elevation": person.get("elevation", 0.0),
+        "timezone": person.get("timezone"),
     }
     }
 
 
 
 
@@ -1626,6 +1631,7 @@ async def calculate_natal_chart_by_id(
         include_patterns=include_patterns,
         include_patterns=include_patterns,
         include_karmic=include_karmic,
         include_karmic=include_karmic,
         top_n_aspects=top_n_aspects,
         top_n_aspects=top_n_aspects,
+        tz=birth.get("timezone"),
     )
     )
 
 
 
 

+ 34 - 0
test_ephem.py

@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""Quick test of the ephemeris client connection."""
+
+import asyncio
+import sys
+import os
+import traceback
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
+
+from astro_mcp.ephemeris_client import call_sky_state
+
+async def main():
+    try:
+        result = await call_sky_state(
+            datetime="1946-06-14T14:54:00",
+            lat=40.6501,
+            lon=-73.7936,
+            elevation=10.0,
+            geocentric=True,
+            house_system="placidus",
+        )
+        print(f"Result keys: {result.keys()}")
+        if "error" in result:
+            print(f"ERROR: {result['error']}")
+        else:
+            bodies = result.get("planetary_positions", {}).get("bodies", [])
+            print(f"Got {len(bodies)} bodies")
+            houses = result.get("houses", {})
+            print(f"Houses: {houses.get('cusps', 'N/A')}")
+    except Exception as e:
+        traceback.print_exc()
+
+asyncio.run(main())

+ 106 - 0
tests/test_step1_ground_truth.py

@@ -0,0 +1,106 @@
+"""
+Step 1: Ground truth — compute houses/angles locally using swisseph directly.
+No server, no container, no network. Pure Python + Swiss Ephemeris.
+
+This gives us the reference values to compare all servers against.
+"""
+from __future__ import annotations
+
+import sys
+import os
+
+# Add ephemeris-mcp to path so we can import swisseph from its venv
+sys.path.insert(0, os.path.expanduser("~/.openclaw/workspace/ephemeris-mcp/src"))
+
+import swisseph as swe
+
+# ── Reference birth data ──────────────────────────────────────────────
+
+PEOPLE = {
+    "trump": {
+        "label": "Donald Trump",
+        "datetime_utc": "1946-06-14T14:54:00",  # 10:54 AM EDT = 14:54 UTC
+        "lat": 40.6413,
+        "lon": -73.7781,
+    },
+    "einstein": {
+        "label": "Albert Einstein",
+        "datetime_utc": "1879-03-14T10:37:00",  # 11:30 AM LMT (UTC+0:53) = 10:37 UTC
+        "lat": 48.39841,
+        "lon": 9.99155,
+    },
+    "chaka": {
+        "label": "Chaka Khan",
+        "datetime_utc": "1953-03-24T03:05:00",  # 9:05 PM CST (UTC-6) = 03:05 UTC next day
+        "lat": 41.87811,
+        "lon": -87.62980,
+    },
+}
+
+TOLERANCE = 0.01  # degrees — very tight
+
+
+def _parse_utc_dt(dt_str):
+    from datetime import datetime
+    return datetime.fromisoformat(dt_str)
+
+
+def _compute_houses(dt_utc, lat, lon, house_system=b"P"):
+    jd = swe.julday(dt_utc.year, dt_utc.month, dt_utc.day,
+                    dt_utc.hour + dt_utc.minute / 60 + dt_utc.second / 3600)
+    cusps, ascmc = swe.houses(jd, lat, lon, house_system)
+    return {
+        "jd": jd,
+        "cusps": list(cusps),
+        "asc": ascmc[0],
+        "mc": ascmc[1],
+        "dsc": ascmc[0] + 180.0 if ascmc[0] < 180.0 else ascmc[0] - 180.0,
+        "ic": ascmc[1] + 180.0 if ascmc[1] < 180.0 else ascmc[1] - 180.0,
+    }
+
+
+def _fmt(lon):
+    signs = ["Ari", "Tau", "Gem", "Can", "Leo", "Vir",
+             "Lib", "Sco", "Sag", "Cap", "Aqu", "Pis"]
+    idx = int(lon // 30) % 12
+    deg = lon % 30.0
+    return f"{signs[idx]} {deg:05.2f}° (abs {lon:.6f}°)"
+
+
+def main():
+    print("=" * 70)
+    print("STEP 1: Ground truth — swisseph direct computation")
+    print("=" * 70)
+
+    results = {}
+    for key, p in PEOPLE.items():
+        dt = _parse_utc_dt(p["datetime_utc"])
+        h = _compute_houses(dt, p["lat"], p["lon"])
+        results[key] = h
+
+        print(f"\n{p['label']}:")
+        print(f"  UTC: {p['datetime_utc']}  lat={p['lat']}  lon={p['lon']}")
+        print(f"  JD:  {h['jd']:.6f}")
+        print(f"  ASC: {_fmt(h['asc'])}")
+        print(f"  MC:  {_fmt(h['mc'])}")
+        print(f"  DSC: {_fmt(h['dsc'])}")
+        print(f"  IC:  {_fmt(h['ic'])}")
+        print(f"  Cusps: ", end="")
+        cusp_strs = [f"H{i+1}={_fmt(c)}" for i, c in enumerate(h["cusps"])]
+        print(" | ".join(cusp_strs))
+
+    # Print machine-readable summary for easy comparison
+    print("\n" + "=" * 70)
+    print("MACHINE-READABLE REFERENCE VALUES:")
+    print("=" * 70)
+    for key, p in PEOPLE.items():
+        h = results[key]
+        print(f"{key}: ASC={h['asc']:.6f} MC={h['mc']:.6f} "
+              f"DSC={h['dsc']:.6f} IC={h['ic']:.6f} "
+              f"JD={h['jd']:.6f}")
+
+    return results
+
+
+if __name__ == "__main__":
+    main()

+ 158 - 0
tests/test_step2_local_ephemeris.py

@@ -0,0 +1,158 @@
+"""
+Step 2: Call the LOCAL ephemeris-mcp server (run.sh) via MCP SSE.
+Compare returned houses/angles against ground truth from Step 1.
+
+Requires: ephemeris-mcp running locally via run.sh on port 7015.
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import sys
+from datetime import timedelta
+
+from mcp import ClientSession
+from mcp.client.sse import sse_client
+
+# ── Ground truth from Step 1 ──────────────────────────────────────────
+
+REFERENCE = {
+    "trump": {
+        "label": "Donald Trump",
+        "datetime_utc": "1946-06-14T14:54:00",
+        "lat": 40.6413,
+        "lon": -73.7781,
+        "asc": 149.972371,
+        "mc": 54.387979,
+    },
+    "einstein": {
+        "label": "Albert Einstein",
+        "datetime_utc": "1879-03-14T10:37:00",
+        "lat": 48.39841,
+        "lon": 9.99155,
+        "asc": 98.923507,
+        "mc": 339.337618,
+    },
+    "chaka": {
+        "label": "Chaka Khan",
+        "datetime_utc": "1953-03-24T03:05:00",
+        "lat": 41.87811,
+        "lon": -87.62980,
+        "asc": 218.923115,
+        "mc": 137.470885,
+    },
+}
+
+TOLERANCE = 0.01  # degrees
+
+EPHEMERIS_URL = os.environ.get(
+    "EPHEMERIS_MCP_URL", "http://127.0.0.1:7015/mcp/sse"
+)
+
+
+def _payload_from_result(result):
+    payload = getattr(result, "structuredContent", None)
+    if isinstance(payload, dict):
+        return payload
+    content_items = getattr(result, "content", []) or []
+    for item in content_items:
+        text = getattr(item, "text", None)
+        if isinstance(text, str) and text.strip():
+            try:
+                decoded = json.loads(text)
+            except Exception:
+                continue
+            if isinstance(decoded, dict):
+                return decoded
+    return {}
+
+
+async def call_ephemeris(datetime_utc, lat, lon, house_system="placidus"):
+    url = EPHEMERIS_URL
+    if not url.endswith("/mcp/sse"):
+        url = url.rstrip("/") + "/mcp/sse"
+    async with sse_client(url, timeout=15.0, sse_read_timeout=15.0) as streams:
+        async with ClientSession(
+            *streams, read_timeout_seconds=timedelta(seconds=15)
+        ) as session:
+            await session.initialize()
+            result = await session.call_tool(
+                "get_sky_state",
+                {
+                    "datetime": datetime_utc,
+                    "lat": lat,
+                    "lon": lon,
+                    "elevation": 0.0,
+                    "geocentric": True,
+                    "house_system": house_system,
+                },
+            )
+            return _payload_from_result(result)
+
+
+def _fmt(lon):
+    signs = ["Ari", "Tau", "Gem", "Can", "Leo", "Vir",
+             "Lib", "Sco", "Sag", "Cap", "Aqu", "Pis"]
+    idx = int(lon // 30) % 12
+    deg = lon % 30.0
+    return f"{signs[idx]} {deg:05.2f}° (abs {lon:.6f}°)"
+
+
+async def main():
+    print("=" * 70)
+    print("STEP 2: Local ephemeris-mcp server (run.sh) via MCP")
+    print(f"  URL: {EPHEMERIS_URL}")
+    print("=" * 70)
+
+    all_ok = True
+    for key, ref in REFERENCE.items():
+        print(f"\n--- {ref['label']} ---")
+        print(f"  Input: UTC={ref['datetime_utc']}  lat={ref['lat']}  lon={ref['lon']}")
+
+        try:
+            sky = await call_ephemeris(ref["datetime_utc"], ref["lat"], ref["lon"])
+        except Exception as e:
+            print(f"  ERROR: Could not reach server: {e}")
+            all_ok = False
+            continue
+
+        houses_data = sky.get("houses", {})
+        if not houses_data or "error" in houses_data:
+            print(f"  ERROR: Server returned error: {houses_data}")
+            all_ok = False
+            continue
+
+        server_asc = houses_data.get("ascendant")
+        server_mc = houses_data.get("midheaven")
+        cusps = houses_data.get("cusps", [])
+        server_cusp1 = cusps[0]["absolute_lon"] if cusps else None
+
+        print(f"  Server ASC:  {_fmt(server_asc)}" if server_asc else "  Server ASC:  MISSING")
+        print(f"  Server MC:   {_fmt(server_mc)}" if server_mc else "  Server MC:   MISSING")
+        print(f"  Server H1:   {_fmt(server_cusp1)}" if server_cusp1 else "  Server H1:   MISSING")
+        print(f"  Expected ASC: {_fmt(ref['asc'])}")
+        print(f"  Expected MC:  {_fmt(ref['mc'])}")
+
+        asc_diff = abs(server_asc - ref["asc"]) if server_asc else float("inf")
+        mc_diff = abs(server_mc - ref["mc"]) if server_mc else float("inf")
+
+        asc_ok = asc_diff < TOLERANCE
+        mc_ok = mc_diff < TOLERANCE
+
+        print(f"  ASC diff: {asc_diff:.6f}°  {'OK' if asc_ok else 'MISMATCH'}")
+        print(f"  MC  diff: {mc_diff:.6f}°  {'OK' if mc_ok else 'MISMATCH'}")
+
+        if not asc_ok or not mc_ok:
+            all_ok = False
+
+    print("\n" + "=" * 70)
+    if all_ok:
+        print("RESULT: LOCAL EPHEMERIS SERVER IS CORRECT")
+    else:
+        print("RESULT: LOCAL EPHEMERIS SERVER HAS MISMATCHES")
+    print("=" * 70)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 161 - 0
tests/test_step4_local_astro.py

@@ -0,0 +1,161 @@
+"""
+Step 4: Call the LOCAL astro-mcp server via MCP SSE.
+Tests both paths (DB lookup + direct call) and compares angles against ground truth.
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+from datetime import timedelta
+
+from mcp import ClientSession
+from mcp.client.sse import sse_client
+
+# ── Ground truth from Step 1 (Placidus, Swiss Ephemeris direct) ─────
+
+REFERENCE = {
+    "trump": {
+        "label": "Donald Trump",
+        "birth_datetime": "1946-06-14T10:54:00-04:00",
+        "lat": 40.72677,
+        "lon": -73.74152,
+        # Ground truth: swe.houses(jd, 40.72677, -73.74152, b'P')
+        "asc": 150.025101,
+        "mc": 54.423678,
+    },
+    "einstein": {
+        "label": "Albert Einstein",
+        "birth_datetime": "1879-03-14T11:30:00+00:53",
+        "lat": 48.39841,
+        "lon": 9.99155,
+        "asc": 98.923507,
+        "mc": 339.337618,
+    },
+    "chaka": {
+        "label": "Chaka Khan",
+        "birth_datetime": "1953-03-23T21:05:00-06:00",
+        "lat": 41.87811,
+        "lon": -87.62980,
+        "asc": 218.923115,
+        "mc": 137.470885,
+    },
+}
+
+TOLERANCE = 0.01
+
+ASTRO_URL = os.environ.get("ASTRO_MCP_URL", "http://127.0.0.1:7016/mcp/sse")
+
+
+def _payload(result):
+    p = getattr(result, "structuredContent", None)
+    if isinstance(p, dict):
+        return p
+    for item in getattr(result, "content", []) or []:
+        t = getattr(item, "text", None)
+        if isinstance(t, str) and t.strip():
+            try:
+                d = json.loads(t)
+                if isinstance(d, dict):
+                    return d
+            except Exception:
+                continue
+    return {}
+
+
+def _fmt(lon):
+    signs = ["Ari", "Tau", "Gem", "Can", "Leo", "Vir",
+             "Lib", "Sco", "Sag", "Cap", "Aqu", "Pis"]
+    idx = int(lon // 30) % 12
+    deg = lon % 30.0
+    return f"{signs[idx]} {deg:05.2f} (abs {lon:.6f})"
+
+
+def check(ref, angles, houses, path_label):
+    asc = angles.get("ascendant", {}).get("absolute_lon")
+    mc = angles.get("midheaven", {}).get("absolute_lon")
+    h1 = houses[0]["absolute_lon"] if houses else None
+
+    asc_diff = abs(asc - ref["asc"]) if asc else float("inf")
+    mc_diff = abs(mc - ref["mc"]) if mc else float("inf")
+    asc_ok = asc_diff < TOLERANCE
+    mc_ok = mc_diff < TOLERANCE
+
+    print(f"\n  [{path_label}]")
+    if asc:
+        print(f"    ASC: {_fmt(asc)}  exp: {_fmt(ref['asc'])}  diff={asc_diff:.6f}  {'OK' if asc_ok else 'MISMATCH'}")
+    else:
+        print(f"    ASC: MISSING")
+    if mc:
+        print(f"    MC:  {_fmt(mc)}  exp: {_fmt(ref['mc'])}  diff={mc_diff:.6f}  {'OK' if mc_ok else 'MISMATCH'}")
+    else:
+        print(f"    MC:  MISSING")
+
+    if not asc_ok or not mc_ok:
+        print(f"    FULL ANGLES: {json.dumps(angles, indent=6, default=str)[:600]}")
+
+    return asc_ok and mc_ok
+
+
+async def main():
+    print("=" * 70)
+    print("STEP 4: Local astro-mcp — find where angles get corrupted")
+    print(f"  URL: {ASTRO_URL}")
+    print("=" * 70)
+
+    url = ASTRO_URL
+    if not url.endswith("/mcp/sse"):
+        url = url.rstrip("/") + "/mcp/sse"
+
+    all_ok = True
+
+    async with sse_client(url, timeout=30, sse_read_timeout=30) as streams:
+        async with ClientSession(*streams, read_timeout_seconds=timedelta(seconds=30)) as session:
+            await session.initialize()
+
+            for key, ref in REFERENCE.items():
+                print(f"\n{'='*50}")
+                print(f"  {ref['label']}")
+                print(f"  Input: {ref['birth_datetime']}  lat={ref['lat']}  lon={ref['lon']}")
+
+                # Path A: DB lookup
+                chart_a = _payload(await session.call_tool(
+                    "calculate_natal_chart_by_id",
+                    {"person_id": key, "house_system": "placidus"},
+                ))
+                if "error" in chart_a:
+                    print(f"\n  [DB PATH] ERROR: {chart_a['error']}")
+                    all_ok = False
+                else:
+                    ok = check(ref, chart_a.get("angles", {}), chart_a.get("houses", []), "DB by_id")
+                    if not ok:
+                        all_ok = False
+
+                # Path B: Direct call
+                chart_b = _payload(await session.call_tool(
+                    "calculate_natal_chart",
+                    {
+                        "birth_datetime": ref["birth_datetime"],
+                        "latitude": ref["lat"],
+                        "longitude": ref["lon"],
+                        "house_system": "placidus",
+                    },
+                ))
+                if "error" in chart_b:
+                    print(f"\n  [DIRECT PATH] ERROR: {chart_b['error']}")
+                    all_ok = False
+                else:
+                    ok = check(ref, chart_b.get("angles", {}), chart_b.get("houses", []), "DIRECT")
+                    if not ok:
+                        all_ok = False
+
+    print("\n" + "=" * 70)
+    if all_ok:
+        print("RESULT: LOCAL ASTRO-MCP IS CORRECT")
+    else:
+        print("RESULT: LOCAL ASTRO-MCP HAS MISMATCHES")
+    print("=" * 70)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())