Просмотр исходного кода

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 месяц назад
Родитель
Сommit
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 {}
 
 
-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.
 
     The ephemeris server expects UTC datetimes as plain ISO strings
     (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:
         return None
     try:
         dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
         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)
+        # else: naive, no tz_name — assume already UTC
         return dt.strftime("%Y-%m-%dT%H:%M:%S")
     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
 
 
@@ -78,21 +92,27 @@ async def call_sky_state(
     elevation: float = 0.0,
     geocentric: bool = True,
     house_system: str | None = None,
+    tz: str | None = None,
 ) -> dict[str, Any]:
     """Call ephemeris-mcp:get_sky_state and return the result dict.
 
     Datetime is normalized to UTC ISO format (no timezone offset)
     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
     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
-    dt_str = _normalize_datetime(datetime)
+    dt_str = _normalize_datetime(datetime, tz_name=tz)
     url = EPHEMERIS_MCP_URL.strip()
     if not url.endswith("/mcp/sse"):
         url = url.rstrip("/") + "/mcp/sse"
@@ -121,17 +141,6 @@ async def call_sky_state(
                     logger.warning("ephemeris-mcp returned empty payload")
                     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
     except Exception as 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_karmic: bool = False,
     top_n_aspects: int | None = None,
+    tz: str | None = None,
 ) -> dict[str, Any]:
     """Calculate a complete natal chart from birth data.
 
@@ -162,6 +163,7 @@ Returns:
         elevation=elevation,
         geocentric=True,
         house_system=house_system,
+        tz=tz,
     )
 
     if "error" in sky:
@@ -233,10 +235,12 @@ Returns:
 
     # Add lunar phase from ephemeris
     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"] = {
-            "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
@@ -1581,6 +1585,7 @@ async def _get_person_birth_data(person_id: str) -> dict[str, Any]:
         "latitude": person["latitude"],
         "longitude": person["longitude"],
         "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_karmic=include_karmic,
         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())