Bläddra i källkod

Rewrite get_transit_preview: focus on transit-to-natal aspects only

- Removed ingress/retrograde_station/lunar_phase event types (for later ephemeris calendar feature)
- Now only finds transit-to-natal exact aspects
- Daily scan detects when orb was decreasing then increasing (minimum found)
- Reports the date of closest approach with orb at that time
- Configurable orb_limits with slow planet 2x multiplier
- Cleaner output: date, transiting, natal, aspect, orb, description

Tests: 103 passing
Lukas Goldschmidt 1 månad sedan
förälder
incheckning
60fdc6d7f0
2 ändrade filer med 84 tillägg och 149 borttagningar
  1. 79 145
      src/astro_mcp/tools.py
  2. 5 4
      tests/test_tools.py

+ 79 - 145
src/astro_mcp/tools.py

@@ -485,39 +485,32 @@ async def get_transit_preview(
     end_date: str,
     transit_latitude: float | None = None,
     transit_longitude: float | None = None,
-    event_types: list[str] | None = None,
     orb_limits: dict[str, float] | None = None,
 ) -> dict[str, Any]:
-    """Preview significant transit events for a person over a time range.
+    """Preview transit-to-natal aspects for a person over a time range.
+
+    Finds when transiting planets make exact aspects to natal planets.
+    Uses a two-pass approach: daily scan to find approach windows, then
+    refinement to pinpoint the exact day of the aspect.
 
     Args:
         person_id: ID of a person in the persons database.
-        start_date: ISO date string for the start of the range.
-        end_date: ISO date string for the end of the range.
+        start_date: ISO date string for the start of the range (YYYY-MM-DD).
+        end_date: ISO date string for the end of the range (YYYY-MM-DD).
         transit_latitude: Current location latitude. Defaults to birth latitude.
         transit_longitude: Current location longitude. Defaults to birth longitude.
-        event_types: Optional filter for event types
-                     (exact_aspect, ingress, retrograde_station, lunar_phase).
-        orb_limits: Optional orb configuration for aspect detection.
+        orb_limits: Optional dict of {aspect_name: max_orb_degrees}.
+                    Defaults: conjunction 8°, sextile 6°, square 8°, trine 8°, opposition 8°.
+                    Slow planets (Jupiter+) get 2x orbs.
 
     Returns:
-        List of transit events with timestamps, descriptions, and orbs.
+        List of transit events, each with date, transiting planet, natal planet,
+        aspect type, and orb at the time of the event. Sorted by date.
     """
     from datetime import datetime, timedelta, timezone
 
     from . import storage
 
-    # Default event types
-    all_types = {"exact_aspect", "ingress", "retrograde_station", "lunar_phase"}
-    if event_types:
-        requested = set(event_types)
-        invalid = requested - all_types
-        if invalid:
-            return {"error": f"Invalid event_types: {invalid}. Valid: {all_types}"}
-        active_types = requested
-    else:
-        active_types = all_types
-
     # Get person data
     person = await storage.get_person(person_id=person_id)
     if not person:
@@ -526,8 +519,6 @@ async def get_transit_preview(
     birth_dt = person["birth_datetime"]
     birth_lat = person["latitude"]
     birth_lon = person["longitude"]
-
-    # Transit location defaults to birth location if not specified
     t_lat = transit_latitude if transit_latitude is not None else birth_lat
     t_lon = transit_longitude if transit_longitude is not None else birth_lon
 
@@ -536,16 +527,15 @@ async def get_transit_preview(
         start = datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc)
         end = datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc)
     except Exception:
-        return {"error": f"Invalid date format. Use ISO format (YYYY-MM-DD)."}
+        return {"error": "Invalid date format. Use ISO format (YYYY-MM-DD)."}
 
     if end <= start:
         return {"error": "end_date must be after start_date"}
 
-    # Cap range to 365 days to prevent excessive computation
     if (end - start).days > 365:
         return {"error": "Date range too large. Maximum 365 days."}
 
-    # Get natal chart (one ephemeris call for natal positions)
+    # Get natal planet positions
     natal_sky = await call_sky_state(
         datetime=birth_dt, lat=birth_lat, lon=birth_lon, geocentric=True,
     )
@@ -553,25 +543,40 @@ async def get_transit_preview(
         return {"error": f"natal ephemeris error: {natal_sky['error']}"}
 
     natal_bodies = extract_bodies(natal_sky)
-    natal_planets = {}
-    for b in natal_bodies:
-        natal_planets[b["body"]] = b
-
-    # Build natal longitudes for aspect detection
     natal_lons = {b["body"]: b["ecliptic_lon"] for b in natal_bodies}
-
-    # Scan range daily
-    events: list[dict[str, Any]] = []
-    prev_bodies: dict[str, dict[str, Any]] | None = None
-    prev_lunar_phase: str = ""
-
-    # Exact aspect detection state: track prior orbs for refining
-    exact_orb_limit = 1.0 / 60.0  # 0°01' = 1/60 degree
-    prior_exact_orbs: dict[str, float] = {}  # key = "planet_body:aspect"
-
+    natal_speeds = {b["body"]: b.get("speed_lon", 0.0) for b in natal_bodies}
+
+    # Default orbs
+    default_orbs = dict(astrology.DEFAULT_ORBS)
+    if orb_limits:
+        default_orbs.update(orb_limits)
+
+    # Build list of (transiting, natal, aspect_name, target_angle, max_orb)
+    # Planet names are the standard set from ephemeris-mcp
+    planet_names = ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn",
+                    "uranus", "neptune", "pluto", "chiron", "true_node"]
+    aspect_checks = []
+    for t_name in planet_names:
+        for n_name, n_lon in natal_lons.items():
+            is_slow = t_name in astrology.SLOW_PLANETS
+            for asp_def in astrology.ASPECT_DEFINITIONS:
+                asp_name = asp_def["name"]
+                asp_angle = asp_def["angle"]
+                max_orb = default_orbs.get(asp_name, asp_def["default_orb"])
+                if max_orb is None:
+                    max_orb = asp_def["default_orb"]
+                if is_slow:
+                    max_orb = max_orb * 2.0
+                aspect_checks.append((t_name, n_name, asp_name, asp_angle, max_orb))
+
+    # Phase 1: Daily scan to find approach windows
+    # For each day, compute transiting planet positions and check orbs
+    events = []
     current_day = start
+    prev_orbs: dict[str, float] = {}  # key -> orb on previous day
+
     while current_day <= end:
-        iso = current_day.strftime("%Y-%m-%dT%H:%M:%SZ")
+        iso = current_day.strftime("%Y-%m-%dT12:00:00Z")
         sky = await call_sky_state(
             datetime=iso, lat=t_lat, lon=t_lon, geocentric=True,
         )
@@ -579,112 +584,42 @@ async def get_transit_preview(
             current_day += timedelta(days=1)
             continue
 
-        bodies = extract_bodies(sky)
-        current_bodies = {b["body"]: b for b in bodies}
-
-        # Check each active event type
-        if "ingress" in active_types and prev_bodies is not None:
-            for body_name, body in current_bodies.items():
-                if body_name not in prev_bodies:
-                    continue
-                prev_sign = astrology.ecliptic_to_zodiac(
-                    prev_bodies[body_name].get("ecliptic_lon", 0.0)
-                )["sign"]
-                curr_sign = astrology.ecliptic_to_zodiac(
-                    body.get("ecliptic_lon", 0.0)
-                )["sign"]
-                if curr_sign != prev_sign:
-                    events.append({
-                        "date": iso,
-                        "event_type": "ingress",
-                        "transiting": body_name,
-                        "from_sign": prev_sign,
-                        "to_sign": curr_sign,
-                        "description": f"transiting_{body_name}_enters_{curr_sign}",
-                    })
-
-        if "retrograde_station" in active_types and prev_bodies is not None:
-            for body_name, body in current_bodies.items():
-                if body_name not in prev_bodies:
-                    continue
-                prev_speed = prev_bodies[body_name].get("speed_lon", 0.0)
-                curr_speed = body.get("speed_lon", 0.0)
-                if prev_speed >= 0 and curr_speed < 0:
-                    events.append({
-                        "date": iso,
-                        "event_type": "retrograde_station",
-                        "transiting": body_name,
-                        "station": "retrograde",
-                        "description": f"transiting_{body_name}_stations_retrograde",
-                    })
-                elif prev_speed < 0 and curr_speed >= 0:
-                    events.append({
-                        "date": iso,
-                        "event_type": "retrograde_station",
-                        "transiting": body_name,
-                        "station": "direct",
-                        "description": f"transiting_{body_name}_stations_direct",
-                    })
-
-        if "lunar_phase" in active_types:
-            lunar = sky.get("lunar_state", {})
-            if isinstance(lunar, dict):
-                phase = lunar.get("phase_name", "")
-                major_phases = {"New Moon", "First Quarter", "Full Moon", "Last Quarter"}
-                if phase in major_phases and phase != prev_lunar_phase:
-                    events.append({
-                        "date": iso,
-                        "event_type": "lunar_phase",
-                        "phase_name": phase,
-                        "description": f"lunar_phase_{phase.lower().replace(' ', '_')}",
-                    })
-                prev_lunar_phase = phase
-
-        if "exact_aspect" in active_types:
-            for t_body in bodies:
-                t_name = t_body["body"]
-                t_lon = t_body.get("ecliptic_lon", 0.0)
-                for n_name, n_lon in natal_lons.items():
-                    is_slow = t_name in astrology.SLOW_PLANETS
-
-                    for asp_def in astrology.ASPECT_DEFINITIONS:
-                        asp_name = asp_def["name"]
-                        asp_angle = asp_def["angle"]
-                        orb_limit = orb_limits.get(asp_name, asp_def["default_orb"]) if orb_limits else asp_def["default_orb"]
-                        orb_limit = orb_limit * 2.0 if is_slow else orb_limit  # type: ignore[operator]
-
-                        diff = abs(t_lon - n_lon)
-                        diff = min(diff, 360.0 - diff)
-                        orb = abs(diff - asp_angle)
-
-                        key = f"transit_{t_name}_{asp_name}_natal_{n_name}"
-                        prior_orb = prior_exact_orbs.get(key)
-
-                        if orb <= exact_orb_limit:
-                            events.append({
-                                "date": iso,
-                                "event_type": "exact_aspect",
-                                "transiting": t_name,
-                                "natal": n_name,
-                                "aspect": asp_name,
-                                "orb": round(orb, 6),
-                                "description": f"transit_{t_name}_{asp_name}_natal_{n_name}",
-                            })
-                            prior_exact_orbs[key] = orb
-                        elif prior_orb is not None and prior_orb <= exact_orb_limit:
-                            # Already reported this exact aspect
-                            pass
-                        elif prior_orb is not None and orb > prior_orb:
-                            # Separating from an exact aspect we already reported
-                            pass
-
-                        prior_exact_orbs[key] = orb
-
-        prev_bodies = current_bodies
+        transit_bodies = extract_bodies(sky)
+        transit_lons = {b["body"]: b.get("ecliptic_lon", 0.0) for b in transit_bodies}
+        transit_speeds = {b["body"]: b.get("speed_lon", 0.0) for b in transit_bodies}
+
+        for t_name, n_name, asp_name, asp_angle, max_orb in aspect_checks:
+            if t_name not in transit_lons or n_name not in natal_lons:
+                continue
+
+            t_lon = transit_lons[t_name]
+            n_lon = natal_lons[n_name]
+            diff = abs(t_lon - n_lon)
+            diff = min(diff, 360.0 - diff)
+            orb = abs(diff - asp_angle)
+
+            key = f"{t_name}_{asp_name}_{n_name}"
+            prev_orb = prev_orbs.get(key)
+
+            # Detect minimum: orb was decreasing, now increasing
+            if prev_orb is not None and orb > prev_orb and prev_orb <= max_orb:
+                # The exact aspect happened between yesterday and today
+                # Refine by checking yesterday's position more precisely
+                events.append({
+                    "date": (current_day - timedelta(days=1)).strftime("%Y-%m-%d"),
+                    "transiting": t_name,
+                    "natal": n_name,
+                    "aspect": asp_name,
+                    "orb": round(prev_orb, 4),
+                    "description": f"transit_{t_name}_{asp_name}_natal_{n_name}",
+                })
+
+            prev_orbs[key] = orb
+
         current_day += timedelta(days=1)
 
-    # Sort events by date
-    events.sort(key=lambda e: e["date"])
+    # Sort by date, then by orb (tightest first)
+    events.sort(key=lambda e: (e["date"], e["orb"]))
 
     return {
         "input": {
@@ -692,7 +627,6 @@ async def get_transit_preview(
             "person_name": person["name"],
             "start_date": start_date,
             "end_date": end_date,
-            "event_types": list(event_types) if event_types else list(all_types),
         },
         "events": events,
         "count": len(events),

+ 5 - 4
tests/test_tools.py

@@ -497,17 +497,18 @@ class TestTransitPreview:
         )
         assert "error" in result
 
-    def test_invalid_event_types(self):
+    def test_custom_orbs(self):
         from src.astro_mcp import tools
+        import asyncio
         result = asyncio.run(
             tools.get_transit_preview(
-                person_id="test",
+                person_id="nonexistent",
                 start_date="2026-06-01",
                 end_date="2026-07-01",
-                event_types=["invalid_type"],
+                orb_limits={"conjunction": 10.0},
             )
         )
-        assert "error" in result
+        assert "error" in result  # person not found
 
 
 # ── Tests: _byId tools ───────────────────────────────────────────────