|
|
@@ -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),
|