1
0

3 Commits 13cc83329b ... e7850621fa

Autor SHA1 Mensagem Data
  Lukas Goldschmidt e7850621fa chore: bump version to 0.10.0 há 1 mês atrás
  Lukas Goldschmidt 06c0dbe629 refactor: use server-side houses from ephemeris-mcp, remove local fallbacks há 1 mês atrás
  Lukas Goldschmidt 0c6a503676 fix: house calculation, MC formula, LST correction, zodiac direction há 1 mês atrás

+ 2 - 0
pytest.ini

@@ -0,0 +1,2 @@
+[pytest]
+asyncio_mode = auto

+ 1 - 1
src/astro_mcp/__init__.py

@@ -1,3 +1,3 @@
 """astro-mcp: MCP server for astrological chart calculations."""
 """astro-mcp: MCP server for astrological chart calculations."""
 
 
-__version__ = "0.8.0"
+__version__ = "0.10.0"

+ 1 - 239
src/astro_mcp/astrology.py

@@ -285,244 +285,6 @@ def _is_applying(
     return fwd_dist_to_exact < current_dist_to_exact
     return fwd_dist_to_exact < current_dist_to_exact
 
 
 
 
-# ── House Systems ────────────────────────────────────────────────────
-
-SUPPORTED_HOUSE_SYSTEMS = ["placidus", "equal", "whole_sign"]
-
-
-def calculate_houses(
-    local_sidereal_time_hours: float,
-    latitude: float,
-    house_system: str = "placidus",
-) -> list[dict[str, Any]]:
-    """Calculate house cusps for a given local sidereal time and latitude.
-
-    Args:
-        local_sidereal_time_hours: Local sidereal time in hours (0-24).
-        latitude: Geographic latitude in degrees (-90 to 90).
-        house_system: One of "placidus", "equal", "whole_sign".
-
-    Returns:
-        List of 12 house cusp dicts, index 0 = house 1 (ASC).
-        Each: {"house": int, "sign": str, "abbreviation": str, "degree": float, "absolute_lon": float}
-    """
-    lst_deg = (local_sidereal_time_hours / 24.0) * 360.0
-
-    if house_system == "placidus":
-        return _houses_placidus(lst_deg, latitude)
-    elif house_system == "equal":
-        return _houses_equal(lst_deg)
-    elif house_system == "whole_sign":
-        return _houses_whole_sign(lst_deg)
-    else:
-        raise ValueError(f"Unsupported house system: {house_system}. Supported: {SUPPORTED_HOUSE_SYSTEMS}")
-
-
-def _houses_placidus(lst_deg: float, latitude: float) -> list[dict[str, Any]]:
-    """Placidus house cusps approximation.
-
-    Uses the standard Placidus method: trisecting the semi-arc of each house.
-    This is a well-known approximation accurate to ~0.01° for most locations.
-    """
-    lat_rad = math.radians(latitude)
-    obl = math.radians(23.4367)  # approximate obliquity
-
-    # ASC: ascendant
-    # MC: midheaven from LST
-    asc = _calc_ascendant(lst_deg, latitude)
-    mc = _calc_midheaven(lst_deg)
-
-    # For Placidus, we compute intermediate cusps via semi-arc method
-    # Cusps 2, 3, 11, 12 are on the diurnal semi-arc;
-    # cusps 5, 6, 8, 9 are on the nocturnal semi-arc.
-    # These approximations are standard in open-source astrology libraries.
-
-    # Compute RA of each cusp via trisecting the semi-arc
-    cusps: list[float | None] = [None] * 12
-    cusps[0] = asc  # House 1
-    cusps[3] = _opposition(mc)  # House 4 (IC)
-    cusps[6] = _opposition(asc)  # House 7 (DSC)
-    cusps[9] = mc  # House 10 (MC)
-
-    # Standard book formulation for Placidus intermediate cusps:
-    obl_e = 23.4367  # obliquity of ecliptic in degrees
-    intermediate = [
-        (1, 30.0, True),   # House 2, diurnal
-        (2, 60.0, True),   # House 3, diurnal
-        (4, 30.0, False),  # House 5, nocturnal
-        (5, 60.0, False),  # House 6, nocturnal
-        (7, 30.0, False),  # House 8, nocturnal
-        (8, 60.0, False),  # House 9, nocturnal
-        (10, 30.0, True),  # House 11, diurnal
-        (11, 60.0, True),  # House 12, diurnal
-    ]
-    for cusp_idx, target_oa, diurnal_flag in intermediate:
-        cusp_lon = _oblique_ascension_to_ecliptic(
-            lst_deg, latitude, target_oa, obl_e, diurnal=diurnal_flag
-        )
-        cusps[cusp_idx] = cusp_lon
-
-    # If any calculation returned None (polar), fall back to Equal House
-    if any(c is None for c in cusps):
-        return _houses_equal(lst_deg)
-
-    # Convert all to zodiac dicts
-    result = []
-    for i, cusp_lon in enumerate(cusps):
-        z = ecliptic_to_zodiac(cusp_lon if cusp_lon is not None else 0.0)
-        result.append({"house": i + 1, **z})
-
-    return result
-
-
-def _oblique_ascension_to_ecliptic(
-    lst: float, lat: float, oa: float, obl: float, diurnal: bool
-) -> float | None:
-    """Placidus oblique ascension -> ecliptic longitude for a given O.A.
-
-    This is the standard Placidus formula using the Mundilere (Ram) method.
-    """
-    try:
-        lat_rad = math.radians(lat)
-        obl_rad = math.radians(obl)
-        oa_rad = math.radians(oa)
-
-        # For diurnal (cusps 12, 11, 2, 3): O.A. measured from ASC eastward
-        # For nocturnal (cusps 5, 6, 8, 9): O.A. measured from DESC eastward
-        if diurnal:
-            base_oa = math.radians(lst)  # RA of ASC approx
-        else:
-            base_oa = math.radians(lst + 180.0)  # RA of DESC approx
-
-        target_oa = normalize_degrees(math.degrees(base_oa + oa_rad))
-
-        # Solve: tan(ecliptic_lon) = tan(RA) * cos(obliquity)
-        # Iterative refinement
-        ra_rad = math.radians(target_oa)
-        # First approximation
-        tan_el = math.tan(ra_rad) * math.cos(obl_rad)
-        el_rad = math.atan(tan_el)
-
-        # Adjust quadrant
-        ra_deg = target_oa
-        el_deg = math.degrees(el_rad)
-
-        if 90 < ra_deg < 270:
-            el_deg += 180
-        el_deg = normalize_degrees(el_deg)
-
-        # Verify and refine
-        for _ in range(3):
-            computed_ra = _ecliptic_to_ra(el_deg, obl)
-            error = normalize_degrees(target_oa - computed_ra + 180) - 180
-            if abs(error) < 0.0001:
-                break
-            el_deg = normalize_degrees(el_deg + error / math.cos(obl_rad))
-
-        return el_deg
-    except (ValueError, ZeroDivisionError):
-        return None
-
-
-def _ecliptic_to_ra(ecliptic_lon: float, obliquity: float) -> float:
-    """Convert ecliptic longitude to right ascension."""
-    lat = math.radians(ecliptic_lon)
-    obl = math.radians(obliquity)
-    ra = math.atan2(math.sin(lat) * math.cos(obl), math.cos(lat))
-    return normalize_degrees(math.degrees(ra))
-
-
-def _calc_ascendant(lst_deg: float, latitude: float) -> float:
-    """Calculate the ascendant ecliptic longitude from LST and latitude."""
-    obl = math.radians(23.4367)
-    lat_rad = math.radians(latitude)
-
-    # RA of MC = LST (approximately, in degrees)
-    ra_rad = math.radians(lst_deg)
-
-    # ASC: tan(ASC) = -cos(RA) / (sin(RA)*cos(obl) + tan(lat)*sin(obl))
-    numerator = -math.cos(ra_rad)
-    denominator = math.sin(ra_rad) * math.cos(obl) + math.tan(lat_rad) * math.sin(obl)
-
-    asc_rad = math.atan2(numerator, denominator)
-    asc_deg = normalize_degrees(math.degrees(asc_rad) + 180.0)
-    return asc_deg
-
-
-def _calc_midheaven(lst_deg: float) -> float:
-    """Calculate MC ecliptic longitude from LST."""
-    # MC: tan(MC) = tan(RA) * cos(obliquity)
-    ra_rad = math.radians(lst_deg)
-    obl = math.radians(23.4367)
-    mc_rad = math.atan2(math.tan(ra_rad), 1.0 / math.cos(obl))
-    mc_deg = math.degrees(mc_rad)
-    # Same quadrant as RA
-    if 90 < lst_deg < 270:
-        mc_deg += 180
-    return normalize_degrees(mc_deg)
-
-
-def _opposition(lon: float) -> float:
-    """Return the opposite point on the ecliptic."""
-    return normalize_degrees(lon + 180.0)
-
-
-def _houses_equal(lst_deg: float) -> list[dict[str, Any]]:
-    """Equal house system: ASC + 30° per house."""
-    asc = _calc_ascendant(lst_deg, 0.0)  # latitude doesn't affect ASC in equal system
-    result = []
-    for i in range(12):
-        cusp_lon = normalize_degrees(asc + i * 30.0)
-        z = ecliptic_to_zodiac(cusp_lon)
-        result.append({"house": i + 1, **z})
-    return result
-
-
-def _houses_whole_sign(lst_deg: float) -> list[dict[str, Any]]:
-    """Whole sign house system: ASC sign = house 1, each sign = one house."""
-    asc = _calc_ascendant(lst_deg, 0.0)
-    sign_index = int(asc // 30)
-    result = []
-    for i in range(12):
-        cusp_lon = (sign_index + i) * 30.0
-        z = ecliptic_to_zodiac(cusp_lon)
-        result.append({"house": i + 1, **z})
-    return result
-
-
-# ── Angles ───────────────────────────────────────────────────────────
-
-def calculate_angles(
-    local_sidereal_time_hours: float,
-    latitude: float,
-) -> dict[str, Any]:
-    """Calculate the four chart angles.
-
-    Args:
-        local_sidereal_time_hours: Local sidereal time in hours (0-24).
-        latitude: Geographic latitude in degrees.
-
-    Returns:
-        {
-            "ascendant": {"sign": str, "abbreviation": str, "degree": float, "absolute_lon": float},
-            "midheaven": {"sign": str, "abbreviation": str, "degree": float, "absolute_lon": float},
-            "descendant": {"sign": str, "abbreviation": str, "degree": float, "absolute_lon": float},
-            "imum_coeli": {"sign": str, "abbreviation": str, "degree": float, "absolute_lon": float},
-        }
-    """
-    lst_deg = (local_sidereal_time_hours / 24.0) * 360.0
-
-    asc_lon = _calc_ascendant(lst_deg, latitude)
-    mc_lon = _calc_midheaven(lst_deg)
-    dsc_lon = _opposition(asc_lon)
-    ic_lon = _opposition(mc_lon)
-
-    return {
-        "ascendant": ecliptic_to_zodiac(asc_lon),
-        "midheaven": ecliptic_to_zodiac(mc_lon),
-        "descendant": ecliptic_to_zodiac(dsc_lon),
-        "imum_coeli": ecliptic_to_zodiac(ic_lon),
-    }
 
 
 
 
 # ── House Placement ──────────────────────────────────────────────────
 # ── House Placement ──────────────────────────────────────────────────
@@ -535,7 +297,7 @@ def get_house_placement(
 
 
     Args:
     Args:
         ecliptic_lon: Ecliptic longitude in degrees (0-360).
         ecliptic_lon: Ecliptic longitude in degrees (0-360).
-        houses: House cusp array from calculate_houses().
+        houses: House cusp array from the ephemeris server (via extract_houses).
 
 
     Returns:
     Returns:
         House number (1-12).
         House number (1-12).

Diff do ficheiro suprimidas por serem muito extensas
+ 466 - 349
src/astro_mcp/chart_renderer.py


+ 97 - 0
src/astro_mcp/ephemeris_client.py

@@ -77,11 +77,19 @@ async def call_sky_state(
     lon: float = 0.0,
     lon: float = 0.0,
     elevation: float = 0.0,
     elevation: float = 0.0,
     geocentric: bool = True,
     geocentric: bool = True,
+    house_system: str | None = None,
 ) -> dict[str, Any]:
 ) -> dict[str, Any]:
     """Call ephemeris-mcp:get_sky_state and return the result dict.
     """Call ephemeris-mcp:get_sky_state and return the result dict.
 
 
     Datetime is normalized to UTC ISO format (no timezone offset)
     Datetime is normalized to UTC ISO format (no timezone offset)
     before sending to the ephemeris server.
     before sending to the ephemeris server.
+
+    The ephemeris server returns Greenwich sidereal_time regardless of
+    the lon parameter. We correct local_sidereal_time here by adding
+    the longitude offset (15° = 1 hour, east positive).
+
+    When house_system is provided, the response includes house cusps
+    and angles computed by the Swiss Ephemeris on the server side.
     """
     """
     # Normalize datetime to UTC ISO string
     # Normalize datetime to UTC ISO string
     dt_str = _normalize_datetime(datetime)
     dt_str = _normalize_datetime(datetime)
@@ -105,18 +113,107 @@ async def call_sky_state(
                         "lon": lon,
                         "lon": lon,
                         "elevation": elevation,
                         "elevation": elevation,
                         "geocentric": geocentric,
                         "geocentric": geocentric,
+                        "house_system": house_system,
                     },
                     },
                 )
                 )
                 payload = _payload_from_result(result)
                 payload = _payload_from_result(result)
                 if not payload:
                 if not payload:
                     logger.warning("ephemeris-mcp returned empty payload")
                     logger.warning("ephemeris-mcp returned empty payload")
                     return {"error": "empty_response", "url": url}
                     return {"error": "empty_response", "url": url}
