1
0

3 Коміти 13cc83329b ... e7850621fa

Автор SHA1 Опис Дата
  Lukas Goldschmidt e7850621fa chore: bump version to 0.10.0 1 місяць тому
  Lukas Goldschmidt 06c0dbe629 refactor: use server-side houses from ephemeris-mcp, remove local fallbacks 1 місяць тому
  Lukas Goldschmidt 0c6a503676 fix: house calculation, MC formula, LST correction, zodiac direction 1 місяць тому

+ 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."""
 
-__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
 
 
-# ── 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 ──────────────────────────────────────────────────
@@ -535,7 +297,7 @@ def get_house_placement(
 
     Args:
         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:
         House number (1-12).

Різницю між файлами не показано, бо вона завелика
+ 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,
     elevation: float = 0.0,
     geocentric: bool = True,
+    house_system: str | None = None,
 ) -> dict[str, Any]:
     """Call ephemeris-mcp:get_sky_state and return the result dict.
 
     Datetime is normalized to UTC ISO format (no timezone offset)
     before sending to the ephemeris server.
+
+    The ephemeris server returns Greenwich sidereal_time regardless of
+    the lon parameter. We correct local_sidereal_time here by adding
+    the longitude offset (15° = 1 hour, east positive).
+
+    When house_system is provided, the response includes house cusps
+    and angles computed by the Swiss Ephemeris on the server side.
     """
     # Normalize datetime to UTC ISO string
     dt_str = _normalize_datetime(datetime)
@@ -105,18 +113,107 @@ async def call_sky_state(
                         "lon": lon,
                         "elevation": elevation,
                         "geocentric": geocentric,
+                        "house_system": house_system,
                     },
                 )
                 payload = _payload_from_result(result)
                 if not payload:
                     logger.warning("ephemeris-mcp returned empty payload")
                     return {"error": "empty_response", "url": url}
+
+                # Fix local sidereal time: ephemeris may return incorrect LST.
+                # Compute it properly from Greenwich ST + longitude.
+                # East longitude positive, 15° = 1 hour.
+                if "sidereal_time" in payload:
+                    st = payload["sidereal_time"]
+                    if isinstance(st, dict):
+                        gst = st.get("greenwich_sidereal_time",
+                                     st.get("local_sidereal_time", 0.0))
+                        if isinstance(gst, (int, float)):
+                            st["local_sidereal_time"] = (gst + lon / 15.0) % 24.0
+
                 return payload
     except Exception as exc:
         logger.error(f"ephemeris-mcp call failed: {exc}")
         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]]:
     """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 . 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")
 
@@ -161,17 +161,17 @@ Returns:
         lon=longitude,
         elevation=elevation,
         geocentric=True,
+        house_system=house_system,
     )
 
     if "error" in sky:
         return {"input": {"birth_datetime": birth_datetime, "latitude": latitude, "longitude": longitude}, "error": sky["error"]}
 
     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
     planets = []
@@ -214,9 +214,6 @@ Returns:
             "exactness": asp["exactness"],
         })
 
-    # Calculate angles
-    angles = astrology.calculate_angles(lst_hours, latitude)
-
     # Build result
     result: dict[str, Any] = {
         "input": {
@@ -344,6 +341,7 @@ Returns:
         lon=longitude,
         elevation=elevation,
         geocentric=True,
+        house_system=house_system,
     )
 
     # Get transit sky state at transit location
@@ -363,10 +361,8 @@ Returns:
     natal_bodies = extract_bodies(natal_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
     natal_planets = []
@@ -482,8 +478,8 @@ Returns:
     Dict with: input, chart_type, chart1_natal, chart2_natal, interaspects,
     house_overlays, composite_chart, davison_chart, summary (top_aspects,
     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:
         return {"error": f"person1: {sky1['error']}"}
@@ -493,13 +489,9 @@ Returns:
     bodies1 = extract_bodies(sky1)
     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):
         result = []
@@ -605,13 +597,11 @@ Returns:
         davison_dt = _jd_to_datetime(davison["date_midpoint_jd"])
         davison_sky = await call_sky_state(
             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:
             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 = []
             for body in davison_raw:
@@ -640,7 +630,7 @@ Returns:
                     "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["houses"] = davison_houses
@@ -1030,13 +1020,12 @@ Returns:
     # Use a date near the midpoint for house calculation
     comp_sky = await call_sky_state(
         datetime=person1_datetime, lat=comp_lat, lon=comp_lon, elevation=elevation,
+        house_system=house_system,
     )
     if "error" in comp_sky:
         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
     composite_planets = []
@@ -1067,8 +1056,7 @@ Returns:
             "exactness": asp["exactness"],
         })
 
-    # Angles
-    angles = astrology.calculate_angles(lst_hours, comp_lat)
+    angles = extract_angles(comp_sky)
 
     return {
         "input": {
@@ -1126,15 +1114,13 @@ Returns:
 
     sky = await call_sky_state(
         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:
         return {"error": f"davison ephemeris error: {sky['error']}"}
 
     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 = []
     for body in raw_bodies:
@@ -1163,7 +1149,7 @@ Returns:
             "exactness": asp["exactness"],
         })
 
-    angles = astrology.calculate_angles(lst_hours, mid_lat)
+    angles = extract_angles(sky)
 
     return {
         "input": {
@@ -1540,19 +1526,38 @@ Returns:
 def list_house_systems() -> dict[str, Any]:
     """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 {
         "systems": [
             {"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": "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()
     assert data["server"] == "astro-mcp"
     assert data["status"] == "ready"
-    assert data["tools"] == [
+    expected_tools = {
         "get_planetary_positions",
         "calculate_natal_chart",
         "calculate_transit_chart",
         "calculate_synastry_chart",
         "calculate_composite_chart",
+        "calculate_davison_chart",
         "get_transit_preview",
         "person_manage",
         "list_house_systems",
@@ -36,6 +37,10 @@ def test_root_lists_tools() -> None:
         "calculate_transit_chart_by_id",
         "calculate_synastry_chart_by_id",
         "calculate_composite_chart_by_id",
+        "calculate_davison_chart_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"}

+ 32 - 10
tests/test_tools.py

@@ -79,6 +79,27 @@ def make_mock_sky_state(
             "local_sidereal_time": lst,
             "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
 
-    def test_min_significance(self):
+    def test_min_significance(self, mock_sky_state):
         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 result["total_aspects"] > 0
 
 
 # ── Tests: _byId tools ───────────────────────────────────────────────

Деякі файли не було показано, через те що забагато файлів було змінено