Browse Source

fix: single-point timezone conversion, clean up double normalization

Rule: _get_person_birth_data() is the ONLY function that converts stored
birth datetimes to UTC. All _byId tools receive UTC and must not re-normalize.

- _get_person_birth_data: now converts naive local time + IANA timezone to UTC
  using zoneinfo (modern dates) or LMT from longitude (fallback)
- call_sky_state: no longer normalizes — expects UTC from callers
- calculate_natal_chart: no longer normalizes — expects UTC from callers
- All _byId functions use _get_person_birth_data (which returns UTC)
- Removed tz parameter from call_sky_state and calculate_natal_chart
- Removed stale sidereal time correction from call_sky_state
- DB migration: stripped bogus offsets from birth_datetime, added missing IANA timezones
- Added agent-guides/server-guide.md with architecture, timezone rules, tool reference
- Registered astro://guides/server-guide resource
Lukas Goldschmidt 1 month ago
parent
commit
05badb5cfd

+ 207 - 0
agent-guides/server-guide.md

@@ -0,0 +1,207 @@
+# Astro-MCP Server Guide
+
+## Overview
+
+Astro-MCP is an astrological chart calculation server. It consumes birth data
+and returns planetary positions, house cusps, angles, and aspects. All
+computation is delegated to a separate **ephemeris-mcp** server which runs the
+Swiss Ephemeris library.
+
+## Architecture
+
+```
+User / Agent
+    |
+    v
+astro-mcp (this server)
+    |  MCP SSE
+    v
+ephemeris-mcp (Swiss Ephemeris backend)
+```
+
+- **astro-mcp** handles: person database, chart interpretation, rendering,
+  timezone conversion, MCP tool interface
+- **ephemeris-mcp** handles: raw astronomical computation (planetary positions,
+  house cusps, sidereal time)
+
+## Timezone Rules (CRITICAL)
+
+### Single Conversion Point
+
+**`_get_person_birth_data()`** is the ONLY function that converts stored birth
+datetimes to UTC. It reads the naive local time and IANA timezone name from the
+persons database, combines them, and returns a UTC datetime. All `_byId` chart
+tools call this function. **No other function performs timezone conversion.**
+
+### Database Storage Format
+
+- `birth_datetime`: naive local time, NO offset (e.g. `"1965-07-02T00:05:00"`)
+- `timezone`: IANA timezone name (e.g. `"Europe/Vienna"`, `"America/New_York"`)
+- `longitude`: used as fallback for LMT when timezone is missing
+
+### Direct-Call Tools
+
+Tools that accept `birth_datetime` as a parameter (e.g. `calculate_natal_chart`,
+`calculate_transit_chart`) expect **UTC or offset-aware datetimes**. The caller
+is responsible for conversion. Use ISO 8601 format:
+- UTC: `"1965-07-01T23:05:00Z"` or `"1965-07-01T23:05:00+00:00"`
+- Offset-aware: `"1965-07-02T00:05:00+01:00"`
+
+### _byId Tools
+
+Tools that look up persons by ID/nickname (e.g. `calculate_natal_chart_by_id`)
+automatically convert to UTC. Just pass the person identifier.
+
+## Person Management
+
+### Adding a Person
+
+Use `person_manage` with `action: "add"`. Required fields:
+- `name`: Full name
+- `birth_datetime`: ISO 8601 datetime (any format — will be stored)
+- `latitude`, `longitude`: Birth coordinates
+- `tz`: IANA timezone name (e.g. `"Europe/Vienna"`)
+
+Optional: `nickname`, `birthplace`, `elevation`, `gender`, `description`, `notes`,
+`birth_time_known`.
+
+### Important: Timezone Must Be Set
+
+When adding a person, ALWAYS provide the `tz` field with the correct IANA
+timezone name. This is essential for accurate chart calculation, especially for:
+- Historical dates (before ~1890) where LMT differs from modern timezones
+- Locations that observed DST differently in the past
+- Any date where the UTC offset is not obvious
+
+### Listing and Updating
+
+- `person_manage` with `action: "list"` — list all persons
+- `person_manage` with `action: "get"` — retrieve by ID or nickname
+- `person_manage` with `action: "update"` — modify fields
+- `person_manage` with `action: "delete"` — remove person
+
+## Tool Categories
+
+### Data-Only Tools (return JSON)
+
+| Tool | Description |
+|------|-------------|
+| `get_sky_state` | Raw planetary positions + houses for a datetime/location |
+| `calculate_natal_chart` | Full natal chart: planets, houses, aspects, angles |
+| `calculate_transit_chart` | Transit-to-natal aspects |
+| `calculate_synastry_chart` | Relationship chart for two people |
+| `calculate_composite_chart` | Composite (midpoint) chart |
+| `calculate_davison_chart` | Davison (space-time midpoint) chart |
+| `get_transit_preview` | Daily transit snapshots over a date range |
+| `get_karmic_relationship_summary` | Karmic analysis for a relationship |
+
+### _byId Variants (lookup person from DB)
+
+Same as above but with `_byId` suffix. Accept `person_id` (or nickname) instead
+of birth data. Automatically handle timezone conversion.
+
+### Rendering Tools (return SVG)
+
+| Tool | Description |
+|------|-------------|
+| `render_natal_chart` | SVG natal chart wheel |
+| `render_transit_chart` | SVG transit chart |
+| `render_synastry_chart` | SVG synastry chart |
+| `render_composite_chart` | SVG composite chart |
+| `render_davison_chart` | SVG Davison chart |
+
+Each has a `_byId` variant.
+
+### Utility Tools
+
+| Tool | Description |
+|------|-------------|
+| `person_manage` | CRUD for persons database |
+| `list_house_systems` | List supported house systems (Placidus, Koch, etc.) |
+
+## House Systems
+
+Supported house system codes: P (Placidus), K (Koch), E (Equal), W (Whole Sign),
+A (Alcabitius), C (Campanus), M (Morinus), R (Porphyry), and more. Use
+`list_house_systems` for the full list.
+
+Default is Placidus.
+
+## Common Workflows
+
+### Natal Chart for a Known Person
+
+```
+1. person_manage(action="add", name="...", birth_datetime="...", latitude=..., longitude=..., tz="...")
+2. calculate_natal_chart_by_id(person_id="...", include_overview=true)
+```
+
+### Natal Chart for a One-Off Calculation
+
+```
+calculate_natal_chart(
+    birth_datetime="1990-05-15T10:30:00+01:00",  # UTC or offset-aware
+    latitude=47.07,
+    longitude=15.42,
+    include_overview=true
+)
+```
+
+### Transit Chart
+
+```
+calculate_transit_chart_by_id(
+    person_id="...",
+    transit_datetime="2026-06-07T12:00:00Z"  # UTC
+)
+```
+
+### Relationship Analysis
+
+```
+calculate_synastry_chart_by_id(
+    person1_id="...",
+    person2_id="...",
+    include_davison_full=true
+)
+```
+
+### Rendered Chart
+
+```
+render_natal_chart_by_id(
+    person_id="...",
+    style="modern",
+    color_mode="color",
+    size=800
+)
+```
+
+## Resources (fetch with astro:// URI)
+
+| URI | Content |
+|-----|---------|
+| `astro://guides/natal-astrology` | Natal chart interpretation guide |
+| `astro://guides/karmic-astrology` | Karmic astrology guide |
+| `astro://guides/relationship-astrology` | Relationship astrology guide |
+| `astro://guides/financial-astrology` | Financial astrology guide |
+
+## Error Handling
+
+All tools return `{"error": "message"}` on failure. Common errors:
+- `"person not found: ..."` — invalid person_id or nickname
+- `"add requires: name, birth_datetime, latitude, longitude"` — missing fields
+- `"empty_response"` — ephemeris server unreachable
+
+## Tips
+
+1. Always use `_byId` tools when working with stored persons — they handle
+   timezone conversion automatically.
+2. For direct-call tools, pass UTC datetimes to avoid ambiguity.
+3. The `include_overview` flag adds element/modality/hemisphere balance,
+   stelliums, empty houses, chart ruler, and house rulers.
+4. The `include_patterns` flag adds T-square, Grand Trine, Grand Cross, Yod
+   detection and chart shape classification.
+5. The `include_karmic` flag adds nodal axis, Saturn, Pluto polarity point,
+   Part of Fortune, and 12th house analysis.
+6. Use `top_n_aspects` to limit aspect output to the N tightest by orb.

+ 0 - 148
gen_trump_chart.py

@@ -1,148 +0,0 @@
-#!/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())

+ 21 - 32
src/astro_mcp/ephemeris_client.py

@@ -54,37 +54,30 @@ def _payload_from_result(result: Any) -> dict[str, Any]:
 
 
 
 
 def _normalize_datetime(dt_str: str | None, tz_name: str | None = None, lon: float = 0.0) -> 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.
-
-    Three modes:
-    - If dt_str has a timezone offset (e.g. '+01:00', '-04:00', 'Z'), it is
-      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.
+    """Convert a datetime string to UTC.
+
+    This is a pure conversion utility — it does NOT know about the source
+    of the datetime. Callers must ensure the right inputs:
+    - If dt_str already has an offset, it is converted directly.
+    - If dt_str is naive, tz_name (IANA) is used for conversion.
+    - If dt_str is naive and tz_name is None, LMT from longitude is used.
+
+    The SINGLE POINT where DB birth data is converted to UTC is
+    _get_person_birth_data(). All other functions receive UTC datetimes
+    and must NOT call this function again.
     """
     """
     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)
             dt = dt.astimezone(timezone.utc)
         elif tz_name:
         elif tz_name:
             from zoneinfo import ZoneInfo
             from zoneinfo import ZoneInfo
-            dt_tz = dt.replace(tzinfo=ZoneInfo(tz_name))
-            dt = dt_tz.astimezone(timezone.utc)
+            dt = dt.replace(tzinfo=ZoneInfo(tz_name)).astimezone(timezone.utc)
         else:
         else:
-            # Naive, no tz_name — use LMT from longitude
             from datetime import timedelta
             from datetime import timedelta
-            lmt_offset_hours = lon / 15.0
-            lmt_tz = timezone(timedelta(hours=lmt_offset_hours))
+            lmt_tz = timezone(timedelta(hours=lon / 15.0))
             dt = dt.replace(tzinfo=lmt_tz).astimezone(timezone.utc)
             dt = dt.replace(tzinfo=lmt_tz).astimezone(timezone.utc)
         return dt.strftime("%Y-%m-%dT%H:%M:%S")
         return dt.strftime("%Y-%m-%dT%H:%M:%S")
     except Exception:
     except Exception:
@@ -99,27 +92,23 @@ 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)
-    before sending to the ephemeris server.
+    datetime MUST be UTC. This function does NOT perform timezone conversion.
+    Callers are responsible for converting to UTC before calling:
+    - _byId functions use _get_person_birth_data() which returns UTC.
+    - Direct-call functions must pass UTC or offset-aware datetime.
 
 
     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
     The server-side computation is authoritative — no post-processing
     of angles, houses, or sidereal time is done here.
     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, tz_name=tz, lon=lon)
+    # Datetime must be UTC — callers are responsible for conversion.
+    # _byId functions pre-convert via _get_person_birth_data.
+    # Direct-call functions must pass UTC or offset-aware datetime.
+    dt_str = datetime or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
     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"

+ 6 - 0
src/astro_mcp/server.py

@@ -98,6 +98,12 @@ def financial_astrology_guide() -> str:
     return _read_guide("financial-astrology.md")
     return _read_guide("financial-astrology.md")
 
 
 
 
+@mcp.resource("astro://guides/server-guide")
+def server_guide() -> str:
+    """Server usage guide — architecture, timezone rules, person management, tool reference."""
+    return _read_guide("server-guide.md")
+
+
 def create_app() -> FastAPI:
 def create_app() -> FastAPI:
     config.LOG_DIR.mkdir(parents=True, exist_ok=True)
     config.LOG_DIR.mkdir(parents=True, exist_ok=True)
     logging.basicConfig(
     logging.basicConfig(

+ 26 - 6
src/astro_mcp/tools.py

@@ -120,10 +120,12 @@ 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.
 
 
+    birth_datetime MUST be UTC. For DB-backed charts, use the _byId variant
+    (calculate_natal_chart_by_id) which handles timezone conversion automatically.
+
 PRIMARY TOOL for natal astrology. Returns planetary positions, houses, aspects,
 PRIMARY TOOL for natal astrology. Returns planetary positions, houses, aspects,
 and angles. Use the optional flags to add interpretation layers.
 and angles. Use the optional flags to add interpretation layers.
 
 
@@ -156,6 +158,8 @@ Args:
 Returns:
 Returns:
     Dict with: input, chart_type, planets, houses, aspects, angles, lunar_phase,
     Dict with: input, chart_type, planets, houses, aspects, angles, lunar_phase,
     and optionally: overview, aspect_patterns, chart_shape, karmic."""
     and optionally: overview, aspect_patterns, chart_shape, karmic."""
+    # birth_datetime is UTC: _byId callers pre-convert via _get_person_birth_data,
+    # direct-call users are responsible for passing UTC or offset-aware datetime.
     sky = await call_sky_state(
     sky = await call_sky_state(
         datetime=birth_datetime,
         datetime=birth_datetime,
         lat=latitude,
         lat=latitude,
@@ -163,7 +167,6 @@ 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:
@@ -1572,20 +1575,38 @@ def list_house_systems() -> dict[str, Any]:
 
 
 
 
 async def _get_person_birth_data(person_id: str) -> dict[str, Any]:
 async def _get_person_birth_data(person_id: str) -> dict[str, Any]:
-    """Fetch birth data from DB by person_id or nickname."""
+    """Fetch birth data from the persons database and convert datetime to UTC.
+
+    This is the SINGLE POINT where stored birth datetime is converted to UTC.
+    All _byId chart tools call this function and receive UTC datetimes — they
+    must NOT perform any additional timezone conversion.
+
+    The DB stores birth_datetime as naive local time (no offset) and timezone
+    as an IANA name (e.g. "Europe/Vienna"). This function combines them to
+    produce the correct UTC datetime, using zoneinfo for modern dates and
+    LMT (longitude/15) as fallback when no timezone is set.
+
+    Rule: birth_datetime in the returned dict is ALWAYS UTC. Callers must not
+    re-normalize it.
+    """
     from . import storage
     from . import storage
+    from .ephemeris_client import _normalize_datetime
     person = await storage.get_person(person_id=person_id)
     person = await storage.get_person(person_id=person_id)
     if not person:
     if not person:
         person = await storage.get_person(nickname=person_id)
         person = await storage.get_person(nickname=person_id)
     if not person:
     if not person:
         return {"error": f"person not found: {person_id}"}
         return {"error": f"person not found: {person_id}"}
+    utc_dt = _normalize_datetime(
+        person["birth_datetime"],
+        tz_name=person.get("timezone"),
+        lon=person["longitude"],
+    )
     return {
     return {
-        "birth_datetime": person["birth_datetime"],
+        "birth_datetime": utc_dt,
         "birthplace": person.get("birthplace"),
         "birthplace": person.get("birthplace"),
         "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"),
     }
     }
 
 
 
 
@@ -1631,7 +1652,6 @@ 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"),
     )
     )
 
 
 
 

+ 0 - 34
test_ephem.py

@@ -1,34 +0,0 @@
-#!/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())

+ 0 - 106
tests/test_step1_ground_truth.py

@@ -1,106 +0,0 @@
-"""
-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()

+ 0 - 158
tests/test_step2_local_ephemeris.py

@@ -1,158 +0,0 @@
-"""
-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())

+ 0 - 161
tests/test_step4_local_astro.py

@@ -1,161 +0,0 @@
-"""
-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())