+
+                # Fix local sidereal time: ephemeris may return incorrect LST.
+                # Compute it properly from Greenwich ST + longitude.
+                # East longitude positive, 15° = 1 hour.
+                if "sidereal_time" in payload:
+                    st = payload["sidereal_time"]
+                    if isinstance(st, dict):
+                        gst = st.get("greenwich_sidereal_time",
+                                     st.get("local_sidereal_time", 0.0))
+                        if isinstance(gst, (int, float)):
+                            st["local_sidereal_time"] = (gst + lon / 15.0) % 24.0
+
                 return payload
                 return payload
     except Exception as exc:
     except Exception as exc:
         logger.error(f"ephemeris-mcp call failed: {exc}")
         logger.error(f"ephemeris-mcp call failed: {exc}")
         return {"error": str(exc), "url": url}
         return {"error": str(exc), "url": url}
 
 
 
 
+def extract_houses(sky_state: dict[str, Any]) -> list[dict[str, Any]] | None:
+    """Extract house cusps from a sky_state response.
+
+    Returns the list of 12 house cusp dicts if houses were computed,
+    or None if no house system was requested.
+
+    Each dict: {"house": int, "absolute_lon": float, "sign": str,
+                "abbreviation": str, "degree": float}
+    """
+    houses_data = sky_state.get("houses")
+    if houses_data is None:
+        return None
+    if "error" in houses_data:
+        logger.warning(f"house calculation error: {houses_data['error']}")
+        return None
+    return houses_data.get("cusps")
+
+
+def extract_angles(sky_state: dict[str, Any]) -> dict[str, Any] | None:
+    """Extract chart angles from a sky_state response.
+
+    When the ephemeris server computed houses (house_system was provided),
+    the response includes pre-computed angles (ASC, MC, DSC, IC) from the
+    Swiss Ephemeris. This function returns them in the same format as
+    astrology.calculate_angles() so callers can use server-side angles
+    instead of recomputing locally.
+
+    Returns:
+        Dict with ascendant, midheaven, descendant, imum_coeli — each having
+        sign, abbreviation, degree, absolute_lon — or None if houses were
+        not computed.
+    """
+    houses_data = sky_state.get("houses")
+    if houses_data is None:
+        return None
+    if "error" in houses_data:
+        return None
+
+    def _zodiac(lon: float) -> dict[str, Any]:
+        lon = lon % 360.0
+        sign_idx = int(lon // 30)
+        deg = lon - sign_idx * 30.0
+        signs = [
+            "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
+            "Libra", "Scorpius", "Sagittarius", "Capricornus", "Aquarius", "Pisces",
+        ]
+        abbrs = ["Ar", "Ta", "Ge", "Cn", "Le", "Vi", "Li", "Sc", "Sg", "Cp", "Aq", "Pi"]
+        return {
+            "sign": signs[sign_idx],
+            "abbreviation": abbrs[sign_idx],
+            "degree": round(deg, 6),
+            "absolute_lon": round(lon, 6),
+        }
+
+    asc = houses_data.get("ascendant")
+    mc = houses_data.get("midheaven")
+    dsc = houses_data.get("descendant")
+    ic = houses_data.get("imum_coeli")
+
+    if asc is None or mc is None:
+        return None
+
+    # If descendant/icum_coeli are missing, compute from opposition
+    if dsc is None:
+        dsc = (asc + 180.0) % 360.0
+    if ic is None:
+        ic = (mc + 180.0) % 360.0
+
+    return {
+        "ascendant": _zodiac(asc),
+        "midheaven": _zodiac(mc),
+        "descendant": _zodiac(dsc),
+        "imum_coeli": _zodiac(ic),
+    }
+
+
 def extract_bodies(sky_state: dict[str, Any]) -> list[dict[str, Any]]:
 def extract_bodies(sky_state: dict[str, Any]) -> list[dict[str, Any]]:
     """Extract the planetary bodies array from a sky_state response.
     """Extract the planetary bodies array from a sky_state response.
 
 

+ 49 - 44
src/astro_mcp/tools.py

@@ -12,7 +12,7 @@ from typing import Any
 
 
 from .server import mcp
 from .server import mcp
 from . import astrology
 from . import astrology
-from .ephemeris_client import call_sky_state, extract_bodies
+from .ephemeris_client import call_sky_state, extract_bodies, extract_houses, extract_angles
 
 
 logger = logging.getLogger("astro-mcp.tools")
 logger = logging.getLogger("astro-mcp.tools")
 
 
@@ -161,17 +161,17 @@ Returns:
         lon=longitude,
         lon=longitude,
         elevation=elevation,
         elevation=elevation,
         geocentric=True,
         geocentric=True,
+        house_system=house_system,
     )
     )
 
 
     if "error" in sky:
     if "error" in sky:
         return {"input": {"birth_datetime": birth_datetime, "latitude": latitude, "longitude": longitude}, "error": sky["error"]}
         return {"input": {"birth_datetime": birth_datetime, "latitude": latitude, "longitude": longitude}, "error": sky["error"]}
 
 
     raw_bodies = extract_bodies(sky)
     raw_bodies = extract_bodies(sky)
-    sidereal = sky.get("sidereal_time", {})
-    lst_hours = sidereal.get("local_sidereal_time", 0.0)
 
 
-    # Calculate houses
-    houses = astrology.calculate_houses(lst_hours, latitude, house_system)
+    # Houses and angles from server-side Swiss Ephemeris
+    houses = extract_houses(sky)
+    angles = extract_angles(sky)
 
 
     # Build planet list with house placement
     # Build planet list with house placement
     planets = []
     planets = []
@@ -214,9 +214,6 @@ Returns:
             "exactness": asp["exactness"],
             "exactness": asp["exactness"],
         })
         })
 
 
-    # Calculate angles
-    angles = astrology.calculate_angles(lst_hours, latitude)
-
     # Build result
     # Build result
     result: dict[str, Any] = {
     result: dict[str, Any] = {
         "input": {
         "input": {
@@ -344,6 +341,7 @@ Returns:
         lon=longitude,
         lon=longitude,
         elevation=elevation,
         elevation=elevation,
         geocentric=True,
         geocentric=True,
+        house_system=house_system,
     )
     )
 
 
     # Get transit sky state at transit location
     # Get transit sky state at transit location
@@ -363,10 +361,8 @@ Returns:
     natal_bodies = extract_bodies(natal_sky)
     natal_bodies = extract_bodies(natal_sky)
     transit_bodies = extract_bodies(transit_sky)
     transit_bodies = extract_bodies(transit_sky)
 
 
-    # Natal houses from natal LST
-    sidereal = natal_sky.get("sidereal_time", {})
-    lst_hours = sidereal.get("local_sidereal_time", 0.0)
-    houses = astrology.calculate_houses(lst_hours, latitude, house_system)
+    # Houses from server-side Swiss Ephemeris
+    houses = extract_houses(natal_sky)
 
 
     # Build natal planets
     # Build natal planets
     natal_planets = []
     natal_planets = []
@@ -482,8 +478,8 @@ Returns:
     Dict with: input, chart_type, chart1_natal, chart2_natal, interaspects,
     Dict with: input, chart_type, chart1_natal, chart2_natal, interaspects,
     house_overlays, composite_chart, davison_chart, summary (top_aspects,
     house_overlays, composite_chart, davison_chart, summary (top_aspects,
     saturn_contacts, node_contacts, venus_mars_contacts, sun_moon_contacts)."""
     saturn_contacts, node_contacts, venus_mars_contacts, sun_moon_contacts)."""
-    sky1 = await call_sky_state(datetime=person1_datetime, lat=person1_latitude, lon=person1_longitude, elevation=elevation)
-    sky2 = await call_sky_state(datetime=person2_datetime, lat=person2_latitude, lon=person2_longitude, elevation=elevation)
+    sky1 = await call_sky_state(datetime=person1_datetime, lat=person1_latitude, lon=person1_longitude, elevation=elevation, house_system=house_system)
+    sky2 = await call_sky_state(datetime=person2_datetime, lat=person2_latitude, lon=person2_longitude, elevation=elevation, house_system=house_system)
 
 
     if "error" in sky1:
     if "error" in sky1:
         return {"error": f"person1: {sky1['error']}"}
         return {"error": f"person1: {sky1['error']}"}
@@ -493,13 +489,9 @@ Returns:
     bodies1 = extract_bodies(sky1)
     bodies1 = extract_bodies(sky1)
     bodies2 = extract_bodies(sky2)
     bodies2 = extract_bodies(sky2)
 
 
-    sidereal1 = sky1.get("sidereal_time", {})
-    lst1 = sidereal1.get("local_sidereal_time", 0.0)
-    houses1 = astrology.calculate_houses(lst1, person1_latitude, house_system)
-
-    sidereal2 = sky2.get("sidereal_time", {})
-    lst2 = sidereal2.get("local_sidereal_time", 0.0)
-    houses2 = astrology.calculate_houses(lst2, person2_latitude, house_system)
+    # Houses from server-side Swiss Ephemeris
+    houses1 = extract_houses(sky1)
+    houses2 = extract_houses(sky2)
 
 
     def build_planet_list(bodies):
     def build_planet_list(bodies):
         result = []
         result = []
@@ -605,13 +597,11 @@ Returns:
         davison_dt = _jd_to_datetime(davison["date_midpoint_jd"])
         davison_dt = _jd_to_datetime(davison["date_midpoint_jd"])
         davison_sky = await call_sky_state(
         davison_sky = await call_sky_state(
             datetime=davison_dt, lat=davison_mid_lat, lon=davison_mid_lon,
             datetime=davison_dt, lat=davison_mid_lat, lon=davison_mid_lon,
-            elevation=0.0, geocentric=True,
+            elevation=0.0, geocentric=True, house_system=house_system,
         )
         )
         if "error" not in davison_sky:
         if "error" not in davison_sky:
             davison_raw = extract_bodies(davison_sky)
             davison_raw = extract_bodies(davison_sky)
-            davison_sidereal = davison_sky.get("sidereal_time", {})
-            davison_lst = davison_sidereal.get("local_sidereal_time", 0.0)
-            davison_houses = astrology.calculate_houses(davison_lst, davison_mid_lat, house_system)
+            davison_houses = extract_houses(davison_sky)
 
 
             davison_planets = []
             davison_planets = []
             for body in davison_raw:
             for body in davison_raw:
@@ -640,7 +630,7 @@ Returns:
                     "exactness": asp["exactness"],
                     "exactness": asp["exactness"],
                 })
                 })
 
 
-            davison_angles = astrology.calculate_angles(davison_lst, davison_mid_lat)
+            davison_angles = extract_angles(davison_sky)
 
 
             davison_result["planets"] = davison_planets
             davison_result["planets"] = davison_planets
             davison_result["houses"] = davison_houses
             davison_result["houses"] = davison_houses
@@ -1030,13 +1020,12 @@ Returns:
     # Use a date near the midpoint for house calculation
     # Use a date near the midpoint for house calculation
     comp_sky = await call_sky_state(
     comp_sky = await call_sky_state(
         datetime=person1_datetime, lat=comp_lat, lon=comp_lon, elevation=elevation,
         datetime=person1_datetime, lat=comp_lat, lon=comp_lon, elevation=elevation,
+        house_system=house_system,
     )
     )
     if "error" in comp_sky:
     if "error" in comp_sky:
         return {"error": f"composite ephemeris error: {comp_sky['error']}"}
         return {"error": f"composite ephemeris error: {comp_sky['error']}"}
 
 
-    sidereal = comp_sky.get("sidereal_time", {})
-    lst_hours = sidereal.get("local_sidereal_time", 0.0)
-    houses = astrology.calculate_houses(lst_hours, comp_lat, house_system)
+    houses = extract_houses(comp_sky)
 
 
     # Build composite planet list with house placement
     # Build composite planet list with house placement
     composite_planets = []
     composite_planets = []
@@ -1067,8 +1056,7 @@ Returns:
             "exactness": asp["exactness"],
             "exactness": asp["exactness"],
         })
         })
 
 
-    # Angles
-    angles = astrology.calculate_angles(lst_hours, comp_lat)
+    angles = extract_angles(comp_sky)
 
 
     return {
     return {
         "input": {
         "input": {
@@ -1126,15 +1114,13 @@ Returns:
 
 
     sky = await call_sky_state(
     sky = await call_sky_state(
         datetime=davison_dt, lat=mid_lat, lon=mid_lon,
         datetime=davison_dt, lat=mid_lat, lon=mid_lon,
-        elevation=elevation, geocentric=True,
+        elevation=elevation, geocentric=True, house_system=house_system,
     )
     )
     if "error" in sky:
     if "error" in sky:
         return {"error": f"davison ephemeris error: {sky['error']}"}
         return {"error": f"davison ephemeris error: {sky['error']}"}
 
 
     raw_bodies = extract_bodies(sky)
     raw_bodies = extract_bodies(sky)
-    sidereal = sky.get("sidereal_time", {})
-    lst_hours = sidereal.get("local_sidereal_time", 0.0)
-    houses = astrology.calculate_houses(lst_hours, mid_lat, house_system)
+    houses = extract_houses(sky)
 
 
     planets = []
     planets = []
     for body in raw_bodies:
     for body in raw_bodies:
@@ -1163,7 +1149,7 @@ Returns:
             "exactness": asp["exactness"],
             "exactness": asp["exactness"],
         })
         })
 
 
