Răsfoiți Sursa

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 lună în urmă
părinte
comite
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:
-    """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:
         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:
             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:
-            # Naive, no tz_name — use LMT from longitude
             from datetime import timedelta
-            lmt_offset_hours = lon / 15.0
-            lmt_tz = timezone(timedelta(hours=lmt_offset_hours))
+            lmt_tz = timezone(timedelta(hours=lon / 15.0))
             dt = dt.replace(tzinfo=lmt_tz).astimezone(timezone.utc)
         return dt.strftime("%Y-%m-%dT%H:%M:%S")
     except Exception:
@@ -99,27 +92,23 @@ 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.
+    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
     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, 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()
     if not url.endswith("/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")
 
 
+@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:
     config.LOG_DIR.mkdir(parents=True, exist_ok=True)
     logging.basicConfig(

+ 26 - 6
src/astro_mcp/tools.py

@@ -120,10 +120,12 @@ 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.
 
+    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,
 and angles. Use the optional flags to add interpretation layers.
 
@@ -156,6 +158,8 @@ Args:
 Returns:
     Dict with: input, chart_type, planets, houses, aspects, angles, lunar_phase,
     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(
         datetime=birth_datetime,
         lat=latitude,
@@ -163,7 +167,6 @@ Returns:
         elevation=elevation,
         geocentric=True,
         house_system=house_system,
-        tz=tz,
     )
 
     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]:
-    """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 .ephemeris_client import _normalize_datetime
     person = await storage.get_person(person_id=person_id)
     if not person:
         person = await storage.get_person(nickname=person_id)
     if not person:
         return {"error": f"person not found: {person_id}"}
+    utc_dt = _normalize_datetime(
+        person["birth_datetime"],
+        tz_name=person.get("timezone"),
+        lon=person["longitude"],
+    )
     return {
-        "birth_datetime": person["birth_datetime"],
+        "birth_datetime": utc_dt,
         "birthplace": person.get("birthplace"),
         "latitude": person["latitude"],
         "longitude": person["longitude"],
         "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_karmic=include_karmic,
         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())