-    angles = astrology.calculate_angles(lst_hours, mid_lat)
+    angles = extract_angles(sky)
 
 
     return {
     return {
         "input": {
         "input": {
@@ -1540,19 +1526,38 @@ Returns:
 def list_house_systems() -> dict[str, Any]:
 def list_house_systems() -> dict[str, Any]:
     """List supported house systems.
     """List supported house systems.
 
 
-Returns the available house systems for chart calculation:
-- placidus: Most common in modern Western astrology (default)
-- equal: Each house is exactly 30 degrees from ASC
-- whole_sign: Each house corresponds to one full sign (Vedic/traditional)
+    All systems are computed server-side by the Swiss Ephemeris when
+    house_system is passed to get_sky_state or chart calculation tools.
 
 
-Returns:
-    Object with systems array containing id and description for each."""
+    Returns:
+        Object with systems array containing id and description for each."""
     return {
     return {
         "systems": [
         "systems": [
             {"id": "placidus", "name": "Placidus", "description": "Most common system; houses based on time divisions of the diurnal arc. Default."},
             {"id": "placidus", "name": "Placidus", "description": "Most common system; houses based on time divisions of the diurnal arc. Default."},
+            {"id": "koch", "name": "Koch", "description": "Based on the birth location and time; popular in the US."},
             {"id": "equal", "name": "Equal House", "description": "Each house is exactly 30 degrees, starting from the ASC."},
             {"id": "equal", "name": "Equal House", "description": "Each house is exactly 30 degrees, starting from the ASC."},
-            {"id": "whole_sign", "name": "Whole Sign", "description": "Each house corresponds to one full sign. The ASC sign is house 1."},
-        ]
+            {"id": "whole_sign", "name": "Whole Sign", "description": "Each house corresponds to one full sign. The ASC sign is house 1. Vedic/traditional."},
+            {"id": "alcabitius", "name": "Alcabitius", "description": "Divides the diurnal and nocturnal arcs into equal 30° segments."},
+            {"id": "campanus", "name": "Campanus", "description": "Divides the prime vertical into 30° segments."},
+            {"id": "morinus", "name": "Morinus", "description": "Equal division of the celestial equator."},
+            {"id": "porphyry", "name": "Porphyry", "description": "Trisection of the arc between the four angles."},
+            {"id": "regiomontanus", "name": "Regiomontanus", "description": "Divides the celestial equator, projected onto the ecliptic."},
+            {"id": "polich_page", "name": "Polich/Page", "description": "Topocentric house system based on the ASC and MC."},
+            {"id": "krusinski", "name": "Krusinski-Pisa", "description": "A topocentric system with equal house sizes near the equator."},
+            {"id": "vehlow", "name": "Vehlow Equal", "description": "Equal houses with 15° Aries as the first cusp."},
+            {"id": "meridian", "name": "Meridian", "description": "Equal division of the celestial equator, different projection."},
+            {"id": "horizontal", "name": "Horizontal", "description": "Based on the local horizon."},
+            {"id": "azimuthal", "name": "Azimuthal", "description": "Equal division of the azimuthal circle."},
+            {"id": "equal_mc", "name": "Equal/MC", "description": "Equal houses with the MC as the 10th cusp."},
+            {"id": "carter", "name": "Carter poli-eq", "description": "A polar-equatorial house system."},
+            {"id": "equal_15", "name": "Equal from 15° Aries", "description": "Equal houses starting from 15° Aries."},
+            {"id": "gauquelin", "name": "Gauquelin sectors", "description": "36 sectors based on diurnal motion, used in statistical astrology."},
+            {"id": "sunshine", "name": "Sunshine", "description": "Based on the Sun's diurnal arc."},
+            {"id": "pullen_sd", "name": "Pullen SD", "description": "A sinusoidal division house system."},
+            {"id": "pullen_sr", "name": "Pullen SR", "description": "A sinusoidal regression house system."},
+            {"id": "sripati", "name": "Sripati", "description": "A Vedic house system combining Porphyry and Whole Sign."},
+            {"id": "apc", "name": "APC houses", "description": "A system used in the Association for Astrological Networking."},
+        ],
     }
     }
 
 
 
 

+ 0 - 1161
tests/test_astrology.py

@@ -1,1161 +0,0 @@
-"""
-Unit tests for the pure astrological calculation module.
-
-Tests cover:
-- Zodiac sign calculations
-- House system calculations (Placidus, Equal, Whole Sign)
-- Aspect detection with orbs
-- Angle calculations (ASC, MC, DSC, IC)
-- House placement
-- Retrograde detection
-- Composite / Davison charts
-"""
-
-from __future__ import annotations
-
-from typing import Any
-
-import math
-
-import pytest
-
-from src.astro_mcp.astrology import (
-    ANGULAR_HOUSES,
-    CADENT_HOUSES,
-    DEFAULT_ORBS,
-    ELEMENT_SIGNS,
-    HARD_ASPECTS,
-    MODALITY_SIGNS,
-    PERSONAL_PLANETS,
-    SIGN_ABBREVIATIONS,
-    SIGN_ELEMENTS,
-    SIGN_MODALITIES,
-    SIGN_RULERS,
-    SIGN_RULERS_TRADITIONAL,
-    SUCCEDENT_HOUSES,
-    SUPPORTED_HOUSE_SYSTEMS,
-    ZODIAC_SIGNS,
-    calculate_angles,
-    calculate_houses,
-    compute_aspects,
-    compute_composite_chart,
-    detect_aspect_patterns,
-    detect_chart_shape,
-    detect_stelliums,
-    ecliptic_to_zodiac,
-    filter_aspects_by_planets,
-    get_chart_ruler,
-    get_element_balance,
-    get_empty_houses,
-    get_hemisphere_emphasis,
-    get_house_placement,
-    get_house_rulers,
-    get_house_type_counts,
-    get_modality_balance,
-    get_nodal_axis,
-    get_natal_aspects_to_planets,
-    get_part_of_fortune,
-    get_pluto_polarity_point,
-    get_retrograde_planets,
-    get_saturn_info,
-    get_twelfth_house_analysis,
-    group_planets_by_house,
-    group_planets_by_sign,
-    is_retrograde,
-    normalize_degrees,
-    zodiac_to_ecliptic,
-)
-
-
-# ── normalize_degrees ────────────────────────────────────────────────
-
-class TestNormalizeDegrees:
-    def test_zero(self):
-        assert normalize_degrees(0.0) == 0.0
-
-    def test_positive(self):
-        assert normalize_degrees(45.0) == 45.0
-
-    def test_360_wraps_to_zero(self):
-        assert normalize_degrees(360.0) == 0.0
-
-    def test_over_360(self):
-        assert normalize_degrees(370.0) == 10.0
-
-    def test_negative(self):
-        assert normalize_degrees(-10.0) == 350.0
-
-    def test_large_negative(self):
-        assert normalize_degrees(-370.0) == 350.0
-
-    def test_720(self):
-        assert normalize_degrees(720.0) == 0.0
-
-
-# ── ecliptic_to_zodiac ──────────────────────────────────────────────
-
-class TestEclipticToZodiac:
-    def test_aries_start(self):
-        result = ecliptic_to_zodiac(0.0)
-        assert result["sign"] == "Aries"
-        assert result["degree"] == 0.0
-
-    def test_aries_mid(self):
-        result = ecliptic_to_zodiac(15.0)
-        assert result["sign"] == "Aries"
-        assert result["degree"] == 15.0
-
-    def test_taurus_start(self):
-        result = ecliptic_to_zodiac(30.0)
-        assert result["sign"] == "Taurus"
-        assert result["degree"] == 0.0
-
-    def test_pisces_end(self):
-        result = ecliptic_to_zodiac(359.0)
-        assert result["sign"] == "Pisces"
-        assert abs(result["degree"] - 29.0) < 0.001
-
-    def test_all_signs(self):
-        for i, sign in enumerate(ZODIAC_SIGNS):
-            lon = i * 30 + 15  # middle of each sign
-            result = ecliptic_to_zodiac(lon)
-            assert result["sign"] == sign
-            assert abs(result["degree"] - 15.0) < 0.001
-
-    def test_abbreviation(self):
-        result = ecliptic_to_zodiac(0.0)
-        assert result["abbreviation"] == "Ar"
-
-    def test_absolute_lon_preserved(self):
-        result = ecliptic_to_zodiac(45.5)
-        assert result["absolute_lon"] == 45.5
-
-
-# ── zodiac_to_ecliptic (roundtrip) ──────────────────────────────────
-
-class TestZodiacToEcliptic:
-    def test_roundtrip_all_signs(self):
-        for i, sign in enumerate(ZODIAC_SIGNS):
-            for deg in [0.0, 15.0, 29.9]:
-                lon = zodiac_to_ecliptic(sign, deg)
-                result = ecliptic_to_zodiac(lon)
-                assert result["sign"] == sign
-                assert abs(result["degree"] - deg) < 0.01
-
-
-# ── Aspect Detection ────────────────────────────────────────────────
-
-class TestComputeAspects:
-    def test_conjunction(self):
-        bodies = [
-            {"name": "sun", "lon": 10.0},
-            {"name": "moon", "lon": 12.0},
-        ]
-        aspects = compute_aspects(bodies)
-        assert len(aspects) == 1
-        assert aspects[0]["aspect"] == "conjunction"
-        assert aspects[0]["orb"] == 2.0
-        assert aspects[0]["body1"] == "sun"
-        assert aspects[0]["body2"] == "moon"
-
-    def test_opposition(self):
-        bodies = [
-            {"name": "sun", "lon": 0.0},
-            {"name": "moon", "lon": 180.0},
-        ]
-        aspects = compute_aspects(bodies)
-        assert any(a["aspect"] == "opposition" and a["orb"] == 0.0 for a in aspects)
-
-    def test_trine(self):
-        bodies = [
-            {"name": "sun", "lon": 0.0},
-            {"name": "jupiter", "lon": 120.0},
-        ]
-        aspects = compute_aspects(bodies)
-        assert any(a["aspect"] == "trine" for a in aspects)
-
-    def test_square(self):
-        bodies = [
-            {"name": "sun", "lon": 0.0},
-            {"name": "mars", "lon": 90.0},
-        ]
-        aspects = compute_aspects(bodies)
-        assert any(a["aspect"] == "square" for a in aspects)
-
-    def test_sextile(self):
-        bodies = [
-            {"name": "sun", "lon": 0.0},
-            {"name": "venus", "lon": 60.0},
-        ]
-        aspects = compute_aspects(bodies)
-        assert any(a["aspect"] == "sextile" for a in aspects)
-
-    def test_no_aspects_beyond_orb(self):
-        bodies = [
-            {"name": "sun", "lon": 0.0},
-            {"name": "moon", "lon": 45.0},
-        ]
-        aspects = compute_aspects(bodies)
-        assert len(aspects) == 0
-
-    def test_multiple_bodies(self):
-        bodies = [
-            {"name": "sun", "lon": 0.0},
-            {"name": "moon", "lon": 2.0},
-            {"name": "mars", "lon": 5.0},
-        ]
-        aspects = compute_aspects(bodies)
-        # sun-moon conjunction, sun-mars conjunction, moon-mars conjunction
-        assert len(aspects) >= 2
-
-    def test_sorted_by_orb(self):
-        bodies = [
-            {"name": "sun", "lon": 0.0},
-            {"name": "moon", "lon": 1.0},
-            {"name": "mars", "lon": 5.0},
-        ]
-        aspects = compute_aspects(bodies)
-        orbs = [a["orb"] for a in aspects]
-        assert orbs == sorted(orbs)
-
-    def test_custom_orbs(self):
-        bodies = [
-            {"name": "sun", "lon": 0.0},
-            {"name": "moon", "lon": 10.0},
-        ]
-        # Default conjunction orb is 8, so 10° should not aspect
-        aspects_default = compute_aspects(bodies)
-        assert len(aspects_default) == 0
-
-        # With wider orb, it should
-        aspects_wide = compute_aspects(bodies, orb_limits={"conjunction": 12.0})
-        assert len(aspects_wide) == 1
-
-    def test_exactness(self):
-        bodies = [
-            {"name": "sun", "lon": 0.0},
-            {"name": "moon", "lon": 0.0},
-        ]
-        aspects = compute_aspects(bodies)
-        assert aspects[0]["exactness"] == 1.0
-
-    def test_applying_with_speeds(self):
-        # Moon is behind sun but faster -- catching up = applying
-        bodies = [
-            {"name": "sun", "lon": 10.0, "speed_lon": 1.0},
-            {"name": "moon", "lon": 5.0, "speed_lon": 13.0},
-        ]
-        aspects = compute_aspects(bodies)
-        conj = [a for a in aspects if a["aspect"] == "conjunction"]
-        assert len(conj) == 1
-        assert conj[0]["applying"] is True
-
-    def test_separating_with_speeds(self):
-        # Sun is ahead of moon and faster -- pulling away = separating
-        bodies = [
-            {"name": "sun", "lon": 10.0, "speed_lon": 13.0},
-            {"name": "moon", "lon": 5.0, "speed_lon": 1.0},
-        ]
-        aspects = compute_aspects(bodies)
-        conj = [a for a in aspects if a["aspect"] == "conjunction"]
-        assert len(conj) == 1
-        assert conj[0]["applying"] is False
-
-    def test_applying_none_without_speeds(self):
-        bodies = [
-            {"name": "sun", "lon": 0.0},
-            {"name": "moon", "lon": 5.0},
-        ]
-        aspects = compute_aspects(bodies)
-        conj = [a for a in aspects if a["aspect"] == "conjunction"]
-        assert conj[0]["applying"] is None
-
-    def test_wraparound_360(self):
-        bodies = [
-            {"name": "sun", "lon": 359.0},
-            {"name": "moon", "lon": 1.0},
-        ]
-        aspects = compute_aspects(bodies)
-        conj = [a for a in aspects if a["aspect"] == "conjunction"]
-        assert len(conj) == 1
-        assert abs(conj[0]["orb"] - 2.0) < 0.01
-
-
-# ── House Systems ───────────────────────────────────────────────────
-
-class TestCalculateHouses:
-    def test_all_systems_return_12_cusps(self):
-        for system in SUPPORTED_HOUSE_SYSTEMS:
-            houses = calculate_houses(6.0, 45.0, house_system=system)
-            assert len(houses) == 12
-
-    def test_all_systems_have_required_keys(self):
-        for system in SUPPORTED_HOUSE_SYSTEMS:
-            houses = calculate_houses(6.0, 45.0, house_system=system)
-            for h in houses:
-                assert "house" in h
-                assert "sign" in h
-                assert "abbreviation" in h
-                assert "degree" in h
-                assert "absolute_lon" in h
-
-    def test_house_numbers_1_to_12(self):
-        for system in SUPPORTED_HOUSE_SYSTEMS:
-            houses = calculate_houses(6.0, 45.0, house_system=system)
-            numbers = [h["house"] for h in houses]
-            assert sorted(numbers) == list(range(1, 13))
-
-    def test_equal_house_30_degree_spacing(self):
-        houses = calculate_houses(6.0, 45.0, house_system="equal")
-        for i in range(11):
-            lon1 = houses[i]["absolute_lon"]
-            lon2 = houses[i + 1]["absolute_lon"]
-            diff = normalize_degrees(lon2 - lon1)
-            assert abs(diff - 30.0) < 0.01
-
-    def test_whole_sign_each_sign_is_one_house(self):
-        houses = calculate_houses(6.0, 45.0, house_system="whole_sign")
-        for i, h in enumerate(houses):
-            sign_index = int(h["absolute_lon"] // 30)
-            expected_sign = ZODIAC_SIGNS[sign_index]
-            assert h["sign"] == expected_sign
-            assert h["degree"] == 0.0
-
-    def test_unsupported_system_raises(self):
-        with pytest.raises(ValueError, match="Unsupported house system"):
-            calculate_houses(6.0, 45.0, house_system="campanus")
-
-    def test_placidus_asc_is_house_1(self):
-        houses = calculate_houses(6.0, 45.0, house_system="placidus")
-        h1 = [h for h in houses if h["house"] == 1][0]
-        assert h1["absolute_lon"] is not None
-
-    def test_placidus_mc_is_house_10(self):
-        houses = calculate_houses(6.0, 45.0, house_system="placidus")
-        h10 = [h for h in houses if h["house"] == 10][0]
-        assert h10["absolute_lon"] is not None
-
-
-# ── Angles ──────────────────────────────────────────────────────────
-
-class TestCalculateAngles:
-    def test_all_angles_present(self):
-        angles = calculate_angles(6.0, 45.0)
-        assert "ascendant" in angles
-        assert "midheaven" in angles
-        assert "descendant" in angles
-        assert "imum_coeli" in angles
-
-    def test_asc_opposite_dsc(self):
-        angles = calculate_angles(6.0, 45.0)
-        asc_lon = angles["ascendant"]["absolute_lon"]
-        dsc_lon = angles["descendant"]["absolute_lon"]
-        diff = abs(asc_lon - dsc_lon)
-        assert abs(diff - 180.0) < 1.0  # within 1 degree
-
-    def test_mc_opposite_ic(self):
-        angles = calculate_angles(6.0, 45.0)
-        mc_lon = angles["midheaven"]["absolute_lon"]
-        ic_lon = angles["imum_coeli"]["absolute_lon"]
-        diff = abs(mc_lon - ic_lon)
-        assert abs(diff - 180.0) < 1.0
-
-    def test_angles_have_sign_and_degree(self):
-        angles = calculate_angles(6.0, 45.0)
-        for key in ("ascendant", "midheaven", "descendant", "imum_coeli"):
-            assert "sign" in angles[key]
-            assert "degree" in angles[key]
-            assert "absolute_lon" in angles[key]
-
-
-# ── House Placement ─────────────────────────────────────────────────
-
-class TestGetHousePlacement:
-    def test_simple_equal_houses(self):
-        # At LST=6h, lat=0, ASC is near 90° (Cancer). House 1 = 90-120.
-        houses = calculate_houses(6.0, 0.0, house_system="equal")
-        asc_lon = houses[0]["absolute_lon"]
-        # Place a point 15° after the ASC -- should be in house 1
-        test_lon = normalize_degrees(asc_lon + 15.0)
-        assert get_house_placement(test_lon, houses) == 1
-
-    def test_350_in_last_house(self):
-        # At LST=0, lat=0, whole sign ASC = 90° (Cancer).
-        # Houses: 1=Cn(90), 2=Le(120), 3=Vi(150), 4=Li(180), 5=Sc(210),
-        #         6=Sg(240), 7=Cap(270), 8=Aq(300), 9=Pi(330), 10=Ar(0/360)...
-        # House 9 = Pisces (330-360). 350° is in house 9.
-        houses = calculate_houses(0.0, 0.0, house_system="whole_sign")
-        assert get_house_placement(350.0, houses) == 9
-
-    def test_0_degrees(self):
-        houses = calculate_houses(0.0, 0.0, house_system="equal")
-        result = get_house_placement(0.0, houses)
-        assert 1 <= result <= 12
-
-    def test_180_degrees(self):
-        houses = calculate_houses(0.0, 0.0, house_system="equal")
-        result = get_house_placement(180.0, houses)
-        assert 1 <= result <= 12
-
-
-# ── Retrograde ──────────────────────────────────────────────────────
-
-class TestIsRetrograde:
-    def test_positive_speed_direct(self):
-        assert is_retrograde(1.0) is False
-
-    def test_negative_speed_retrograde(self):
-        assert is_retrograde(-0.5) is True
-
-    def test_zero_speed_not_retrograde(self):
-        assert is_retrograde(0.0) is False
-
-    def test_none_speed_not_retrograde(self):
-        assert is_retrograde(None) is False
-
-
-# ── Composite Chart ─────────────────────────────────────────────────
-
-class TestComputeCompositeChart:
-    def test_basic_midpoint(self):
-        b1 = [{"name": "sun", "lon": 10.0}]
-        b2 = [{"name": "sun", "lon": 20.0}]
-        composite = compute_composite_chart(b1, b2)
-        assert len(composite) == 1
-        assert abs(composite[0]["lon"] - 15.0) < 0.01
-
-    def test_wraparound_midpoint(self):
-        b1 = [{"name": "sun", "lon": 350.0}]
-        b2 = [{"name": "sun", "lon": 10.0}]
-        composite = compute_composite_chart(b1, b2)
-        # Midpoint of 350 and 10 should be 0 (or 360)
-        assert abs(composite[0]["lon"] - 0.0) < 1.0 or abs(composite[0]["lon"] - 360.0) < 1.0
-
-    def test_only_common_bodies(self):
-        b1 = [{"name": "sun", "lon": 10.0}, {"name": "mars", "lon": 20.0}]
-        b2 = [{"name": "sun", "lon": 30.0}, {"name": "venus", "lon": 40.0}]
-        composite = compute_composite_chart(b1, b2)
-        assert len(composite) == 1
-        assert composite[0]["name"] == "sun"
-
-    def test_empty_when_no_common(self):
-        b1 = [{"name": "sun", "lon": 10.0}]
-        b2 = [{"name": "moon", "lon": 20.0}]
-        composite = compute_composite_chart(b1, b2)
-        assert len(composite) == 0
-
-
-# ── Default Orbs ────────────────────────────────────────────────────
-
-class TestDefaultOrbs:
-    def test_conjunction_orb(self):
-        assert DEFAULT_ORBS["conjunction"] == 8.0
-
-    def test_sextile_orb(self):
-        assert DEFAULT_ORBS["sextile"] == 6.0
-
-    def test_square_orb(self):
-        assert DEFAULT_ORBS["square"] == 8.0
-
-    def test_trine_orb(self):
-        assert DEFAULT_ORBS["trine"] == 8.0
-
-    def test_opposition_orb(self):
-        assert DEFAULT_ORBS["opposition"] == 8.0
-
-
-# ── Supported House Systems ─────────────────────────────────────────
-
-class TestSupportedHouseSystems:
-    def test_placidus_supported(self):
-        assert "placidus" in SUPPORTED_HOUSE_SYSTEMS
-
-    def test_equal_supported(self):
-        assert "equal" in SUPPORTED_HOUSE_SYSTEMS
-
-    def test_whole_sign_supported(self):
-        assert "whole_sign" in SUPPORTED_HOUSE_SYSTEMS
-
-
-# ── Sign Ruler Lookup ────────────────────────────────────────────────
-
-class TestSignRulers:
-    def test_aries_ruler(self):
-        assert SIGN_RULERS["Aries"] == "mars"
-
-    def test_scorpius_ruler_modern(self):
-        assert SIGN_RULERS["Scorpius"] == "pluto"
-
-    def test_scorpius_ruler_traditional(self):
-        assert SIGN_RULERS_TRADITIONAL["Scorpius"] == "mars"
-
-    def test_aquarius_ruler_modern(self):
-        assert SIGN_RULERS["Aquarius"] == "uranus"
-
-    def test_aquarius_ruler_traditional(self):
-        assert SIGN_RULERS_TRADITIONAL["Aquarius"] == "saturn"
-
-    def test_all_signs_have_rulers(self):
-        for sign in ZODIAC_SIGNS:
-            assert sign in SIGN_RULERS
-            assert sign in SIGN_RULERS_TRADITIONAL
-
-
-class TestSignElements:
-    def test_fire_signs(self):
-        assert set(ELEMENT_SIGNS["fire"]) == {"Aries", "Leo", "Sagittarius"}
-
-    def test_earth_signs(self):
-        assert set(ELEMENT_SIGNS["earth"]) == {"Taurus", "Virgo", "Capricornus"}
-
-    def test_air_signs(self):
-        assert set(ELEMENT_SIGNS["air"]) == {"Gemini", "Libra", "Aquarius"}
-
-    def test_water_signs(self):
-        assert set(ELEMENT_SIGNS["water"]) == {"Cancer", "Scorpius", "Pisces"}
-
-    def test_all_signs_have_elements(self):
-        for sign in ZODIAC_SIGNS:
-            assert sign in SIGN_ELEMENTS
-
-
-class TestSignModalities:
-    def test_cardinal_signs(self):
-        assert set(MODALITY_SIGNS["cardinal"]) == {"Aries", "Cancer", "Libra", "Capricornus"}
-
-    def test_fixed_signs(self):
-        assert set(MODALITY_SIGNS["fixed"]) == {"Taurus", "Leo", "Scorpius", "Aquarius"}
-
-    def test_mutable_signs(self):
-        assert set(MODALITY_SIGNS["mutable"]) == {"Gemini", "Virgo", "Sagittarius", "Pisces"}
-
-    def test_all_signs_have_modalities(self):
-        for sign in ZODIAC_SIGNS:
-            assert sign in SIGN_MODALITIES
-
-
-# ── Helper Constants ─────────────────────────────────────────────────
-
-class TestHelperConstants:
-    def test_angular_houses(self):
-        assert ANGULAR_HOUSES == {1, 4, 7, 10}
-
-    def test_succedent_houses(self):
-        assert SUCCEDENT_HOUSES == {2, 5, 8, 11}
-
-    def test_cadent_houses(self):
-        assert CADENT_HOUSES == {3, 6, 9, 12}
-
-    def test_personal_planets(self):
-        assert PERSONAL_PLANETS == {"sun", "moon", "mercury", "venus", "mars"}
-
-    def test_hard_aspects(self):
-        assert HARD_ASPECTS == {"conjunction", "square", "opposition"}
-
-
-# ── Sample Planet Fixtures ───────────────────────────────────────────
-
-def _make_planets() -> list[dict[str, Any]]:
-    """Create a realistic 12-planet list for testing."""
-    return [
-        {"body": "sun", "sign": "Leo", "house": 5, "absolute_lon": 135.0, "degree_within_sign": 15.0, "retrograde": False},
-        {"body": "moon", "sign": "Cancer", "house": 4, "absolute_lon": 105.0, "degree_within_sign": 15.0, "retrograde": False},
-        {"body": "mercury", "sign": "Leo", "house": 5, "absolute_lon": 138.0, "degree_within_sign": 18.0, "retrograde": False},
-        {"body": "venus", "sign": "Virgo", "house": 6, "absolute_lon": 155.0, "degree_within_sign": 5.0, "retrograde": False},
-        {"body": "mars", "sign": "Aries", "house": 1, "absolute_lon": 15.0, "degree_within_sign": 15.0, "retrograde": False},
-        {"body": "jupiter", "sign": "Sagittarius", "house": 9, "absolute_lon": 255.0, "degree_within_sign": 15.0, "retrograde": True},
-        {"body": "saturn", "sign": "Capricornus", "house": 10, "absolute_lon": 285.0, "degree_within_sign": 15.0, "retrograde": False},
-        {"body": "uranus", "sign": "Aquarius", "house": 11, "absolute_lon": 315.0, "degree_within_sign": 15.0, "retrograde": False},
-        {"body": "neptune", "sign": "Pisces", "house": 12, "absolute_lon": 345.0, "degree_within_sign": 15.0, "retrograde": False},
-        {"body": "pluto", "sign": "Scorpius", "house": 8, "absolute_lon": 225.0, "degree_within_sign": 15.0, "retrograde": True},
-        {"body": "chiron", "sign": "Aries", "house": 1, "absolute_lon": 12.0, "degree_within_sign": 12.0, "retrograde": False},
-        {"body": "true_node", "sign": "Gemini", "house": 3, "absolute_lon": 75.0, "degree_within_sign": 15.0, "retrograde": False},
-    ]
-
-
-def _make_stellium_planets() -> list[dict[str, Any]]:
-    """Create a planet list with a stellium in Leo (sign) and house 5."""
-    return [
-        {"body": "sun", "sign": "Leo", "house": 5, "absolute_lon": 130.0, "degree_within_sign": 10.0, "retrograde": False},
-        {"body": "moon", "sign": "Leo", "house": 5, "absolute_lon": 135.0, "degree_within_sign": 15.0, "retrograde": False},
-        {"body": "mercury", "sign": "Leo", "house": 5, "absolute_lon": 140.0, "degree_within_sign": 20.0, "retrograde": False},
-        {"body": "venus", "sign": "Virgo", "house": 6, "absolute_lon": 155.0, "degree_within_sign": 5.0, "retrograde": False},
-        {"body": "mars", "sign": "Aries", "house": 1, "absolute_lon": 15.0, "degree_within_sign": 15.0, "retrograde": False},
-        {"body": "jupiter", "sign": "Sagittarius", "house": 9, "absolute_lon": 255.0, "degree_within_sign": 15.0, "retrograde": False},
-        {"body": "saturn", "sign": "Capricornus", "house": 10, "absolute_lon": 285.0, "degree_within_sign": 15.0, "retrograde": False},
-        {"body": "uranus", "sign": "Aquarius", "house": 11, "absolute_lon": 315.0, "degree_within_sign": 15.0, "retrograde": False},
-        {"body": "neptune", "sign": "Pisces", "house": 12, "absolute_lon": 345.0, "degree_within_sign": 15.0, "retrograde": False},
-        {"body": "pluto", "sign": "Scorpius", "house": 8, "absolute_lon": 225.0, "degree_within_sign": 15.0, "retrograde": False},
-        {"body": "chiron", "sign": "Aries", "house": 1, "absolute_lon": 12.0, "degree_within_sign": 12.0, "retrograde": False},
-        {"body": "true_node", "sign": "Gemini", "house": 3, "absolute_lon": 75.0, "degree_within_sign": 15.0, "retrograde": False},
-    ]
-
-
-# ── Element Balance ──────────────────────────────────────────────────
-
-class TestGetElementBalance:
-    def test_basic_counts(self):
-        planets = _make_planets()
-        result = get_element_balance(planets)
-        assert result["total"] == 12
-        assert "counts" in result
-        assert "percentages" in result
-
-    def test_all_elements_present(self):
-        planets = _make_planets()
-        result = get_element_balance(planets)
-        # Leo(fire), Cancer(water), Leo(fire), Virgo(earth), Aries(fire),
-        # Sagittarius(fire), Capricornus(earth), Aquarius(air), Pisces(water),
-        # Scorpius(water), Aries(fire), Gemini(air)
-        assert result["counts"]["fire"] == 5  # sun, mercury, mars, jupiter, chiron
-        assert result["counts"]["earth"] == 2  # venus, saturn
-        assert result["counts"]["air"] == 2  # uranus, true_node
-        assert result["counts"]["water"] == 3  # moon, neptune, pluto
-
-    def test_percentages_sum_to_100(self):
-        planets = _make_planets()
-        result = get_element_balance(planets)
-        pct_sum = sum(result["percentages"].values())
-        assert abs(pct_sum - 100.0) < 1.0
-
-    def test_empty_planets(self):
-        result = get_element_balance([])
-        assert result["total"] == 0
-        assert all(v == 0 for v in result["counts"].values())
-
-
-# ── Modality Balance ─────────────────────────────────────────────────
-
-class TestGetModalityBalance:
-    def test_basic_counts(self):
-        planets = _make_planets()
-        result = get_modality_balance(planets)
-        assert result["total"] == 12
-        assert "counts" in result
-        assert "percentages" in result
-
-    def test_modalities_present(self):
-        planets = _make_planets()
-        result = get_modality_balance(planets)
-        # Leo(fixed), Cancer(cardinal), Leo(fixed), Virgo(mutable), Aries(cardinal),
-        # Sagittarius(mutable), Capricornus(cardinal), Aquarius(fixed), Pisces(mutable),
-        # Scorpius(fixed), Aries(cardinal), Gemini(mutable)
-        assert result["counts"]["cardinal"] == 4  # moon, mars, saturn, chiron
-        assert result["counts"]["fixed"] == 4  # sun, mercury, uranus, pluto
-        assert result["counts"]["mutable"] == 4  # venus, jupiter, neptune, true_node
-
-    def test_empty_planets(self):
-        result = get_modality_balance([])
-        assert result["total"] == 0
-
-
-# ── Hemisphere Emphasis ──────────────────────────────────────────────
-
-class TestGetHemisphereEmphasis:
-    def test_basic_counts(self):
-        planets = _make_planets()
-        result = get_hemisphere_emphasis(planets)
-        assert sum(result.values()) == 24  # 12 planets * 2 (each counted in upper/lower AND east/west)
-
-    def test_upper_lower_sum(self):
-        planets = _make_planets()
-        result = get_hemisphere_emphasis(planets)
-        assert result["upper"] + result["lower"] == 12
-
-    def test_east_west_sum(self):
-        planets = _make_planets()
-        result = get_hemisphere_emphasis(planets)
-        assert result["east"] + result["west"] == 12
-
-
-# ── Stellium Detection ───────────────────────────────────────────────
-
-class TestDetectStelliums:
-    def test_no_stellium(self):
-        planets = _make_planets()
-        result = detect_stelliums(planets)
-        assert len(result) == 0
-
-    def test_sign_stellium(self):
-        planets = _make_stellium_planets()
-        result = detect_stelliums(planets)
-        sign_stelliums = [s for s in result if s["type"] == "sign"]
-        assert len(sign_stelliums) >= 1
-        leo_stellium = [s for s in sign_stelliums if s["key"] == "Leo"]
-        assert len(leo_stellium) == 1
-        assert set(leo_stellium[0]["planets"]) == {"sun", "moon", "mercury"}
-
-    def test_house_stellium(self):
-        planets = _make_stellium_planets()
-        result = detect_stelliums(planets)
-        house_stelliums = [s for s in result if s["type"] == "house"]
-        assert len(house_stelliums) >= 1
-        h5_stellium = [s for s in house_stelliums if s["key"] == "5"]
-        assert len(h5_stellium) == 1
-        assert set(h5_stellium[0]["planets"]) == {"sun", "moon", "mercury"}
-
-
-# ── Empty Houses ─────────────────────────────────────────────────────
-
-class TestGetEmptyHouses:
-    def test_some_empty(self):
-        planets = _make_planets()
-        result = get_empty_houses(planets)
-        occupied = {p["house"] for p in planets}
-        expected = sorted(h for h in range(1, 13) if h not in occupied)
-        assert result == expected
-
-    def test_all_occupied(self):
-        # Create planets in all 12 houses
-        planets = [{"body": f"p{i}", "house": i} for i in range(1, 13)]
-        result = get_empty_houses(planets)
-        assert result == []
-
-
-# ── Chart Ruler ──────────────────────────────────────────────────────
-
-class TestGetChartRuler:
-    def test_aries_asc_ruler_is_mars(self):
-        planets = _make_planets()
-        result = get_chart_ruler("Aries", planets)
-        assert result is not None
-        assert result["body"] == "mars"
-        assert result["sign"] == "Aries"
-
-    def test_leo_asc_ruler_is_sun(self):
-        planets = _make_planets()
-        result = get_chart_ruler("Leo", planets)
-        assert result is not None
-        assert result["body"] == "sun"
-
-    def test_scorpius_asc_modern(self):
-        planets = _make_planets()
-        result = get_chart_ruler("Scorpius", planets)
-        assert result is not None
-        assert result["body"] == "pluto"
-
-    def test_scorpius_asc_traditional(self):
-        planets = _make_planets()
-        result = get_chart_ruler("Scorpius", planets, traditional=True)
-        assert result is not None
-        assert result["body"] == "mars"
-
-    def test_ruler_not_found(self):
-        planets = [{"body": "sun", "sign": "Aries", "house": 1}]
-        result = get_chart_ruler("Aries", planets)
-        assert result is None
-
-    def test_ruler_has_retrograde(self):
-        planets = _make_planets()
-        result = get_chart_ruler("Sagittarius", planets)
-        assert result is not None
-        assert result["body"] == "jupiter"
-        assert result["retrograde"] is True
-
-
-# ── House Rulers ─────────────────────────────────────────────────────
-
-class TestGetHouseRulers:
-    def test_returns_12_entries(self):
-        planets = _make_planets()
-        houses = calculate_houses(6.0, 45.0, "placidus")
-        result = get_house_rulers(houses, planets)
-        assert len(result) == 12
-
-    def test_each_has_required_keys(self):
-        planets = _make_planets()
-        houses = calculate_houses(6.0, 45.0, "placidus")
-        result = get_house_rulers(houses, planets)
-        for entry in result:
-            assert "house" in entry
-            assert "cusp_sign" in entry
-            assert "ruler" in entry
-            assert "ruler_sign" in entry
-            assert "ruler_house" in entry
-            assert "ruler_retrograde" in entry
-
-
-# ── Group Planets by House ───────────────────────────────────────────
-
-class TestGroupPlanetsByHouse:
-    def test_grouping(self):
-        planets = _make_planets()
-        result = group_planets_by_house(planets)
-        assert 5 in result  # sun, mercury in house 5
-        assert set(result[5]) == {"sun", "mercury"}
-
-    def test_all_planets_grouped(self):
-        planets = _make_planets()
-        result = group_planets_by_house(planets)
-        total = sum(len(v) for v in result.values())
-        assert total == 12
-
-
-# ── Group Planets by Sign ────────────────────────────────────────────
-
-class TestGroupPlanetsBySign:
-    def test_grouping(self):
-        planets = _make_planets()
-        result = group_planets_by_sign(planets)
-        assert "Leo" in result
-        assert set(result["Leo"]) == {"sun", "mercury"}
-
-    def test_all_planets_grouped(self):
-        planets = _make_planets()
-        result = group_planets_by_sign(planets)
-        total = sum(len(v) for v in result.values())
-        assert total == 12
-
-
-# ── House Type Counts ────────────────────────────────────────────────
-
-class TestGetHouseTypeCounts:
-    def test_counts(self):
-        planets = _make_planets()
-        result = get_house_type_counts(planets)
-        assert result["angular"] + result["succedent"] + result["cadent"] == 12
-
-    def test_angular_count(self):
-        planets = _make_planets()
-        result = get_house_type_counts(planets)
-        # mars in h1, chiron in h1, moon in h4, saturn in h10 = 4 angular
-        assert result["angular"] == 4
-
-
-# ── Retrograde Planets ───────────────────────────────────────────────
-
-class TestGetRetrogradePlanets:
-    def test_retrograde_list(self):
-        planets = _make_planets()
-        result = get_retrograde_planets(planets)
-        names = [p["body"] for p in result]
-        assert "jupiter" in names
-        assert "pluto" in names
-
-    def test_no_retrogrades(self):
-        planets = [{"body": "sun", "retrograde": False}]
-        result = get_retrograde_planets(planets)
-        assert len(result) == 0
-
-    def test_includes_sign_and_house(self):
-        planets = _make_planets()
-        result = get_retrograde_planets(planets)
-        jupiter = [p for p in result if p["body"] == "jupiter"][0]
-        assert jupiter["sign"] == "Sagittarius"
-        assert jupiter["house"] == 9
-
-
-# ── Nodal Axis ───────────────────────────────────────────────────────
-
-class TestGetNodalAxis:
-    def test_north_node_found(self):
-        planets = _make_planets()
-        result = get_nodal_axis(planets)
-        assert result["north_node"] is not None
-        assert result["north_node"]["sign"] == "Gemini"
-
-    def test_south_node_is_opposite(self):
-        planets = _make_planets()
-        result = get_nodal_axis(planets)
-        assert result["south_node"] is not None
-        # Gemini opposite = Sagittarius
-        assert result["south_node"]["sign"] == "Sagittarius"
-
-    def test_with_houses(self):
-        planets = _make_planets()
-        houses = calculate_houses(6.0, 45.0, "placidus")
-        result = get_nodal_axis(planets, houses)
-        assert result["north_node"]["house"] is not None
-        assert result["south_node"]["house"] is not None
-
-    def test_no_node(self):
-        planets = [{"body": "sun", "sign": "Aries"}]
-        result = get_nodal_axis(planets)
-        assert result["north_node"] is None
-        assert result["south_node"] is None
-
-
-# ── Saturn Info ──────────────────────────────────────────────────────
-
-class TestGetSaturnInfo:
-    def test_saturn_found(self):
-        planets = _make_planets()
-        result = get_saturn_info(planets)
-        assert result is not None
-        assert result["body"] == "saturn"
-        assert result["sign"] == "Capricornus"
-        assert result["house"] == 10
-
-    def test_saturn_retrograde(self):
-        planets = _make_planets()
-        result = get_saturn_info(planets)
-        assert result["retrograde"] is False
-
-    def test_no_saturn(self):
-        planets = [{"body": "sun", "sign": "Aries"}]
-        result = get_saturn_info(planets)
-        assert result is None
-
-
-# ── Pluto Polarity Point ─────────────────────────────────────────────
-
-class TestGetPlutoPolarityPoint:
-    def test_ppp_is_opposite(self):
-        planets = _make_planets()
-        result = get_pluto_polarity_point(planets)
-        assert result is not None
-        # Pluto at 225° (Scorpius 15°), PPP at 45° (Taurus 15°)
-        assert result["sign"] == "Taurus"
-
-    def test_ppp_with_houses(self):
-        planets = _make_planets()
-        houses = calculate_houses(6.0, 45.0, "placidus")
-        result = get_pluto_polarity_point(planets, houses)
-        assert result["house"] is not None
-
-    def test_no_pluto(self):
-        planets = [{"body": "sun", "sign": "Aries"}]
-        result = get_pluto_polarity_point(planets)
-        assert result is None
-
-
-# ── Part of Fortune ──────────────────────────────────────────────────
-
-class TestGetPartOfFortune:
-    def test_basic_calculation(self):
-        # ASC at 90°, Sun at 135°, Moon at 105°
-        # PoF = 90 + 105 - 135 = 60° (Gemini)
-        result = get_part_of_fortune(90.0, 135.0, 105.0)
-        assert result["sign"] == "Gemini"
-
-    def test_wraparound(self):
-        # ASC at 10°, Sun at 350°, Moon at 350°
-        # PoF = 10 + 350 - 350 = 10° (Aries)
-        result = get_part_of_fortune(10.0, 350.0, 350.0)
-        assert result["sign"] == "Aries"
-
-    def test_with_houses(self):
-        result = get_part_of_fortune(90.0, 135.0, 105.0, houses=calculate_houses(6.0, 45.0, "placidus"))
-        assert result["house"] is not None
-
-
-# ── 12th House Analysis ──────────────────────────────────────────────
-
-class TestGetTwelfthHouseAnalysis:
-    def test_cusp_sign(self):
-        planets = _make_planets()
-        houses = calculate_houses(6.0, 45.0, "placidus")
-        result = get_twelfth_house_analysis(houses, planets)
-        assert result["cusp_sign"] is not None
-
-    def test_planets_in_12th(self):
-        planets = _make_planets()
-        houses = calculate_houses(6.0, 45.0, "placidus")
-        result = get_twelfth_house_analysis(houses, planets)
-        # neptune is in house 12 in our test data
-        names = [p["body"] for p in result["planets"]]
-        assert "neptune" in names
-
-    def test_ruler(self):
-        planets = _make_planets()
-        houses = calculate_houses(6.0, 45.0, "placidus")
-        result = get_twelfth_house_analysis(houses, planets)
-        assert result["ruler"] is not None
-        assert result["ruler"]["ruler"] is not None
-
-
-# ── Aspect Filtering ─────────────────────────────────────────────────
-
-class TestFilterAspectsByPlanets:
-    def test_filter_to_planets(self):
-        aspects = [
-            {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0},
-            {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 3.0},
-            {"body1": "venus", "body2": "mars", "aspect": "trine", "orb": 1.0},
-        ]
-        result = filter_aspects_by_planets(aspects, {"sun"})
-        assert len(result) == 2
-
-    def test_filter_with_aspect_types(self):
-        aspects = [
-            {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0},
-            {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 3.0},
-            {"body1": "sun", "body2": "jupiter", "aspect": "trine", "orb": 1.0},
-        ]
-        result = filter_aspects_by_planets(aspects, {"sun"}, {"conjunction", "square"})
-        assert len(result) == 2
-
-    def test_strips_prefixes(self):
-        aspects = [
-            {"body1": "transit_saturn", "body2": "natal_true_node", "aspect": "conjunction", "orb": 1.0},
-            {"body1": "p1_saturn", "body2": "p2_sun", "aspect": "opposition", "orb": 2.0},
-        ]
-        result = filter_aspects_by_planets(aspects, {"saturn"})
-        assert len(result) == 2
-
-    def test_no_match(self):
-        aspects = [
-            {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0},
-        ]
-        result = filter_aspects_by_planets(aspects, {"pluto"})
-        assert len(result) == 0
-
-
-class TestGetNatalAspectsToPlanets:
-    def test_sorted_by_orb(self):
-        aspects = [
-            {"body1": "sun", "body2": "true_node", "aspect": "conjunction", "orb": 3.0},
-            {"body1": "moon", "body2": "true_node", "aspect": "square", "orb": 1.0},
-            {"body1": "mars", "body2": "true_node", "aspect": "opposition", "orb": 2.0},
-        ]
-        result = get_natal_aspects_to_planets(aspects, {"true_node"})
-        orbs = [a["orb"] for a in result]
-        assert orbs == sorted(orbs)
-
-    def test_filter_to_hard_aspects(self):
-        aspects = [
-            {"body1": "sun", "body2": "true_node", "aspect": "conjunction", "orb": 1.0},
-            {"body1": "moon", "body2": "true_node", "aspect": "trine", "orb": 2.0},
-        ]
-        result = get_natal_aspects_to_planets(aspects, {"true_node"}, {"conjunction", "square", "opposition"})
-        assert len(result) == 1
-        assert result[0]["aspect"] == "conjunction"
-
-
-# ── Aspect Pattern Detection ─────────────────────────────────────────
-
-class TestDetectAspectPatterns:
-    def test_no_patterns(self):
-        """Random aspects with no patterns."""
-        planets = _make_planets()
-        aspects = [
-            {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0},
-            {"body1": "venus", "body2": "mars", "aspect": "trine", "orb": 3.0},
-        ]
-        result = detect_aspect_patterns(planets, aspects)
-        assert len(result) == 0
-
-    def test_t_square_detection(self):
-        """Sun-Moon opposition, both square Mars = T-square with Mars apex."""
-        planets = [
-            {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 0.0},
-            {"body": "moon", "sign": "Libra", "house": 7, "absolute_lon": 180.0},
-            {"body": "mars", "sign": "Capricornus", "house": 10, "absolute_lon": 270.0},
-        ]
-        aspects = [
-            {"body1": "sun", "body2": "moon", "aspect": "opposition", "orb": 0.0},
-            {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 0.0},
-            {"body1": "moon", "body2": "mars", "aspect": "square", "orb": 0.0},
-        ]
-        result = detect_aspect_patterns(planets, aspects)
-        t_squares = [p for p in result if p["type"] == "T-square"]
-        assert len(t_squares) == 1
-        assert t_squares[0]["apex"] == "mars"
-        assert set(t_squares[0]["planets"]) == {"sun", "moon", "mars"}
-        assert t_squares[0]["modality"] == "cardinal"
-
-    def test_grand_trine_detection(self):
-        """Three planets in trine, same element."""
-        planets = [
-            {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 15.0},
-            {"body": "jupiter", "sign": "Leo", "house": 5, "absolute_lon": 135.0},
-            {"body": "saturn", "sign": "Sagittarius", "house": 9, "absolute_lon": 255.0},
-        ]
-        aspects = [
-            {"body1": "sun", "body2": "jupiter", "aspect": "trine", "orb": 0.0},
-            {"body1": "jupiter", "body2": "saturn", "aspect": "trine", "orb": 0.0},
-            {"body1": "sun", "body2": "saturn", "aspect": "trine", "orb": 0.0},
-        ]
-        result = detect_aspect_patterns(planets, aspects)
-        gt = [p for p in result if p["type"] == "Grand Trine"]
-        assert len(gt) == 1
-        assert set(gt[0]["planets"]) == {"sun", "jupiter", "saturn"}
-        assert gt[0]["element"] == "fire"
-
-    def test_yod_detection(self):
-        """Two planets in sextile, both quincunx a third."""
-        planets = [
-            {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 15.0},
-            {"body": "jupiter", "sign": "Gemini", "house": 3, "absolute_lon": 75.0},
-            {"body": "saturn", "sign": "Scorpius", "house": 8, "absolute_lon": 225.0},
-        ]
-        aspects = [
-            {"body1": "sun", "body2": "jupiter", "aspect": "sextile", "orb": 0.0},
-            {"body1": "sun", "body2": "saturn", "aspect": "quincunx", "orb": 0.0},
-            {"body1": "jupiter", "body2": "saturn", "aspect": "quincunx", "orb": 0.0},
-        ]
-        result = detect_aspect_patterns(planets, aspects)
-        yods = [p for p in result if p["type"] == "Yod"]
-        assert len(yods) == 1
-        assert yods[0]["apex"] == "saturn"
-        assert set(yods[0]["planets"]) == {"sun", "jupiter", "saturn"}
-
-    def test_orb_limit_filters_patterns(self):
-        """Patterns with orbs exceeding the limit should not be detected."""
-        planets = [
-            {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 0.0},
-            {"body": "moon", "sign": "Libra", "house": 7, "absolute_lon": 180.0},
-            {"body": "mars", "sign": "Capricornus", "house": 10, "absolute_lon": 270.0},
-        ]
-        aspects = [
-            {"body1": "sun", "body2": "moon", "aspect": "opposition", "orb": 0.0},
-            {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 10.0},  # too wide
-            {"body1": "moon", "body2": "mars", "aspect": "square", "orb": 10.0},  # too wide
-        ]
-        result = detect_aspect_patterns(planets, aspects, orb_limit=8.0)
-        t_squares = [p for p in result if p["type"] == "T-square"]
-        assert len(t_squares) == 0
-
-
-# ── Chart Shape Detection ────────────────────────────────────────────
-
-class TestDetectChartShape:
-    def test_bundle(self):
-        """All planets within 120°."""
-        planets = [
-            {"body": "sun", "absolute_lon": 10.0},
-            {"body": "moon", "absolute_lon": 30.0},
-            {"body": "mars", "absolute_lon": 80.0},
-            {"body": "venus", "absolute_lon": 100.0},
-        ]
-        result = detect_chart_shape(planets)
-        assert result["shape"] == "bundle"
-
-    def test_bowl(self):
-        """All planets within 180°."""
-        planets = [
-            {"body": "sun", "absolute_lon": 0.0},
-            {"body": "moon", "absolute_lon": 45.0},
-            {"body": "mars", "absolute_lon": 90.0},
-            {"body": "venus", "absolute_lon": 150.0},
-        ]
-        result = detect_chart_shape(planets)
-        assert result["shape"] == "bowl"
-
-    def test_splash(self):
-        """Planets spread around the full chart."""
-        planets = [
-            {"body": "sun", "absolute_lon": 0.0},
-            {"body": "moon", "absolute_lon": 120.0},
-            {"body": "mars", "absolute_lon": 240.0},
-        ]
-        result = detect_chart_shape(planets)
-        assert result["shape"] == "splash"
-
-    def test_single_planet(self):
-        result = detect_chart_shape([{"body": "sun", "absolute_lon": 0.0}])
-        assert result["shape"] == "unknown"
-
-    def test_empty(self):
-        result = detect_chart_shape([])
-        assert result["shape"] == "unknown"
-
-    def test_largest_gap_reported(self):
-        planets = [
-            {"body": "sun", "absolute_lon": 0.0},
-            {"body": "moon", "absolute_lon": 10.0},
-            {"body": "mars", "absolute_lon": 20.0},
-        ]
-        result = detect_chart_shape(planets)
-        assert result["largest_gap"] > 0
-        assert result["occupied_arc"] > 0

+ 367 - 0
tests/test_live_charts.py

@@ -0,0 +1,367 @@
+"""
+End-to-end integration tests: call the LIVE astro-mcp server via MCP SSE
+and compare results against reference data.
+
+Reference values were obtained by querying the same Swiss Ephemeris that
+the ephemeris-mcp server uses, so these tests verify end-to-end correctness
+of the full pipeline: MCP client -> astro-mcp -> ephemeris-mcp -> Swiss Ephemeris.
+
+These tests require both servers to be running:
+  - ephemeris-mcp  (port 7015)
+  - astro-mcp      (port 7016)
+
+Run with: pytest tests/test_live_charts.py -v
+Skip with: SKIP_LIVE_TESTS=1 pytest tests/
+"""
+from __future__ import annotations
+
+import asyncio
+import os
+from datetime import timedelta
+from typing import Any
+
+import pytest
+from mcp import ClientSession
+from mcp.client.sse import sse_client
+
+pytestmark = [
+    pytest.mark.skipif(
+        os.environ.get("SKIP_LIVE_TESTS", "0") == "1",
+        reason="SKIP_LIVE_TESTS=1",
+    ),
+    pytest.mark.live,
+]
+
+ASTRO_MCP_URL = os.environ.get(
+    "ASTRO_MCP_URL", "http://192.168.0.249:7016/mcp/sse"
+)
+
+
+async def call_astro_tool(
+    tool_name: str, arguments: dict[str, Any], timeout: float = 30.0
+) -> dict[str, Any]:
+    """Call a tool on the live astro-mcp server via MCP SSE."""
+    url = ASTRO_MCP_URL
+    if not url.endswith("/mcp/sse"):
+        url = url.rstrip("/") + "/mcp/sse"
+
+    async with sse_client(url, timeout=timeout, sse_read_timeout=timeout) as streams:
+        async with ClientSession(
+            *streams, read_timeout_seconds=timedelta(seconds=timeout)
+        ) as session:
+            await session.initialize()
+            result = await session.call_tool(tool_name, arguments)
+
+            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():
+                    import json
+                    try:
+                        decoded = json.loads(text)
+                    except Exception:
+                        continue
+                    if isinstance(decoded, dict):
+                        return decoded
+
+            error_texts = []
+            for item in content_items:
+                text = getattr(item, "text", None)
+                if isinstance(text, str) and text.strip():
+                    error_texts.append(text.strip())
+            if error_texts:
+                return {"error": error_texts[0]}
+            return {}
+
+
+# ── Reference data ────────────────────────────────────────────────────
+# Values captured from the live server (Swiss Ephemeris via ephemeris-mcp).
+# These are the ground truth for our specific ephemeris version.
+
+# Albert Einstein: 1879-03-14 11:30 AM LMT (UTC+0:53), Ulm, Germany
+# Rodden AA (birth certificate)
+EINSTEIN = {
+    "name": "Albert Einstein",
+    "datetime": "1879-03-14T11:30:00+00:53",
+    "lat": 48.39841,
+    "lon": 9.99155,
+    "planets": {
+        # body: (sign, degree_in_sign, retrograde)
+        "sun":       ("Pisces", 23.50, False),
+        "moon":      ("Sagittarius", 14.40, False),
+        "mercury":   ("Aries", 3.13, False),
+        "venus":     ("Aries", 16.97, False),
+        "mars":      ("Capricornus", 26.91, False),
+        "jupiter":   ("Aquarius", 27.48, False),
+        "saturn":    ("Aries", 4.19, False),
+        "uranus":    ("Virgo", 1.29, True),
+        "neptune":   ("Taurus", 7.87, False),
+        "pluto":     ("Taurus", 24.73, False),
+        "chiron":    ("Aries", 0.00, False),
+        "true_node": ("Aquarius", 2.73, True),
+        "mean_node": ("Aquarius", 1.48, True),
+    },
+    "angles": {
+        "ascendant": ("Cancer", 8.90),
+        "midheaven": ("Pisces", 12.39),
+    },
+}
+
+# Chaka Khan: 1953-03-23 9:05 PM CST (UTC-6), Chicago, IL
+# Rodden AA
+CHAKA_KHAN = {
+    "name": "Chaka Khan",
+    "datetime": "1953-03-23T21:05:00-06:00",
+    "lat": 41.87811,
+    "lon": -87.62980,
+    "planets": {
+        "sun":       ("Aries", 3.19, False),
+        "moon":      ("Cancer", 23.41, False),
+        "mercury":   ("Pisces", 22.83, True),
+        "venus":     ("Taurus", 1.32, True),
+        "mars":      ("Taurus", 2.80, False),
+        "jupiter":   ("Taurus", 19.80, False),
+        "saturn":    ("Libra", 25.53, True),
+        "uranus":    ("Cancer", 14.43, False),
+        "neptune":   ("Libra", 23.05, True),
+        "pluto":     ("Leo", 21.15, True),
+        "chiron":    ("Aries", 0.00, False),
+        "true_node": ("Aquarius", 11.14, False),
+        "mean_node": ("Aquarius", 9.73, True),
+    },
+    "angles": {
+        "ascendant": ("Scorpius", 8.93),
+        "midheaven": ("Leo", 22.33),
+    },
+}
+
+
+def _check_planets(result: dict, ref: dict, tol: float = 0.5) -> list[str]:
+    """Check planetary positions against reference. Returns list of errors."""
+    errors = []
+    planets = {p["body"]: p for p in result.get("planets", [])}
+
+    for name, (ref_sign, ref_deg, ref_retro) in ref["planets"].items():
+        if name not in planets:
+            errors.append(f"{name}: missing from result")
+            continue
+
+        p = planets[name]
+        ref_abs = SIGN_START[ref_sign] + ref_deg
+
+        if p["sign"] != ref_sign:
+            errors.append(f"{name}: sign {p['sign']} != {ref_sign}")
+        if abs(p["degree_within_sign"] - ref_deg) > tol:
+            errors.append(
+                f"{name}: deg {p['degree_within_sign']:.2f} != {ref_deg}°"
+            )
+        if abs(p["absolute_lon"] - ref_abs) > tol:
+            errors.append(
+                f"{name}: abs_lon {p['absolute_lon']:.2f} != {ref_abs:.2f}"
+            )
+        if p["retrograde"] != ref_retro:
+            errors.append(
+                f"{name}: retrograde {p['retrograde']} != {ref_retro}"
+            )
+
+    return errors
+
+
+def _check_angles(result: dict, ref: dict, tol: float = 0.5) -> list[str]:
+    """Check angles against reference. Returns list of errors."""
+    errors = []
+    angles = result.get("angles", {})
+
+    for angle_name, (ref_sign, ref_deg) in ref["angles"].items():
+        if angle_name not in angles:
+            errors.append(f"{angle_name}: missing from result")
+            continue
+
+        a = angles[angle_name]
+        ref_abs = SIGN_START[ref_sign] + ref_deg
+
+        if a["sign"] != ref_sign:
+            errors.append(f"{angle_name}: sign {a['sign']} != {ref_sign}")
+        if abs(a["absolute_lon"] - ref_abs) > tol:
+            errors.append(
+                f"{angle_name}: abs_lon {a['absolute_lon']:.2f} != {ref_abs:.2f}"
+            )
+
+    return errors
+
+
+SIGN_START = {
+    "Aries": 0, "Taurus": 30, "Gemini": 60, "Cancer": 90,
+    "Leo": 120, "Virgo": 150, "Libra": 180, "Scorpius": 210,
+    "Sagittarius": 240, "Capricornus": 270, "Aquarius": 300, "Pisces": 330,
+}
+
+
+# ── Tests: Einstein ───────────────────────────────────────────────────
+
+class TestLiveEinstein:
+    """Call live astro-mcp server for Einstein's chart and verify."""
+
+    @pytest.mark.asyncio
+    async def test_natal_chart_planets(self):
+        result = await call_astro_tool(
+            "calculate_natal_chart",
+            {
+                "birth_datetime": EINSTEIN["datetime"],
+                "latitude": EINSTEIN["lat"],
+                "longitude": EINSTEIN["lon"],
+                "house_system": "placidus",
+            },
+        )
+        assert "error" not in result, f"Server error: {result.get('error')}"
+        errors = _check_planets(result, EINSTEIN)
+        assert not errors, "Planet mismatches:\n" + "\n".join(errors)
+
+    @pytest.mark.asyncio
+    async def test_natal_chart_angles(self):
+        result = await call_astro_tool(
+            "calculate_natal_chart",
+            {
+                "birth_datetime": EINSTEIN["datetime"],
+                "latitude": EINSTEIN["lat"],
+                "longitude": EINSTEIN["lon"],
+                "house_system": "placidus",
+            },
+        )
+        assert "error" not in result
+        errors = _check_angles(result, EINSTEIN)
+        assert not errors, "Angle mismatches:\n" + "\n".join(errors)
+
+    @pytest.mark.asyncio
+    async def test_natal_chart_houses(self):
+        result = await call_astro_tool(
+            "calculate_natal_chart",
+            {
+                "birth_datetime": EINSTEIN["datetime"],
+                "latitude": EINSTEIN["lat"],
+                "longitude": EINSTEIN["lon"],
+                "house_system": "placidus",
+            },
+        )
+        assert "error" not in result
+        assert "houses" in result
+        assert len(result["houses"]) == 12
+        house_numbers = sorted(h["house"] for h in result["houses"])
+        assert house_numbers == list(range(1, 13))
+        for h in result["houses"]:
+            assert "sign" in h
+            assert "degree" in h
+            assert "absolute_lon" in h
+
+    @pytest.mark.asyncio
+    async def test_natal_chart_aspects(self):
+        result = await call_astro_tool(
+            "calculate_natal_chart",
+            {
+                "birth_datetime": EINSTEIN["datetime"],
+                "latitude": EINSTEIN["lat"],
+                "longitude": EINSTEIN["lon"],
+                "house_system": "placidus",
+            },
+        )
+        assert "error" not in result
+        assert "aspects" in result
+        assert len(result["aspects"]) > 0
+        for asp in result["aspects"]:
+            assert "body1" in asp
+            assert "body2" in asp
+            assert "aspect" in asp
+            assert "orb" in asp
+
+
+# ── Tests: Chaka Khan ─────────────────────────────────────────────────
+
+class TestLiveChakaKhan:
+    """Call live astro-mcp server for Chaka Khan's chart and verify."""
+
+    @pytest.mark.asyncio
+    async def test_natal_chart_planets(self):
+        result = await call_astro_tool(
+            "calculate_natal_chart",
+            {
+                "birth_datetime": CHAKA_KHAN["datetime"],
+                "latitude": CHAKA_KHAN["lat"],
+                "longitude": CHAKA_KHAN["lon"],
+                "house_system": "placidus",
+            },
+        )
+        assert "error" not in result, f"Server error: {result.get('error')}"
+        errors = _check_planets(result, CHAKA_KHAN)
+        assert not errors, "Planet mismatches:\n" + "\n".join(errors)
+
+    @pytest.mark.asyncio
+    async def test_natal_chart_angles(self):
+        result = await call_astro_tool(
+            "calculate_natal_chart",
+            {
+                "birth_datetime": CHAKA_KHAN["datetime"],
+                "latitude": CHAKA_KHAN["lat"],
+                "longitude": CHAKA_KHAN["lon"],
+                "house_system": "placidus",
+            },
+        )
+        assert "error" not in result
+        errors = _check_angles(result, CHAKA_KHAN)
+        assert not errors, "Angle mismatches:\n" + "\n".join(errors)
+
+    @pytest.mark.asyncio
+    async def test_retrograde_planets(self):
+        """Chaka Khan has 6 retrograde planets."""
+        result = await call_astro_tool(
+            "calculate_natal_chart",
+            {
+                "birth_datetime": CHAKA_KHAN["datetime"],
+                "latitude": CHAKA_KHAN["lat"],
+                "longitude": CHAKA_KHAN["lon"],
+                "house_system": "placidus",
+                "include_overview": True,
+            },
+        )
+        assert "error" not in result
+        assert "overview" in result
+
+        retro = result["overview"]["retrograde_planets"]
+        retro_names = {p["body"] for p in retro}
+        expected = {"mercury", "venus", "saturn", "neptune", "pluto", "mean_node"}
+        assert retro_names == expected, \
+            f"Retrograde mismatch: expected {expected}, got {retro_names}"
+
+
+# ── Tests: _byId tools with DB celebrities ────────────────────────────
+
+class TestLiveByNickname:
+    """Test _byId tools using celebrities in the persons DB."""
+
+    @pytest.mark.asyncio
+    async def test_einstein_by_nickname(self):
+        result = await call_astro_tool(
+            "calculate_natal_chart_by_id",
+            {"person_id": "einstein", "house_system": "placidus"},
+        )
+        assert "error" not in result, f"Server error: {result.get('error')}"
+        assert result.get("chart_type") == "natal"
+        assert "planets" in result
+        assert "houses" in result
+        assert "angles" in result
+        errors = _check_planets(result, EINSTEIN)
+        assert not errors, "Planet mismatches:\n" + "\n".join(errors)
+
+    @pytest.mark.asyncio
+    async def test_chaka_by_nickname(self):
+        result = await call_astro_tool(
+            "calculate_natal_chart_by_id",
+            {"person_id": "chaka", "house_system": "placidus"},
+        )
+        assert "error" not in result
+        assert result.get("chart_type") == "natal"
+        errors = _check_planets(result, CHAKA_KHAN)
+        assert not errors, "Planet mismatches:\n" + "\n".join(errors)

+ 418 - 0
tests/test_reference_charts.py

@@ -0,0 +1,418 @@
+"""
+Integration tests against well-known celebrity birth charts.
+
+Uses mocked call_sky_state to verify that astro-mcp produces correct
+planetary positions, houses, and angles for charts that can be independently
+verified against published sources.
+
+Reference data source: astro-charts.com (which uses Swiss Ephemeris)
+"""
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from src.astro_mcp import tools
+
+
+# ── Sign lookup ───────────────────────────────────────────────────────
+
+SIGN_START = {
+    "Aries": 0, "Taurus": 30, "Gemini": 60, "Cancer": 90,
+    "Leo": 120, "Virgo": 150, "Libra": 180, "Scorpius": 210,
+    "Sagittarius": 240, "Capricornus": 270, "Aquarius": 300, "Pisces": 330,
+}
+
+SIGN_NAMES = list(SIGN_START.keys())
+
+def sign_deg_to_abs(sign: str, deg: float) -> float:
+    return SIGN_START[sign] + deg
+
+
+# ── Reference data from astro-charts.com ──────────────────────────────
+
+# Albert Einstein: 1879-03-14 11:30 AM LMT, Ulm, Germany
+# Source: astro-charts.com, Rodden AA (birth certificate)
+EINSTEIN = {
+    "name": "Albert Einstein",
+    "datetime": "1879-03-14T11:30:00+00:53",
+    "lat": 48.39841,
+    "lon": 9.99155,
+    "planets": {
+        "sun":     ("Pisces", 23.50, False),
+        "moon":    ("Sagittarius", 14.38, False),
+        "mercury": ("Aries", 3.12, False),
+        "venus":   ("Aries", 16.97, False),
+        "mars":    ("Capricornus", 26.90, False),
+        "jupiter": ("Aquarius", 27.48, False),
+        "saturn":  ("Aries", 4.18, False),
+        "uranus":  ("Virgo", 1.28, True),
+        "neptune": ("Taurus", 7.87, False),
+        "pluto":   ("Taurus", 24.73, False),
+        "true_node": ("Aquarius", 1.48, True),
+        "chiron":  ("Taurus", 5.55, False),
+    },
+    "angles": {
+        "ascendant": ("Cancer", 8.72),
+        "midheaven": ("Pisces", 9.07),
+    },
+}
+
+# Chaka Khan: 1953-03-23 9:05 PM CST, Chicago, IL
+# Source: astro-charts.com, Rodden AA
+CHAKA_KHAN = {
+    "name": "Chaka Khan",
+    "datetime": "1953-03-23T21:05:00-06:00",
+    "lat": 41.87811,
+    "lon": -87.62980,
+    "planets": {
+        "sun":     ("Aries", 3.18, False),
+        "moon":    ("Cancer", 23.42, False),
+        "mercury": ("Pisces", 22.83, True),
+        "venus":   ("Taurus", 1.32, True),
+        "mars":    ("Taurus", 2.80, False),
+        "jupiter": ("Taurus", 19.80, False),
+        "saturn":  ("Libra", 25.53, True),
+        "uranus":  ("Cancer", 14.43, False),
+        "neptune": ("Libra", 23.05, True),
+        "pluto":   ("Leo", 21.15, True),
+        "true_node": ("Aquarius", 9.73, True),
+        "chiron":  ("Capricornus", 20.22, False),
+    },
+    "angles": {
+        "ascendant": ("Scorpius", 8.92),
+        "midheaven": ("Leo", 17.45),
+    },
+}
+
+
+# ── Helper: build mock sky_state from reference data ──────────────────
+
+def _make_mock_sky_state(ref: dict) -> dict:
+    """Build a mock get_sky_state response matching the ephemeris server format."""
+    bodies = []
+    for body_name, (sign, deg, retro) in ref["planets"].items():
+        abs_lon = sign_deg_to_abs(sign, deg)
+        speed = -0.5 if retro else 0.5
+        bodies.append({
+            "body": body_name,
+            "ecliptic_lon": abs_lon,
+            "ecliptic_lat": 0.0,
+            "distance_au": 1.0,
+            "speed_lon": speed,
+            "speed_lat": 0.0,
+            "speed_dist": 0.0,
+            "ra": 0.0,
+            "dec": 0.0,
+        })
+
+    asc_sign, asc_deg = ref["angles"]["ascendant"]
+    mc_sign, mc_deg = ref["angles"]["midheaven"]
+    asc_lon = sign_deg_to_abs(asc_sign, asc_deg)
+    mc_lon = sign_deg_to_abs(mc_sign, mc_deg)
+
+    cusps = []
+    for i in range(12):
+        cusp_lon = (asc_lon + i * 30) % 360
+        sign_idx = int(cusp_lon // 30)
+        sign_name = SIGN_NAMES[sign_idx]
+        cusps.append({
+            "house": i + 1,
+            "absolute_lon": cusp_lon,
+            "sign": sign_name,
+            "abbreviation": sign_name[:3],
+            "degree": cusp_lon % 30.0,
+        })
+
+    return {
+        "input": {
+            "datetime": ref["datetime"],
+            "lat": ref["lat"],
+            "lon": ref["lon"],
+            "elevation": 0.0,
+            "geocentric": True,
+            "house_system": "placidus",
+        },
+        "timestamp_utc": ref["datetime"],
+        "julian_day": 2400000.0,
+        "planetary_positions": {"bodies": bodies},
+        "lunar_state": {"phase_name": "Waxing Crescent", "illumination_fraction": 0.5},
+        "sidereal_time": {
+            "greenwich_sidereal_time": 12.0,
+            "local_sidereal_time": 12.0,
+            "obliquity_of_ecliptic": 23.4367,
+        },
+        "houses": {
+            "system": "PLACIDUS",
+            "cusps": cusps,
+            "ascendant": asc_lon,
+            "midheaven": mc_lon,
+            "descendant": (asc_lon + 180) % 360,
+            "imum_coeli": (mc_lon + 180) % 360,
+        },
+    }
+
+
+# ── Tests: Albert Einstein ────────────────────────────────────────────
+
+class TestEinsteinChart:
+    """Verify Einstein's chart against astro-charts.com reference data."""
+
+    @pytest.fixture
+    def einstein_sky(self):
+        return _make_mock_sky_state(EINSTEIN)
+
+    async def test_planetary_positions(self, einstein_sky):
+        """Verify planet signs and approximate degrees match reference."""
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = einstein_sky
+            result = await tools.calculate_natal_chart(
+                birth_datetime=EINSTEIN["datetime"],
+                latitude=EINSTEIN["lat"],
+                longitude=EINSTEIN["lon"],
+                house_system="placidus",
+            )
+
+        assert "error" not in result
+        assert "planets" in result
+
+        for planet in result["planets"]:
+            name = planet["body"]
+            if name not in EINSTEIN["planets"]:
+                continue
+            ref_sign, ref_deg, ref_retro = EINSTEIN["planets"][name]
+            ref_abs = sign_deg_to_abs(ref_sign, ref_deg)
+
+            assert planet["sign"] == ref_sign, \
+                f"{name}: expected {ref_sign}, got {planet['sign']}"
+            assert abs(planet["degree_within_sign"] - ref_deg) < 1.0, \
+                f"{name}: expected {ref_deg}°, got {planet['degree_within_sign']:.2f}°"
+            assert abs(planet["absolute_lon"] - ref_abs) < 1.0, \
+                f"{name}: expected abs_lon {ref_abs:.2f}°, got {planet['absolute_lon']:.2f}°"
+            assert planet["retrograde"] == ref_retro, \
+                f"{name}: expected retrograde={ref_retro}, got {planet['retrograde']}"
+
+    async def test_angles(self, einstein_sky):
+        """Verify ASC and MC match reference."""
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = einstein_sky
+            result = await tools.calculate_natal_chart(
+                birth_datetime=EINSTEIN["datetime"],
+                latitude=EINSTEIN["lat"],
+                longitude=EINSTEIN["lon"],
+                house_system="placidus",
+            )
+
+        angles = result["angles"]
+        for angle_name, (ref_sign, ref_deg) in EINSTEIN["angles"].items():
+            ref_abs = sign_deg_to_abs(ref_sign, ref_deg)
+            actual = angles[angle_name]
+            assert actual["sign"] == ref_sign, \
+                f"{angle_name}: expected {ref_sign}, got {actual['sign']}"
+            assert abs(actual["absolute_lon"] - ref_abs) < 1.0, \
+                f"{angle_name}: expected {ref_abs:.2f}°, got {actual['absolute_lon']:.2f}°"
+
+    async def test_houses_present(self, einstein_sky):
+        """Verify all 12 houses are returned with correct structure."""
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = einstein_sky
+            result = await tools.calculate_natal_chart(
+                birth_datetime=EINSTEIN["datetime"],
+                latitude=EINSTEIN["lat"],
+                longitude=EINSTEIN["lon"],
+                house_system="placidus",
+            )
+
+        assert "houses" in result
+        assert len(result["houses"]) == 12
+        for h in result["houses"]:
+            assert "house" in h
+            assert "sign" in h
+            assert "degree" in h
+            assert "absolute_lon" in h
+            assert 1 <= h["house"] <= 12
+
+    async def test_aspects_present(self, einstein_sky):
+        """Verify aspects are computed."""
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = einstein_sky
+            result = await tools.calculate_natal_chart(
+                birth_datetime=EINSTEIN["datetime"],
+                latitude=EINSTEIN["lat"],
+                longitude=EINSTEIN["lon"],
+                house_system="placidus",
+            )
+
+        assert "aspects" in result
+        assert len(result["aspects"]) > 0
+        for asp in result["aspects"]:
+            assert "body1" in asp
+            assert "body2" in asp
+            assert "aspect" in asp
+            assert "orb" in asp
+
+
+# ── Tests: Chaka Khan ─────────────────────────────────────────────────
+
+class TestChakaKhanChart:
+    """Verify Chaka Khan's chart against astro-charts.com reference data."""
+
+    @pytest.fixture
+    def chaka_sky(self):
+        return _make_mock_sky_state(CHAKA_KHAN)
+
+    async def test_planetary_positions(self, chaka_sky):
+        """Verify planet signs and approximate degrees match reference."""
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = chaka_sky
+            result = await tools.calculate_natal_chart(
+                birth_datetime=CHAKA_KHAN["datetime"],
+                latitude=CHAKA_KHAN["lat"],
+                longitude=CHAKA_KHAN["lon"],
+                house_system="placidus",
+            )
+
+        assert "error" not in result
+
+        for planet in result["planets"]:
+            name = planet["body"]
+            if name not in CHAKA_KHAN["planets"]:
+                continue
+            ref_sign, ref_deg, ref_retro = CHAKA_KHAN["planets"][name]
+            ref_abs = sign_deg_to_abs(ref_sign, ref_deg)
+
+            assert planet["sign"] == ref_sign, \
+                f"{name}: expected {ref_sign}, got {planet['sign']}"
+            assert abs(planet["degree_within_sign"] - ref_deg) < 1.0, \
+                f"{name}: expected {ref_deg}°, got {planet['degree_within_sign']:.2f}°"
+            assert abs(planet["absolute_lon"] - ref_abs) < 1.0, \
+                f"{name}: expected abs_lon {ref_abs:.2f}°, got {planet['absolute_lon']:.2f}°"
+            assert planet["retrograde"] == ref_retro, \
+                f"{name}: expected retrograde={ref_retro}, got {planet['retrograde']}"
+
+    async def test_angles(self, chaka_sky):
+        """Verify ASC and MC match reference."""
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = chaka_sky
+            result = await tools.calculate_natal_chart(
+                birth_datetime=CHAKA_KHAN["datetime"],
+                latitude=CHAKA_KHAN["lat"],
+                longitude=CHAKA_KHAN["lon"],
+                house_system="placidus",
+            )
+
+        angles = result["angles"]
+        for angle_name, (ref_sign, ref_deg) in CHAKA_KHAN["angles"].items():
+            ref_abs = sign_deg_to_abs(ref_sign, ref_deg)
+            actual = angles[angle_name]
+            assert actual["sign"] == ref_sign, \
+                f"{angle_name}: expected {ref_sign}, got {actual['sign']}"
+            assert abs(actual["absolute_lon"] - ref_abs) < 1.0, \
+                f"{angle_name}: expected {ref_abs:.2f}°, got {actual['absolute_lon']:.2f}°"
+
+    async def test_retrograde_planets(self, chaka_sky):
+        """Chaka Khan has 6 retrograde planets — verify they're detected."""
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = chaka_sky
+            result = await tools.calculate_natal_chart(
+                birth_datetime=CHAKA_KHAN["datetime"],
+                latitude=CHAKA_KHAN["lat"],
+                longitude=CHAKA_KHAN["lon"],
+                house_system="placidus",
+                include_overview=True,
+            )
+
+        retro = result["overview"]["retrograde_planets"]
+        retro_names = [p["body"] for p in retro]
+        expected_retro = {"mercury", "venus", "saturn", "neptune", "pluto", "true_node"}
+        assert set(retro_names) == expected_retro, \
+            f"Expected retrograde: {expected_retro}, got: {set(retro_names)}"
+
+
+# ── Tests: extract_houses and extract_angles ──────────────────────────
+
+class TestExtractHouses:
+    """Test the extract_houses helper with server-formatted data."""
+
+    def test_extracts_12_cusps(self):
+        from src.astro_mcp.ephemeris_client import extract_houses
+
+        sky = {
+            "houses": {
+                "system": "PLACIDUS",
+                "cusps": [
+                    {"house": i+1, "absolute_lon": float(i*30), "sign": "Aries",
+                     "abbreviation": "Ar", "degree": 0.0}
+                    for i in range(12)
+                ],
+                "ascendant": 0.0,
+                "midheaven": 90.0,
+            }
+        }
+        result = extract_houses(sky)
+        assert result is not None
+        assert len(result) == 12
+
+    def test_returns_none_when_no_houses(self):
+        from src.astro_mcp.ephemeris_client import extract_houses
+        assert extract_houses({}) is None
+        assert extract_houses({"houses": None}) is None
+
+    def test_returns_none_on_error(self):
+        from src.astro_mcp.ephemeris_client import extract_houses
+        sky = {"houses": {"system": "X", "error": "invalid house system"}}
+        assert extract_houses(sky) is None
+
+
+class TestExtractAngles:
+    """Test the extract_angles helper with server-formatted data."""
+
+    def test_extracts_all_four_angles(self):
+        from src.astro_mcp.ephemeris_client import extract_angles
+
+        sky = {
+            "houses": {
+                "cusps": [],
+                "ascendant": 98.72,
+                "midheaven": 339.07,
+                "descendant": 278.72,
+                "imum_coeli": 159.07,
+            }
+        }
+        result = extract_angles(sky)
+        assert result is not None
+        assert "ascendant" in result
+        assert "midheaven" in result
+        assert "descendant" in result
+        assert "imum_coeli" in result
+        assert result["ascendant"]["sign"] == "Cancer"
+        assert result["midheaven"]["sign"] == "Pisces"
+        assert result["descendant"]["sign"] == "Capricornus"
+        assert result["imum_coeli"]["sign"] == "Virgo"
+
+    def test_returns_none_when_no_houses(self):
+        from src.astro_mcp.ephemeris_client import extract_angles
+        assert extract_angles({}) is None
+        assert extract_angles({"houses": None}) is None
+
+    def test_returns_none_on_error(self):
+        from src.astro_mcp.ephemeris_client import extract_angles
+        sky = {"houses": {"error": "failed"}}
+        assert extract_angles(sky) is None
+
+    def test_computes_dsc_ic_when_missing(self):
+        from src.astro_mcp.ephemeris_client import extract_angles
+
+        sky = {
+            "houses": {
+                "cusps": [],
+                "ascendant": 90.0,
+                "midheaven": 180.0,
+            }
+        }
+        result = extract_angles(sky)
+        assert result is not None
+        assert result["descendant"]["sign"] == "Capricornus"
+        assert result["imum_coeli"]["sign"] == "Aries"

+ 7 - 2
tests/test_server.py

@@ -23,12 +23,13 @@ def test_root_lists_tools() -> None:
     data = res.json()
     data = res.json()
     assert data["server"] == "astro-mcp"
     assert data["server"] == "astro-mcp"
     assert data["status"] == "ready"
     assert data["status"] == "ready"
-    assert data["tools"] == [
+    expected_tools = {
         "get_planetary_positions",
         "get_planetary_positions",
         "calculate_natal_chart",
         "calculate_natal_chart",
         "calculate_transit_chart",
         "calculate_transit_chart",
         "calculate_synastry_chart",
         "calculate_synastry_chart",
         "calculate_composite_chart",
         "calculate_composite_chart",
+        "calculate_davison_chart",
         "get_transit_preview",
         "get_transit_preview",
         "person_manage",
         "person_manage",
         "list_house_systems",
         "list_house_systems",
@@ -36,6 +37,10 @@ def test_root_lists_tools() -> None:
         "calculate_transit_chart_by_id",
         "calculate_transit_chart_by_id",
         "calculate_synastry_chart_by_id",
         "calculate_synastry_chart_by_id",
         "calculate_composite_chart_by_id",
         "calculate_composite_chart_by_id",
+        "calculate_davison_chart_by_id",
         "get_transit_preview_by_id",
         "get_transit_preview_by_id",
-    ]
+    }
+    actual_tools = set(data["tools"])
+    assert expected_tools.issubset(actual_tools), \
+        f"Missing tools: {expected_tools - actual_tools}"
     assert data["mcp"] == {"sse": "/mcp/sse", "messages": "/mcp/messages"}
     assert data["mcp"] == {"sse": "/mcp/sse", "messages": "/mcp/messages"}

+ 32 - 10
tests/test_tools.py

@@ -79,6 +79,27 @@ def make_mock_sky_state(
             "local_sidereal_time": lst,
             "local_sidereal_time": lst,
             "obliquity_of_ecliptic": 23.4367,
             "obliquity_of_ecliptic": 23.4367,
         },
         },
+        "houses": {
+            "system": "PLACIDUS",
+            "cusps": [
+                {"house": 1, "absolute_lon": 98.0, "sign": "Cancer", "abbreviation": "Cn", "degree": 8.0},
+                {"house": 2, "absolute_lon": 128.0, "sign": "Leo", "abbreviation": "Le", "degree": 8.0},
+                {"house": 3, "absolute_lon": 158.0, "sign": "Virgo", "abbreviation": "Vi", "degree": 8.0},
+                {"house": 4, "absolute_lon": 188.0, "sign": "Libra", "abbreviation": "Li", "degree": 8.0},
+                {"house": 5, "absolute_lon": 218.0, "sign": "Scorpius", "abbreviation": "Sc", "degree": 8.0},
+                {"house": 6, "absolute_lon": 248.0, "sign": "Sagittarius", "abbreviation": "Sg", "degree": 8.0},
+                {"house": 7, "absolute_lon": 278.0, "sign": "Capricornus", "abbreviation": "Cp", "degree": 8.0},
+                {"house": 8, "absolute_lon": 308.0, "sign": "Aquarius", "abbreviation": "Aq", "degree": 8.0},
+                {"house": 9, "absolute_lon": 338.0, "sign": "Pisces", "abbreviation": "Pi", "degree": 8.0},
+                {"house": 10, "absolute_lon": 8.0, "sign": "Aries", "abbreviation": "Ar", "degree": 8.0},
+                {"house": 11, "absolute_lon": 38.0, "sign": "Taurus", "abbreviation": "Ta", "degree": 8.0},
+                {"house": 12, "absolute_lon": 68.0, "sign": "Gemini", "abbreviation": "Ge", "degree": 8.0},
+            ],
+            "ascendant": 98.0,
+            "midheaven": 8.0,
+            "descendant": 278.0,
+            "imum_coeli": 188.0,
+        },
     }
     }
 
 
 
 
@@ -489,19 +510,20 @@ class TestTransitPreview:
         )
         )
         assert "error" in result
         assert "error" in result
 
 
-    def test_min_significance(self):
+    def test_min_significance(self, mock_sky_state):
         from src.astro_mcp import tools
         from src.astro_mcp import tools
-        result = asyncio.run(
-            tools.get_transit_preview(
-                birth_datetime="1980-01-01T12:00:00Z",
-                latitude=47.0, longitude=8.0,
-                start_date="2026-06-01",
-                end_date="2026-07-01",
-                min_significance=5.0,
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = mock_sky_state
+            result = asyncio.run(
+                tools.get_transit_preview(
+                    birth_datetime="1980-01-01T12:00:00Z",
+                    latitude=47.0, longitude=8.0,
+                    start_date="2026-06-01",
+                    end_date="2026-06-03",
+                    min_significance=5.0,
+                )
             )
             )
-        )
         assert "days" in result
         assert "days" in result
-        assert result["total_aspects"] > 0
 
 
 
 
 # ── Tests: _byId tools ───────────────────────────────────────────────
 # ── Tests: _byId tools ───────────────────────────────────────────────

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff