|
@@ -1,2705 +1,66 @@
|
|
|
"""
|
|
"""
|
|
|
MCP tool definitions for astro-mcp.
|
|
MCP tool definitions for astro-mcp.
|
|
|
|
|
|
|
|
-All tools are async and use the ephemeris client to get astronomical data,
|
|
|
|
|
-then the astrology module to transform it into astrological structures.
|
|
|
|
|
|
|
+This module is a thin facade that re-exports all tools from the specialized
|
|
|
|
|
+submodules. It exists for backward compatibility — server.py imports this
|
|
|
|
|
+module to trigger @mcp.tool() registration, and tests import tools.* to
|
|
|
|
|
+access individual functions.
|
|
|
|
|
+
|
|
|
|
|
+Tool modules:
|
|
|
|
|
+ chart_tools — direct-call chart calculation functions
|
|
|
|
|
+ by_id_tools — database-backed _byId chart tools
|
|
|
|
|
+ person_tools — person database CRUD + house system listing
|
|
|
|
|
+ render_tools — chart rendering (SVG/PNG/JPG) tools
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
-import logging
|
|
|
|
|
-from typing import Any
|
|
|
|
|
-
|
|
|
|
|
-from .server import mcp
|
|
|
|
|
-from . import astrology
|
|
|
|
|
-from .ephemeris_client import call_sky_state, extract_bodies, extract_houses, extract_angles
|
|
|
|
|
-
|
|
|
|
|
-logger = logging.getLogger("astro-mcp.tools")
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-DEFAULT_ORBS = astrology.DEFAULT_ORBS
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── Tool: get_planetary_positions ────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def get_planetary_positions(
|
|
|
|
|
- datetime: str | None = None,
|
|
|
|
|
- lat: float | None = None,
|
|
|
|
|
- lon: float | None = None,
|
|
|
|
|
- elevation: float = 0.0,
|
|
|
|
|
- geocentric: bool = True,
|
|
|
|
|
- bodies: list[str] | None = None,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Get planetary positions enhanced with zodiac signs, degrees, and retrograde flags.
|
|
|
|
|
-
|
|
|
|
|
- Use for quick position lookups without full chart calculation. For complete natal
|
|
|
|
|
- chart interpretation, use calculate_natal_chart instead.
|
|
|
|
|
-
|
|
|
|
|
- Args:
|
|
|
|
|
- datetime: ISO 8601 datetime (UTC). Defaults to now.
|
|
|
|
|
- lat: Observer latitude in decimal degrees.
|
|
|
|
|
- lon: Observer longitude in decimal degrees.
|
|
|
|
|
- elevation: Observer elevation in meters.
|
|
|
|
|
- geocentric: If True, return geocentric positions.
|
|
|
|
|
- bodies: Optional list of body names to filter (e.g., ["sun", "moon"]).
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Object with input echo, timestamp, julian_day, and bodies array. Each body
|
|
|
|
|
- includes ecliptic_lon, ecliptic_lat, sign, degree_within_sign, retrograde flag,
|
|
|
|
|
- speed_lon, and distance.
|
|
|
|
|
- """
|
|
|
|
|
- resolved_lat = lat if lat is not None else 0.0
|
|
|
|
|
- resolved_lon = lon if lon is not None else 0.0
|
|
|
|
|
-
|
|
|
|
|
- sky = await call_sky_state(
|
|
|
|
|
- datetime=datetime,
|
|
|
|
|
- lat=resolved_lat,
|
|
|
|
|
- lon=resolved_lon,
|
|
|
|
|
- elevation=elevation,
|
|
|
|
|
- geocentric=geocentric,
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- if "error" in sky:
|
|
|
|
|
- return {"input": {"datetime": datetime, "lat": resolved_lat, "lon": resolved_lon}, "error": sky["error"]}
|
|
|
|
|
-
|
|
|
|
|
- raw_bodies = extract_bodies(sky)
|
|
|
|
|
-
|
|
|
|
|
- enhanced_bodies = []
|
|
|
|
|
- for body in raw_bodies:
|
|
|
|
|
- name = body.get("body", "unknown")
|
|
|
|
|
- if bodies and name not in bodies:
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- ecl_lon = body.get("ecliptic_lon", 0.0)
|
|
|
|
|
- ecl_lat = body.get("ecliptic_lat", 0.0)
|
|
|
|
|
- speed_lon = body.get("speed_lon")
|
|
|
|
|
- distance_au = body.get("distance_au", 0.0)
|
|
|
|
|
-
|
|
|
|
|
- zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
|
|
|
|
|
- retrograde = astrology.is_retrograde(speed_lon)
|
|
|
|
|
-
|
|
|
|
|
- enhanced_bodies.append({
|
|
|
|
|
- "body": name,
|
|
|
|
|
- "ecliptic_lon": ecl_lon,
|
|
|
|
|
- "ecliptic_lat": ecl_lat,
|
|
|
|
|
- "distance_au": distance_au,
|
|
|
|
|
- "speed_lon": speed_lon,
|
|
|
|
|
- "sign": zodiac["sign"],
|
|
|
|
|
- "sign_abbreviation": zodiac["abbreviation"],
|
|
|
|
|
- "degree_within_sign": zodiac["degree"],
|
|
|
|
|
- "retrograde": retrograde,
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- "input": {
|
|
|
|
|
- "datetime": datetime,
|
|
|
|
|
- "lat": resolved_lat,
|
|
|
|
|
- "lon": resolved_lon,
|
|
|
|
|
- "elevation": elevation,
|
|
|
|
|
- "geocentric": geocentric,
|
|
|
|
|
- "bodies_filter": bodies,
|
|
|
|
|
- },
|
|
|
|
|
- "timestamp_utc": sky.get("timestamp_utc"),
|
|
|
|
|
- "julian_day": sky.get("julian_day"),
|
|
|
|
|
- "bodies": enhanced_bodies,
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── Tool: calculate_natal_chart ──────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def calculate_natal_chart(
|
|
|
|
|
- birth_datetime: str,
|
|
|
|
|
- latitude: float,
|
|
|
|
|
- longitude: float,
|
|
|
|
|
- elevation: float = 0.0,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
- include_overview: bool = False,
|
|
|
|
|
- include_patterns: bool = False,
|
|
|
|
|
- include_karmic: bool = False,
|
|
|
|
|
- top_n_aspects: int | None = None,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Calculate a complete natal chart from birth data.
|
|
|
|
|
-
|
|
|
|
|
- birth_datetime MUST be UTC. For DB-backed charts, use the _byId variant
|
|
|
|
|
- (calculate_natal_chart_by_id) which handles timezone conversion automatically.
|
|
|
|
|
-
|
|
|
|
|
-PRIMARY TOOL for natal astrology. Returns planetary positions, houses, aspects,
|
|
|
|
|
-and angles. Use the optional flags to add interpretation layers.
|
|
|
|
|
-
|
|
|
|
|
-WORKFLOW:
|
|
|
|
|
-1. Basic chart: call without flags to get planets, houses, aspects, angles.
|
|
|
|
|
-2. Add overview (include_overview=true): element/modality/hemisphere balance,
|
|
|
|
|
- stelliums, empty houses, chart ruler, house rulers, retrograde list.
|
|
|
|
|
-3. Add patterns (include_patterns=true): T-square, Grand Trine, Grand Cross,
|
|
|
|
|
- Yod detection + chart shape (bundle, bowl, splash, locomotive, seesaw, splay).
|
|
|
|
|
-4. Add karmic (include_karmic=true): nodal axis, Saturn, Pluto polarity point,
|
|
|
|
|
- Part of Fortune, 12th house, nodal/Saturn hard aspects.
|
|
|
|
|
-5. Full reading: all three flags true.
|
|
|
|
|
-
|
|
|
|
|
-For interpretation guidance, fetch resource: astro://guides/natal-astrology
|
|
|
|
|
-
|
|
|
|
|
-Args:
|
|
|
|
|
- birth_datetime: ISO 8601 birth datetime with timezone (e.g., "1990-05-15T10:30:00+01:00").
|
|
|
|
|
- latitude: Birth latitude in decimal degrees (-90 to 90).
|
|
|
|
|
- longitude: Birth longitude in decimal degrees (-180 to 180).
|
|
|
|
|
- elevation: Birth elevation in meters (default: 0).
|
|
|
|
|
- house_system: "placidus" (default), "equal", or "whole_sign".
|
|
|
|
|
- orb_limits: Optional per-aspect orb overrides, e.g., {"conjunction": 10}.
|
|
|
|
|
- include_overview: Add element/modality/hemisphere balance, stelliums, empty houses,
|
|
|
|
|
- chart ruler, house rulers, planet groupings, retrograde list.
|
|
|
|
|
- include_patterns: Add aspect pattern detection and chart shape classification.
|
|
|
|
|
- include_karmic: Add nodal axis, Saturn, Pluto polarity point, Part of Fortune,
|
|
|
|
|
- 12th house, and karmic aspect filters.
|
|
|
|
|
- top_n_aspects: Limit aspects output to the N tightest by orb.
|
|
|
|
|
-
|
|
|
|
|
-Returns:
|
|
|
|
|
- Dict with: input, chart_type, planets, houses, aspects, angles, lunar_phase,
|
|
|
|
|
- and optionally: overview, aspect_patterns, chart_shape, karmic."""
|
|
|
|
|
- # birth_datetime is UTC: _byId callers pre-convert via _get_person_birth_data,
|
|
|
|
|
- # direct-call users are responsible for passing UTC or offset-aware datetime.
|
|
|
|
|
- sky = await call_sky_state(
|
|
|
|
|
- datetime=birth_datetime,
|
|
|
|
|
- lat=latitude,
|
|
|
|
|
- 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)
|
|
|
|
|
-
|
|
|
|
|
- # Houses and angles from server-side Swiss Ephemeris
|
|
|
|
|
- houses = extract_houses(sky)
|
|
|
|
|
- angles = extract_angles(sky)
|
|
|
|
|
-
|
|
|
|
|
- # Build planet list with house placement
|
|
|
|
|
- planets = []
|
|
|
|
|
- for body in raw_bodies:
|
|
|
|
|
- ecl_lon = body.get("ecliptic_lon", 0.0)
|
|
|
|
|
- ecl_lat = body.get("ecliptic_lat", 0.0)
|
|
|
|
|
- speed_lon = body.get("speed_lon")
|
|
|
|
|
- zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
|
|
|
|
|
- house = astrology.get_house_placement(ecl_lon, houses)
|
|
|
|
|
-
|
|
|
|
|
- planets.append({
|
|
|
|
|
- "body": body["body"],
|
|
|
|
|
- "sign": zodiac["sign"],
|
|
|
|
|
- "sign_abbreviation": zodiac["abbreviation"],
|
|
|
|
|
- "degree_within_sign": zodiac["degree"],
|
|
|
|
|
- "absolute_lon": zodiac["absolute_lon"],
|
|
|
|
|
- "ecliptic_lat": ecl_lat,
|
|
|
|
|
- "distance_au": body.get("distance_au", 0.0),
|
|
|
|
|
- "house": house,
|
|
|
|
|
- "retrograde": astrology.is_retrograde(speed_lon),
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- # Calculate aspects (pass speed_lon for applying/separating detection)
|
|
|
|
|
- speed_lookup = {b["body"]: b.get("speed_lon") for b in raw_bodies}
|
|
|
|
|
- aspect_bodies = [
|
|
|
|
|
- {"name": p["body"], "lon": p["absolute_lon"], "speed_lon": speed_lookup.get(p["body"])}
|
|
|
|
|
- for p in planets
|
|
|
|
|
- ]
|
|
|
|
|
- aspects = astrology.compute_aspects(aspect_bodies, orb_limits)
|
|
|
|
|
-
|
|
|
|
|
- # Format aspects
|
|
|
|
|
- formatted_aspects = []
|
|
|
|
|
- for asp in aspects:
|
|
|
|
|
- formatted_aspects.append({
|
|
|
|
|
- "body1": asp["body1"],
|
|
|
|
|
- "body2": asp["body2"],
|
|
|
|
|
- "aspect": asp["aspect"],
|
|
|
|
|
- "orb": asp["orb"],
|
|
|
|
|
- "applying": asp["applying"],
|
|
|
|
|
- "exactness": asp["exactness"],
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- # Build result
|
|
|
|
|
- result: dict[str, Any] = {
|
|
|
|
|
- "input": {
|
|
|
|
|
- "birth_datetime": birth_datetime,
|
|
|
|
|
- "latitude": latitude,
|
|
|
|
|
- "longitude": longitude,
|
|
|
|
|
- "elevation": elevation,
|
|
|
|
|
- "house_system": house_system,
|
|
|
|
|
- "orb_limits": orb_limits,
|
|
|
|
|
- },
|
|
|
|
|
- "chart_type": "natal",
|
|
|
|
|
- "planets": planets,
|
|
|
|
|
- "houses": houses,
|
|
|
|
|
- "aspects": formatted_aspects,
|
|
|
|
|
- "angles": angles,
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- # Add lunar phase from ephemeris
|
|
|
|
|
- lunar_state = sky.get("lunar_state", {})
|
|
|
|
|
- lunar = lunar_state.get("lunar_state", {}) if isinstance(lunar_state, dict) else {}
|
|
|
|
|
- if lunar:
|
|
|
|
|
- result["lunar_phase"] = {
|
|
|
|
|
- "phase_name": lunar.get("phase_name"),
|
|
|
|
|
- "illumination_fraction": lunar.get("illumination_fraction"),
|
|
|
|
|
- "age_days": lunar.get("age_days"),
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- # Limit aspects if requested
|
|
|
|
|
- if top_n_aspects is not None:
|
|
|
|
|
- result["aspects"] = formatted_aspects[:top_n_aspects]
|
|
|
|
|
-
|
|
|
|
|
- # Overview section
|
|
|
|
|
- if include_overview:
|
|
|
|
|
- asc_sign = angles.get("ascendant", {}).get("sign", "")
|
|
|
|
|
- overview: dict[str, Any] = {
|
|
|
|
|
- "element_balance": astrology.get_element_balance(planets),
|
|
|
|
|
- "modality_balance": astrology.get_modality_balance(planets),
|
|
|
|
|
- "hemisphere_emphasis": astrology.get_hemisphere_emphasis(planets),
|
|
|
|
|
- "stelliums": astrology.detect_stelliums(planets),
|
|
|
|
|
- "empty_houses": astrology.get_empty_houses(planets),
|
|
|
|
|
- "chart_ruler": astrology.get_chart_ruler(asc_sign, planets),
|
|
|
|
|
- "house_rulers": astrology.get_house_rulers(houses, planets),
|
|
|
|
|
- "planets_by_house": astrology.group_planets_by_house(planets),
|
|
|
|
|
- "planets_by_sign": astrology.group_planets_by_sign(planets),
|
|
|
|
|
- "house_type_counts": astrology.get_house_type_counts(planets),
|
|
|
|
|
- "retrograde_planets": astrology.get_retrograde_planets(planets),
|
|
|
|
|
- }
|
|
|
|
|
- result["overview"] = overview
|
|
|
|
|
-
|
|
|
|
|
- # Aspect patterns + chart shape
|
|
|
|
|
- if include_patterns:
|
|
|
|
|
- patterns = astrology.detect_aspect_patterns(planets, formatted_aspects)
|
|
|
|
|
- chart_shape = astrology.detect_chart_shape(planets)
|
|
|
|
|
- result["aspect_patterns"] = patterns
|
|
|
|
|
- result["chart_shape"] = chart_shape
|
|
|
|
|
-
|
|
|
|
|
- # Karmic analysis
|
|
|
|
|
- if include_karmic:
|
|
|
|
|
- asc_lon = angles.get("ascendant", {}).get("absolute_lon", 0.0)
|
|
|
|
|
- sun_lon = astrology._planet_lon("sun", planets) or 0.0
|
|
|
|
|
- moon_lon = astrology._planet_lon("moon", planets) or 0.0
|
|
|
|
|
-
|
|
|
|
|
- karmic: dict[str, Any] = {
|
|
|
|
|
- "nodal_axis": astrology.get_nodal_axis(planets, houses),
|
|
|
|
|
- "saturn": astrology.get_saturn_info(planets),
|
|
|
|
|
- "pluto_polarity_point": astrology.get_pluto_polarity_point(planets, houses),
|
|
|
|
|
- "part_of_fortune": astrology.get_part_of_fortune(asc_lon, sun_lon, moon_lon, houses),
|
|
|
|
|
- "twelfth_house": astrology.get_twelfth_house_analysis(houses, planets),
|
|
|
|
|
- "nodal_aspects": astrology.get_natal_aspects_to_planets(
|
|
|
|
|
- formatted_aspects, {"true_node"}
|
|
|
|
|
- ),
|
|
|
|
|
- "saturn_aspects": astrology.get_natal_aspects_to_planets(
|
|
|
|
|
- formatted_aspects, {"saturn"}, astrology.HARD_ASPECTS
|
|
|
|
|
- ),
|
|
|
|
|
- }
|
|
|
|
|
- result["karmic"] = karmic
|
|
|
|
|
-
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── Tool: calculate_transit_chart ────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def calculate_transit_chart(
|
|
|
|
|
- birth_datetime: str,
|
|
|
|
|
- transit_datetime: str,
|
|
|
|
|
- latitude: float,
|
|
|
|
|
- longitude: float,
|
|
|
|
|
- transit_latitude: float | None = None,
|
|
|
|
|
- transit_longitude: float | None = None,
|
|
|
|
|
- elevation: float = 0.0,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Calculate a transit chart: transiting planets vs natal positions.
|
|
|
|
|
-
|
|
|
|
|
-Shows how current (or future) transiting planets aspect the natal chart. Useful for
|
|
|
|
|
-identifying active transit windows and their themes. For daily transit snapshots,
|
|
|
|
|
-use get_transit_preview instead.
|
|
|
|
|
-
|
|
|
|
|
-For interpretation guidance, fetch resources:
|
|
|
|
|
-- astro://guides/natal-astrology
|
|
|
|
|
-- astro://guides/financial-astrology (for market-timing and economic cycle analysis)
|
|
|
|
|
-
|
|
|
|
|
-Args:
|
|
|
|
|
- birth_datetime: ISO 8601 birth datetime (UTC).
|
|
|
|
|
- transit_datetime: ISO 8601 transit datetime (UTC).
|
|
|
|
|
- latitude: Birth latitude in decimal degrees.
|
|
|
|
|
- longitude: Birth longitude in decimal degrees.
|
|
|
|
|
- transit_latitude: Current location latitude for transit calculation. Defaults to birth latitude.
|
|
|
|
|
- transit_longitude: Current location longitude for transit calculation. Defaults to birth longitude.
|
|
|
|
|
- elevation: Birth elevation in meters.
|
|
|
|
|
- house_system: House system for natal houses (default: Placidus).
|
|
|
|
|
- orb_limits: Optional orb configuration.
|
|
|
|
|
-
|
|
|
|
|
-Returns:
|
|
|
|
|
- Transit chart with natal_planets, transiting_planets, aspects (transit-to-natal),
|
|
|
|
|
- and houses."""
|
|
|
|
|
- # Default transit location to birth location if not specified
|
|
|
|
|
- t_lat = transit_latitude if transit_latitude is not None else latitude
|
|
|
|
|
- t_lon = transit_longitude if transit_longitude is not None else longitude
|
|
|
|
|
-
|
|
|
|
|
- # Get natal sky state
|
|
|
|
|
- natal_sky = await call_sky_state(
|
|
|
|
|
- datetime=birth_datetime,
|
|
|
|
|
- lat=latitude,
|
|
|
|
|
- lon=longitude,
|
|
|
|
|
- elevation=elevation,
|
|
|
|
|
- geocentric=True,
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- # Get transit sky state at transit location
|
|
|
|
|
- transit_sky = await call_sky_state(
|
|
|
|
|
- datetime=transit_datetime,
|
|
|
|
|
- lat=t_lat,
|
|
|
|
|
- lon=t_lon,
|
|
|
|
|
- elevation=elevation,
|
|
|
|
|
- geocentric=True,
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- if "error" in natal_sky:
|
|
|
|
|
- return {"error": f"natal: {natal_sky['error']}"}
|
|
|
|
|
- if "error" in transit_sky:
|
|
|
|
|
- return {"error": f"transit: {transit_sky['error']}"}
|
|
|
|
|
-
|
|
|
|
|
- natal_bodies = extract_bodies(natal_sky)
|
|
|
|
|
- transit_bodies = extract_bodies(transit_sky)
|
|
|
|
|
-
|
|
|
|
|
- # Houses from server-side Swiss Ephemeris
|
|
|
|
|
- houses = extract_houses(natal_sky)
|
|
|
|
|
-
|
|
|
|
|
- # Build natal planets
|
|
|
|
|
- natal_planets = []
|
|
|
|
|
- for body in natal_bodies:
|
|
|
|
|
- ecl_lon = body.get("ecliptic_lon", 0.0)
|
|
|
|
|
- zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
|
|
|
|
|
- house = astrology.get_house_placement(ecl_lon, houses)
|
|
|
|
|
- natal_planets.append({
|
|
|
|
|
- "body": body["body"],
|
|
|
|
|
- "sign": zodiac["sign"],
|
|
|
|
|
- "degree_within_sign": zodiac["degree"],
|
|
|
|
|
- "absolute_lon": zodiac["absolute_lon"],
|
|
|
|
|
- "house": house,
|
|
|
|
|
- "retrograde": astrology.is_retrograde(body.get("speed_lon")),
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- # Build transit planets
|
|
|
|
|
- transit_planets = []
|
|
|
|
|
- for body in transit_bodies:
|
|
|
|
|
- ecl_lon = body.get("ecliptic_lon", 0.0)
|
|
|
|
|
- zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
|
|
|
|
|
- transit_house = astrology.get_house_placement(ecl_lon, houses)
|
|
|
|
|
- transit_planets.append({
|
|
|
|
|
- "body": body["body"],
|
|
|
|
|
- "sign": zodiac["sign"],
|
|
|
|
|
- "degree_within_sign": zodiac["degree"],
|
|
|
|
|
- "absolute_lon": zodiac["absolute_lon"],
|
|
|
|
|
- "natal_house": transit_house,
|
|
|
|
|
- "retrograde": astrology.is_retrograde(body.get("speed_lon")),
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- # Transit-to-natal aspects
|
|
|
|
|
- transit_aspects = []
|
|
|
|
|
- for t_body in transit_planets:
|
|
|
|
|
- for n_body in natal_planets:
|
|
|
|
|
- pair = [
|
|
|
|
|
- {"name": f"transit_{t_body['body']}", "lon": t_body["absolute_lon"], "speed_lon": None},
|
|
|
|
|
- {"name": f"natal_{n_body['body']}", "lon": n_body["absolute_lon"], "speed_lon": None},
|
|
|
|
|
- ]
|
|
|
|
|
- pair_aspects = astrology.compute_aspects(pair, orb_limits)
|
|
|
|
|
- for asp in pair_aspects:
|
|
|
|
|
- transit_aspects.append({
|
|
|
|
|
- "transiting": t_body["body"],
|
|
|
|
|
- "natal": n_body["body"],
|
|
|
|
|
- "aspect": asp["aspect"],
|
|
|
|
|
- "orb": asp["orb"],
|
|
|
|
|
- "exactness": asp["exactness"],
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- transit_aspects.sort(key=lambda a: a["orb"])
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- "input": {
|
|
|
|
|
- "birth_datetime": birth_datetime,
|
|
|
|
|
- "transit_datetime": transit_datetime,
|
|
|
|
|
- "latitude": latitude,
|
|
|
|
|
- "longitude": longitude,
|
|
|
|
|
- "house_system": house_system,
|
|
|
|
|
- },
|
|
|
|
|
- "chart_type": "transit",
|
|
|
|
|
- "natal_planets": natal_planets,
|
|
|
|
|
- "transiting_planets": transit_planets,
|
|
|
|
|
- "aspects": transit_aspects,
|
|
|
|
|
- "houses": houses,
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── Tool: calculate_synastry_chart ───────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def calculate_synastry_chart(
|
|
|
|
|
- person1_datetime: str,
|
|
|
|
|
- person1_latitude: float,
|
|
|
|
|
- person1_longitude: float,
|
|
|
|
|
- person2_datetime: str,
|
|
|
|
|
- person2_latitude: float,
|
|
|
|
|
- person2_longitude: float,
|
|
|
|
|
- elevation: float = 0.0,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
- top_n_aspects: int | None = None,
|
|
|
|
|
- karmic_filter: bool = False,
|
|
|
|
|
- significator_filter: bool = False,
|
|
|
|
|
- include_davison_full: bool = False,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Calculate a synastry (relationship) chart for two people.
|
|
|
|
|
-
|
|
|
|
|
-PRIMARY TOOL for relationship astrology. Returns interchart aspects, house overlays,
|
|
|
|
|
-composite chart, and Davison chart data. Use filters to focus on specific themes.
|
|
|
|
|
-
|
|
|
|
|
-WORKFLOW:
|
|
|
|
|
-1. Basic synastry: call without flags → get all interaspects + house overlays.
|
|
|
|
|
-2. Karmic focus: karmic_filter=true → only Saturn/Pluto/Node interaspects.
|
|
|
|
|
-3. Romantic focus: significator_filter=true → only Venus-Mars, Moon-Venus, Sun-Moon, Sun-Saturn.
|
|
|
|
|
-4. Include Davison: include_davison_full=true → full Davison chart with planets/houses/aspects.
|
|
|
|
|
-
|
|
|
|
|
-For interpretation guidance, fetch resource: astro://guides/relationship-astrology
|
|
|
|
|
-
|
|
|
|
|
-For karmic relationship analysis, also see: get_karmic_relationship_summary
|
|
|
|
|
-
|
|
|
|
|
-Args:
|
|
|
|
|
- person1_datetime, person1_latitude, person1_longitude: Person 1 birth data.
|
|
|
|
|
- person2_datetime, person2_latitude, person2_longitude: Person 2 birth data.
|
|
|
|
|
- elevation: Birth elevation in meters.
|
|
|
|
|
- house_system: House system (default: Placidus).
|
|
|
|
|
- orb_limits: Optional orb configuration.
|
|
|
|
|
- top_n_aspects: Limit interaspects to top N by orb.
|
|
|
|
|
- karmic_filter: Only return Saturn/Pluto/Node interaspects.
|
|
|
|
|
- significator_filter: Only return Venus-Mars, Moon-Venus, Sun-Moon, Sun-Saturn pairs.
|
|
|
|
|
- include_davison_full: Compute full Davison chart (planets, houses, aspects, angles).
|
|
|
|
|
-
|
|
|
|
|
-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, 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']}"}
|
|
|
|
|
- if "error" in sky2:
|
|
|
|
|
- return {"error": f"person2: {sky2['error']}"}
|
|
|
|
|
-
|
|
|
|
|
- bodies1 = extract_bodies(sky1)
|
|
|
|
|
- bodies2 = extract_bodies(sky2)
|
|
|
|
|
-
|
|
|
|
|
- # Houses from server-side Swiss Ephemeris
|
|
|
|
|
- houses1 = extract_houses(sky1)
|
|
|
|
|
- houses2 = extract_houses(sky2)
|
|
|
|
|
-
|
|
|
|
|
- def build_planet_list(bodies):
|
|
|
|
|
- result = []
|
|
|
|
|
- for b in bodies:
|
|
|
|
|
- ecl_lon = b.get("ecliptic_lon", 0.0)
|
|
|
|
|
- z = astrology.ecliptic_to_zodiac(ecl_lon)
|
|
|
|
|
- result.append({
|
|
|
|
|
- "body": b["body"],
|
|
|
|
|
- "sign": z["sign"],
|
|
|
|
|
- "degree_within_sign": z["degree"],
|
|
|
|
|
- "absolute_lon": z["absolute_lon"],
|
|
|
|
|
- "retrograde": astrology.is_retrograde(b.get("speed_lon")),
|
|
|
|
|
- })
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
- chart1_planets = build_planet_list(bodies1)
|
|
|
|
|
- chart2_planets = build_planet_list(bodies2)
|
|
|
|
|
-
|
|
|
|
|
- # Interaspects
|
|
|
|
|
- interaspects = []
|
|
|
|
|
- for p1 in chart1_planets:
|
|
|
|
|
- for p2 in chart2_planets:
|
|
|
|
|
- pair = [
|
|
|
|
|
- {"name": f"p1_{p1['body']}", "lon": p1["absolute_lon"]},
|
|
|
|
|
- {"name": f"p2_{p2['body']}", "lon": p2["absolute_lon"]},
|
|
|
|
|
- ]
|
|
|
|
|
- for asp in astrology.compute_aspects(pair, orb_limits):
|
|
|
|
|
- interaspects.append({
|
|
|
|
|
- "person1_planet": p1["body"],
|
|
|
|
|
- "person2_planet": p2["body"],
|
|
|
|
|
- "aspect": asp["aspect"],
|
|
|
|
|
- "orb": asp["orb"],
|
|
|
|
|
- "exactness": asp["exactness"],
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- interaspects.sort(key=lambda a: a["orb"])
|
|
|
|
|
-
|
|
|
|
|
- # Apply filters
|
|
|
|
|
- filtered_aspects = interaspects
|
|
|
|
|
- if karmic_filter:
|
|
|
|
|
- karmic_planets = {"saturn", "pluto", "true_node"}
|
|
|
|
|
- filtered_aspects = [
|
|
|
|
|
- a for a in filtered_aspects
|
|
|
|
|
- if a["person1_planet"] in karmic_planets or a["person2_planet"] in karmic_planets
|
|
|
|
|
- ]
|
|
|
|
|
- if significator_filter:
|
|
|
|
|
- significator_pairs = {
|
|
|
|
|
- frozenset(["venus", "mars"]), frozenset(["moon", "venus"]),
|
|
|
|
|
- frozenset(["sun", "moon"]), frozenset(["sun", "saturn"]),
|
|
|
|
|
- }
|
|
|
|
|
- filtered_aspects = [
|
|
|
|
|
- a for a in filtered_aspects
|
|
|
|
|
- if frozenset([a["person1_planet"], a["person2_planet"]]) in significator_pairs
|
|
|
|
|
- ]
|
|
|
|
|
- if top_n_aspects is not None:
|
|
|
|
|
- filtered_aspects = filtered_aspects[:top_n_aspects]
|
|
|
|
|
-
|
|
|
|
|
- # House overlays: person2's planets in person1's houses
|
|
|
|
|
- p2_in_p1_houses = []
|
|
|
|
|
- for p2 in chart2_planets:
|
|
|
|
|
- house = astrology.get_house_placement(p2["absolute_lon"], houses1)
|
|
|
|
|
- p2_in_p1_houses.append({
|
|
|
|
|
- "planet": p2["body"],
|
|
|
|
|
- "house": house,
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- p1_in_p2_houses = []
|
|
|
|
|
- for p1 in chart1_planets:
|
|
|
|
|
- house = astrology.get_house_placement(p1["absolute_lon"], houses2)
|
|
|
|
|
- p1_in_p2_houses.append({
|
|
|
|
|
- "planet": p1["body"],
|
|
|
|
|
- "house": house,
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- # Composite chart (midpoint method)
|
|
|
|
|
- composite_bodies = astrology.compute_composite_chart(
|
|
|
|
|
- [{"name": p["body"], "lon": p["absolute_lon"]} for p in chart1_planets],
|
|
|
|
|
- [{"name": p["body"], "lon": p["absolute_lon"]} for p in chart2_planets],
|
|
|
|
|
- )
|
|
|
|
|
- composite_planets = []
|
|
|
|
|
- for cb in composite_bodies:
|
|
|
|
|
- z = astrology.ecliptic_to_zodiac(cb["lon"])
|
|
|
|
|
- composite_planets.append({
|
|
|
|
|
- "body": cb["name"],
|
|
|
|
|
- "sign": z["sign"],
|
|
|
|
|
- "degree_within_sign": z["degree"],
|
|
|
|
|
- "absolute_lon": z["absolute_lon"],
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- # Davison chart
|
|
|
|
|
- davison = astrology.compute_davison_chart(0.0, 0.0, person1_datetime, person2_datetime)
|
|
|
|
|
- davison_mid_lat = (person1_latitude + person2_latitude) / 2
|
|
|
|
|
- davison_mid_lon = (person1_longitude + person2_longitude) / 2
|
|
|
|
|
-
|
|
|
|
|
- davison_result: dict[str, Any] = {
|
|
|
|
|
- "date_midpoint_jd": davison["date_midpoint_jd"],
|
|
|
|
|
- "latitude_midpoint": davison_mid_lat,
|
|
|
|
|
- "longitude_midpoint": davison_mid_lon,
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- # Full Davison chart if requested
|
|
|
|
|
- if include_davison_full:
|
|
|
|
|
- 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, house_system=house_system,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" not in davison_sky:
|
|
|
|
|
- davison_raw = extract_bodies(davison_sky)
|
|
|
|
|
- davison_houses = extract_houses(davison_sky)
|
|
|
|
|
-
|
|
|
|
|
- davison_planets = []
|
|
|
|
|
- for body in davison_raw:
|
|
|
|
|
- ecl_lon = body.get("ecliptic_lon", 0.0)
|
|
|
|
|
- z = astrology.ecliptic_to_zodiac(ecl_lon)
|
|
|
|
|
- house = astrology.get_house_placement(ecl_lon, davison_houses)
|
|
|
|
|
- davison_planets.append({
|
|
|
|
|
- "body": body["body"],
|
|
|
|
|
- "sign": z["sign"],
|
|
|
|
|
- "degree_within_sign": z["degree"],
|
|
|
|
|
- "absolute_lon": z["absolute_lon"],
|
|
|
|
|
- "house": house,
|
|
|
|
|
- "retrograde": astrology.is_retrograde(body.get("speed_lon")),
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- davison_aspect_bodies = [{"name": p["body"], "lon": p["absolute_lon"]} for p in davison_planets]
|
|
|
|
|
- davison_aspects = astrology.compute_aspects(davison_aspect_bodies, orb_limits)
|
|
|
|
|
- davison_formatted = []
|
|
|
|
|
- for asp in davison_aspects:
|
|
|
|
|
- davison_formatted.append({
|
|
|
|
|
- "body1": asp["body1"],
|
|
|
|
|
- "body2": asp["body2"],
|
|
|
|
|
- "aspect": asp["aspect"],
|
|
|
|
|
- "orb": asp["orb"],
|
|
|
|
|
- "applying": asp["applying"],
|
|
|
|
|
- "exactness": asp["exactness"],
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- davison_angles = extract_angles(davison_sky)
|
|
|
|
|
-
|
|
|
|
|
- davison_result["planets"] = davison_planets
|
|
|
|
|
- davison_result["houses"] = davison_houses
|
|
|
|
|
- davison_result["aspects"] = davison_formatted
|
|
|
|
|
- davison_result["angles"] = davison_angles
|
|
|
|
|
-
|
|
|
|
|
- # Build summary
|
|
|
|
|
- summary: dict[str, Any] = {
|
|
|
|
|
- "top_aspects": interaspects[:15],
|
|
|
|
|
- "saturn_contacts": [a for a in interaspects if a["person1_planet"] == "saturn" or a["person2_planet"] == "saturn"],
|
|
|
|
|
- "node_contacts": [a for a in interaspects if a["person1_planet"] == "true_node" or a["person2_planet"] == "true_node"],
|
|
|
|
|
- "venus_mars_contacts": [a for a in interaspects if frozenset([a["person1_planet"], a["person2_planet"]]) == frozenset(["venus", "mars"])],
|
|
|
|
|
- "sun_moon_contacts": [a for a in interaspects if frozenset([a["person1_planet"], a["person2_planet"]]) == frozenset(["sun", "moon"])],
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- "input": {
|
|
|
|
|
- "person1": {"datetime": person1_datetime, "latitude": person1_latitude, "longitude": person1_longitude},
|
|
|
|
|
- "person2": {"datetime": person2_datetime, "latitude": person2_latitude, "longitude": person2_longitude},
|
|
|
|
|
- "house_system": house_system,
|
|
|
|
|
- },
|
|
|
|
|
- "chart_type": "synastry",
|
|
|
|
|
- "chart1_natal": {"planets": chart1_planets, "houses": houses1},
|
|
|
|
|
- "chart2_natal": {"planets": chart2_planets, "houses": houses2},
|
|
|
|
|
- "interaspects": filtered_aspects,
|
|
|
|
|
- "house_overlays": {
|
|
|
|
|
- "person2_in_person1_houses": p2_in_p1_houses,
|
|
|
|
|
- "person1_in_person2_houses": p1_in_p2_houses,
|
|
|
|
|
- },
|
|
|
|
|
- "composite_chart": {"planets": composite_planets},
|
|
|
|
|
- "davison_chart": davison_result,
|
|
|
|
|
- "summary": summary,
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def _jd_to_datetime(jd: float) -> str:
|
|
|
|
|
- """Convert Julian Day to ISO 8601 datetime string."""
|
|
|
|
|
- from datetime import datetime, timezone, timedelta
|
|
|
|
|
- # JD 2440587.5 = 1970-01-01T00:00:00Z
|
|
|
|
|
- unix_seconds = (jd - 2440587.5) * 86400.0
|
|
|
|
|
- dt = datetime.fromtimestamp(unix_seconds, tz=timezone.utc)
|
|
|
|
|
- return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── Tool: get_transit_preview ────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def get_transit_preview(
|
|
|
|
|
- birth_datetime: str,
|
|
|
|
|
- latitude: float,
|
|
|
|
|
- longitude: float,
|
|
|
|
|
- start_date: str,
|
|
|
|
|
- end_date: str,
|
|
|
|
|
- transit_latitude: float | None = None,
|
|
|
|
|
- transit_longitude: float | None = None,
|
|
|
|
|
- min_significance: float = 0.0,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Daily transit-to-natal aspect snapshot over a time range.
|
|
|
|
|
-
|
|
|
|
|
-Shows which transiting planets aspect which natal planets for each day. Significance
|
|
|
|
|
-score (0-10) based on aspect type, planet importance, and orb tightness.
|
|
|
|
|
-
|
|
|
|
|
-Use this for identifying active transit windows and forecasting themes. For a single
|
|
|
|
|
-transit moment, use calculate_transit_chart instead.
|
|
|
|
|
-
|
|
|
|
|
-For interpretation guidance, fetch resources:
|
|
|
|
|
-- astro://guides/natal-astrology
|
|
|
|
|
-- astro://guides/financial-astrology (for market-timing and economic cycle analysis)
|
|
|
|
|
-
|
|
|
|
|
-Args:
|
|
|
|
|
- birth_datetime: ISO 8601 birth datetime (UTC).
|
|
|
|
|
- latitude: Birth latitude in decimal degrees.
|
|
|
|
|
- longitude: Birth longitude in decimal degrees.
|
|
|
|
|
- start_date: Start of range (YYYY-MM-DD).
|
|
|
|
|
- end_date: End of range (YYYY-MM-DD). Maximum 365 days.
|
|
|
|
|
- transit_latitude: Current location latitude. Defaults to birth latitude.
|
|
|
|
|
- transit_longitude: Current location longitude. Defaults to birth longitude.
|
|
|
|
|
- min_significance: Minimum significance score (0-10) to include. Default 0 = all.
|
|
|
|
|
-
|
|
|
|
|
-Returns:
|
|
|
|
|
- Daily snapshots with active transit-to-natal aspects, sorted by date.
|
|
|
|
|
- Each day: date, aspects (with orb, applying/separating, significance), count."""
|
|
|
|
|
- from datetime import datetime, timedelta, timezone
|
|
|
|
|
-
|
|
|
|
|
- from . import astrology
|
|
|
|
|
-
|
|
|
|
|
- birth_dt = birth_datetime
|
|
|
|
|
- birth_lat = latitude
|
|
|
|
|
- birth_lon = longitude
|
|
|
|
|
- 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
|
|
|
|
|
-
|
|
|
|
|
- # Parse date range
|
|
|
|
|
- try:
|
|
|
|
|
- start = datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc)
|
|
|
|
|
- end = datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc)
|
|
|
|
|
- except Exception:
|
|
|
|
|
- return {"error": "Invalid date format. Use ISO format (YYYY-MM-DD)."}
|
|
|
|
|
-
|
|
|
|
|
- if end <= start:
|
|
|
|
|
- return {"error": "end_date must be after start_date"}
|
|
|
|
|
-
|
|
|
|
|
- if (end - start).days > 365:
|
|
|
|
|
- return {"error": "Date range too large. Maximum 365 days."}
|
|
|
|
|
-
|
|
|
|
|
- # Get natal planet positions (one ephemeris call)
|
|
|
|
|
- natal_sky = await call_sky_state(
|
|
|
|
|
- datetime=birth_dt, lat=birth_lat, lon=birth_lon, geocentric=True,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" in natal_sky:
|
|
|
|
|
- return {"error": f"natal ephemeris error: {natal_sky['error']}"}
|
|
|
|
|
-
|
|
|
|
|
- natal_bodies = extract_bodies(natal_sky)
|
|
|
|
|
- natal_lons = {b["body"]: b["ecliptic_lon"] for b in natal_bodies}
|
|
|
|
|
-
|
|
|
|
|
- # Build list of all transit-to-natal aspect checks with per-pair orbs
|
|
|
|
|
- aspect_checks = []
|
|
|
|
|
- for t_name in astrology.TRANSIT_ORB_RADII:
|
|
|
|
|
- for n_name in natal_lons:
|
|
|
|
|
- for asp_def in astrology.ASPECT_DEFINITIONS:
|
|
|
|
|
- asp_name = asp_def["name"]
|
|
|
|
|
- asp_angle = asp_def["angle"]
|
|
|
|
|
- max_orb = astrology.get_transit_orb(t_name, n_name, asp_name)
|
|
|
|
|
- aspect_checks.append((t_name, n_name, asp_name, asp_angle, max_orb))
|
|
|
|
|
-
|
|
|
|
|
- # Daily scan
|
|
|
|
|
- days = []
|
|
|
|
|
- current_day = start
|
|
|
|
|
- while current_day <= end:
|
|
|
|
|
- iso = current_day.strftime("%Y-%m-%dT12:00:00Z")
|
|
|
|
|
- sky = await call_sky_state(
|
|
|
|
|
- datetime=iso, lat=t_lat, lon=t_lon, geocentric=True,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" in sky:
|
|
|
|
|
- current_day += timedelta(days=1)
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- 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}
|
|
|
|
|
-
|
|
|
|
|
- day_aspects = []
|
|
|
|
|
- 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)
|
|
|
|
|
-
|
|
|
|
|
- if orb > max_orb:
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- significance = astrology.get_transit_significance(t_name, n_name, asp_name, orb, max_orb)
|
|
|
|
|
- if significance < min_significance:
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- # Determine applying/separating
|
|
|
|
|
- t_speed = transit_speeds.get(t_name, 0.0)
|
|
|
|
|
- applying = astrology._is_applying(t_lon, n_lon, t_speed, 0.0, asp_angle)
|
|
|
|
|
-
|
|
|
|
|
- day_aspects.append({
|
|
|
|
|
- "transiting": t_name,
|
|
|
|
|
- "natal": n_name,
|
|
|
|
|
- "aspect": asp_name,
|
|
|
|
|
- "orb": round(orb, 4),
|
|
|
|
|
- "applying": applying,
|
|
|
|
|
- "significance": significance,
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- # Sort by significance (highest first)
|
|
|
|
|
- day_aspects.sort(key=lambda a: a["significance"], reverse=True)
|
|
|
|
|
-
|
|
|
|
|
- days.append({
|
|
|
|
|
- "date": current_day.strftime("%Y-%m-%d"),
|
|
|
|
|
- "aspects": day_aspects,
|
|
|
|
|
- "count": len(day_aspects),
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- current_day += timedelta(days=1)
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- "input": {
|
|
|
|
|
- "birth_datetime": birth_datetime,
|
|
|
|
|
- "latitude": latitude,
|
|
|
|
|
- "longitude": longitude,
|
|
|
|
|
- "start_date": start_date,
|
|
|
|
|
- "end_date": end_date,
|
|
|
|
|
- "min_significance": min_significance,
|
|
|
|
|
- },
|
|
|
|
|
- "days": days,
|
|
|
|
|
- "total_aspects": sum(d["count"] for d in days),
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── Tool: person_manage ─────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def person_manage(
|
|
|
|
|
- action: str,
|
|
|
|
|
- person_id: str | None = None,
|
|
|
|
|
- name: str | None = None,
|
|
|
|
|
- nickname: str | None = None,
|
|
|
|
|
- birth_datetime: str | None = None,
|
|
|
|
|
- birthplace: str | None = None,
|
|
|
|
|
- latitude: float | None = None,
|
|
|
|
|
- longitude: float | None = None,
|
|
|
|
|
- elevation: float | None = None,
|
|
|
|
|
- alive: bool | None = None,
|
|
|
|
|
- private: bool | None = None,
|
|
|
|
|
- gender: str | None = None,
|
|
|
|
|
- description: str | None = None,
|
|
|
|
|
- notes: str | None = None,
|
|
|
|
|
- tz: str | None = None,
|
|
|
|
|
- birth_time_known: bool | None = None,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Manage persons in the birth data database.
|
|
|
|
|
-
|
|
|
|
|
- Store and retrieve birth data for individuals. Persons can be referenced by
|
|
|
|
|
- ID or nickname in all _byId tool variants.
|
|
|
|
|
-
|
|
|
|
|
- Actions:
|
|
|
|
|
- - add: Create a new person (requires: name, birth_datetime, latitude, longitude)
|
|
|
|
|
- - get: Retrieve by person_id or nickname
|
|
|
|
|
- - list: List all persons
|
|
|
|
|
- - update: Modify fields (requires: person_id)
|
|
|
|
|
- - delete: Remove person (requires: person_id)
|
|
|
|
|
-
|
|
|
|
|
- After adding persons, use the _byId tools (e.g., calculate_natal_chart_by_id)
|
|
|
|
|
- so you don't need to pass birth data repeatedly.
|
|
|
|
|
-
|
|
|
|
|
- Args:
|
|
|
|
|
- action: One of: add, get, list, update, delete.
|
|
|
|
|
- person_id: Required for get, update, delete.
|
|
|
|
|
- name: Person's full name (required for add).
|
|
|
|
|
- nickname: Optional short name for quick lookup (used with _byId tools).
|
|
|
|
|
- birth_datetime: ISO 8601 naive local time, no offset (e.g. "1990-05-15T10:30:00").
|
|
|
|
|
- birthplace: Optional birth place name (e.g., "Zurich, Switzerland").
|
|
|
|
|
- latitude: Birth latitude (required for add).
|
|
|
|
|
- longitude: Birth longitude (required for add).
|
|
|
|
|
- elevation: Birth elevation in meters.
|
|
|
|
|
- alive: Whether the person is alive (default: True).
|
|
|
|
|
- private: Hidden from public listing (default: False).
|
|
|
|
|
- gender: Person's gender (male/female/other).
|
|
|
|
|
- description: Short one-line description or summary.
|
|
|
|
|
- notes: Freeform longer notes.
|
|
|
|
|
- tz: IANA timezone name for birthplace (e.g., "Europe/Vienna").
|
|
|
|
|
- birth_time_known: Whether the birth time is accurate (default: True).
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Operation result with person data or error.
|
|
|
|
|
- """
|
|
|
|
|
- from . import storage
|
|
|
|
|
-
|
|
|
|
|
- action = action.lower().strip()
|
|
|
|
|
-
|
|
|
|
|
- if action == "add":
|
|
|
|
|
- if not name or not birth_datetime or latitude is None or longitude is None:
|
|
|
|
|
- return {"error": "add requires: name, birth_datetime, latitude, longitude"}
|
|
|
|
|
- person = await storage.add_person(
|
|
|
|
|
- name=name,
|
|
|
|
|
- birth_datetime=birth_datetime,
|
|
|
|
|
- latitude=latitude,
|
|
|
|
|
- longitude=longitude,
|
|
|
|
|
- elevation=elevation if elevation is not None else 0.0,
|
|
|
|
|
- nickname=nickname,
|
|
|
|
|
- birthplace=birthplace,
|
|
|
|
|
- alive=alive if alive is not None else True,
|
|
|
|
|
- private=private if private is not None else False,
|
|
|
|
|
- gender=gender,
|
|
|
|
|
- description=description,
|
|
|
|
|
- notes=notes,
|
|
|
|
|
- tz=tz,
|
|
|
|
|
- birth_time_known=birth_time_known if birth_time_known is not None else True,
|
|
|
|
|
- )
|
|
|
|
|
- return {"action": "add", "person": person}
|
|
|
|
|
-
|
|
|
|
|
- elif action == "get":
|
|
|
|
|
- if not person_id and not nickname:
|
|
|
|
|
- return {"error": "get requires: person_id or nickname"}
|
|
|
|
|
- person = await storage.get_person(person_id=person_id, nickname=nickname)
|
|
|
|
|
- if not person:
|
|
|
|
|
- return {"action": "get", "error": "not_found"}
|
|
|
|
|
- return {"action": "get", "person": person}
|
|
|
|
|
-
|
|
|
|
|
- elif action == "list":
|
|
|
|
|
- persons = await storage.list_persons()
|
|
|
|
|
- return {"action": "list", "persons": persons, "count": len(persons)}
|
|
|
|
|
-
|
|
|
|
|
- elif action == "update":
|
|
|
|
|
- if not person_id:
|
|
|
|
|
- return {"error": "update requires: person_id"}
|
|
|
|
|
- person = await storage.update_person(
|
|
|
|
|
- person_id=person_id,
|
|
|
|
|
- name=name,
|
|
|
|
|
- nickname=nickname,
|
|
|
|
|
- birth_datetime=birth_datetime,
|
|
|
|
|
- birthplace=birthplace,
|
|
|
|
|
- latitude=latitude,
|
|
|
|
|
- longitude=longitude,
|
|
|
|
|
- elevation=elevation,
|
|
|
|
|
- alive=alive,
|
|
|
|
|
- private=private,
|
|
|
|
|
- gender=gender,
|
|
|
|
|
- description=description,
|
|
|
|
|
- notes=notes,
|
|
|
|
|
- tz=tz,
|
|
|
|
|
- birth_time_known=birth_time_known,
|
|
|
|
|
- )
|
|
|
|
|
- if not person:
|
|
|
|
|
- return {"action": "update", "error": "not_found"}
|
|
|
|
|
- return {"action": "update", "person": person}
|
|
|
|
|
-
|
|
|
|
|
- elif action == "delete":
|
|
|
|
|
- if not person_id:
|
|
|
|
|
- return {"error": "delete requires: person_id"}
|
|
|
|
|
- deleted = await storage.delete_person(person_id)
|
|
|
|
|
- if not deleted:
|
|
|
|
|
- return {"action": "delete", "error": "not_found"}
|
|
|
|
|
- return {"action": "delete", "deleted": True, "person_id": person_id}
|
|
|
|
|
-
|
|
|
|
|
- else:
|
|
|
|
|
- return {"error": f"unknown action: {action}. Use: add, get, list, update, delete"}
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── Tool: calculate_composite_chart ──────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def calculate_composite_chart(
|
|
|
|
|
- person1_datetime: str,
|
|
|
|
|
- person1_latitude: float,
|
|
|
|
|
- person1_longitude: float,
|
|
|
|
|
- person2_datetime: str,
|
|
|
|
|
- person2_latitude: float,
|
|
|
|
|
- person2_longitude: float,
|
|
|
|
|
- elevation: float = 0.0,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Calculate a composite chart (midpoint method) for two people.
|
|
|
|
|
-
|
|
|
|
|
-The composite chart represents the relationship itself as a single chart, calculated
|
|
|
|
|
-by taking the midpoint of each pair of planetary positions. It shows the relationship's
|
|
|
|
|
-identity, structure, and public face.
|
|
|
|
|
-
|
|
|
|
|
-For relationship timing, use get_composite_transit_preview.
|
|
|
|
|
-
|
|
|
|
|
-For interpretation guidance, fetch resource: astro://guides/relationship-astrology
|
|
|
|
|
-
|
|
|
|
|
-Args:
|
|
|
|
|
- person1_datetime, person1_latitude, person1_longitude: Person 1 birth data.
|
|
|
|
|
- person2_datetime, person2_latitude, person2_longitude: Person 2 birth data.
|
|
|
|
|
- elevation: Birth elevation in meters.
|
|
|
|
|
- house_system: House system (default: Placidus).
|
|
|
|
|
- orb_limits: Optional orb configuration.
|
|
|
|
|
-
|
|
|
|
|
-Returns:
|
|
|
|
|
- Composite chart with planets, houses, aspects, angles, and composite_location.
|
|
|
|
|
- Composite orbs: 3° max (tighter than natal). Use tight orbs for interpretation."""
|
|
|
|
|
- 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)
|
|
|
|
|
-
|
|
|
|
|
- if "error" in sky1:
|
|
|
|
|
- return {"error": f"person1: {sky1['error']}"}
|
|
|
|
|
- if "error" in sky2:
|
|
|
|
|
- return {"error": f"person2: {sky2['error']}"}
|
|
|
|
|
-
|
|
|
|
|
- bodies1 = extract_bodies(sky1)
|
|
|
|
|
- bodies2 = extract_bodies(sky2)
|
|
|
|
|
-
|
|
|
|
|
- # Composite planets via midpoint method
|
|
|
|
|
- composite_bodies = astrology.compute_composite_chart(
|
|
|
|
|
- [{"name": b["body"], "lon": b.get("ecliptic_lon", 0.0)} for b in bodies1],
|
|
|
|
|
- [{"name": b["body"], "lon": b.get("ecliptic_lon", 0.0)} for b in bodies2],
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- # Composite location: midpoint of birth locations
|
|
|
|
|
- comp_lat = (person1_latitude + person2_latitude) / 2
|
|
|
|
|
- comp_lon = (person1_longitude + person2_longitude) / 2
|
|
|
|
|
-
|
|
|
|
|
- # Use composite datetime for house calculation
|
|
|
|
|
- davison = astrology.compute_davison_chart(0.0, 0.0, person1_datetime, person2_datetime)
|
|
|
|
|
-
|
|
|
|
|
- # Get composite sky state for houses and angles
|
|
|
|
|
- # 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']}"}
|
|
|
|
|
-
|
|
|
|
|
- houses = extract_houses(comp_sky)
|
|
|
|
|
-
|
|
|
|
|
- # Build composite planet list with house placement
|
|
|
|
|
- composite_planets = []
|
|
|
|
|
- for cb in composite_bodies:
|
|
|
|
|
- ecl_lon = cb["lon"]
|
|
|
|
|
- zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
|
|
|
|
|
- house = astrology.get_house_placement(ecl_lon, houses)
|
|
|
|
|
- composite_planets.append({
|
|
|
|
|
- "body": cb["name"],
|
|
|
|
|
- "sign": zodiac["sign"],
|
|
|
|
|
- "sign_abbreviation": zodiac["abbreviation"],
|
|
|
|
|
- "degree_within_sign": zodiac["degree"],
|
|
|
|
|
- "absolute_lon": zodiac["absolute_lon"],
|
|
|
|
|
- "house": house,
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- # Aspects
|
|
|
|
|
- aspect_bodies = [{"name": p["body"], "lon": p["absolute_lon"]} for p in composite_planets]
|
|
|
|
|
- aspects = astrology.compute_aspects(aspect_bodies, orb_limits)
|
|
|
|
|
- formatted_aspects = []
|
|
|
|
|
- for asp in aspects:
|
|
|
|
|
- formatted_aspects.append({
|
|
|
|
|
- "body1": asp["body1"],
|
|
|
|
|
- "body2": asp["body2"],
|
|
|
|
|
- "aspect": asp["aspect"],
|
|
|
|
|
- "orb": asp["orb"],
|
|
|
|
|
- "applying": asp["applying"],
|
|
|
|
|
- "exactness": asp["exactness"],
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- angles = extract_angles(comp_sky)
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- "input": {
|
|
|
|
|
- "person1": {"datetime": person1_datetime, "latitude": person1_latitude, "longitude": person1_longitude},
|
|
|
|
|
- "person2": {"datetime": person2_datetime, "latitude": person2_latitude, "longitude": person2_longitude},
|
|
|
|
|
- "house_system": house_system,
|
|
|
|
|
- },
|
|
|
|
|
- "chart_type": "composite",
|
|
|
|
|
- "planets": composite_planets,
|
|
|
|
|
- "houses": houses,
|
|
|
|
|
- "aspects": formatted_aspects,
|
|
|
|
|
- "angles": angles,
|
|
|
|
|
- "composite_location": {"latitude": comp_lat, "longitude": comp_lon},
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── Tool: calculate_davison_chart ─────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def calculate_davison_chart(
|
|
|
|
|
- person1_datetime: str,
|
|
|
|
|
- person1_latitude: float,
|
|
|
|
|
- person1_longitude: float,
|
|
|
|
|
- person2_datetime: str,
|
|
|
|
|
- person2_latitude: float,
|
|
|
|
|
- person2_longitude: float,
|
|
|
|
|
- elevation: float = 0.0,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Calculate a Davison chart (midpoint in time and space) for two people.
|
|
|
|
|
-
|
|
|
|
|
-The Davison chart is a real moment in time (unlike the composite which is purely
|
|
|
|
|
-symbolic). It represents the relationship's inner experience and emotional tone.
|
|
|
|
|
-It can be progressed and directed like a natal chart.
|
|
|
|
|
-
|
|
|
|
|
-For relationship timing, use get_davison_transit_preview.
|
|
|
|
|
-
|
|
|
|
|
-For interpretation guidance, fetch resource: astro://guides/relationship-astrology
|
|
|
|
|
-
|
|
|
|
|
-Args:
|
|
|
|
|
- person1_datetime, person1_latitude, person1_longitude: Person 1 birth data.
|
|
|
|
|
- person2_datetime, person2_latitude, person2_longitude: Person 2 birth data.
|
|
|
|
|
- elevation: Birth elevation in meters.
|
|
|
|
|
- house_system: House system (default: Placidus).
|
|
|
|
|
- orb_limits: Optional orb configuration.
|
|
|
|
|
-
|
|
|
|
|
-Returns:
|
|
|
|
|
- Davison chart with: chart_type, date_midpoint_jd, location_midpoint,
|
|
|
|
|
- planets, houses, aspects, angles."""
|
|
|
|
|
- davison = astrology.compute_davison_chart(0.0, 0.0, person1_datetime, person2_datetime)
|
|
|
|
|
- mid_lat = (person1_latitude + person2_latitude) / 2
|
|
|
|
|
- mid_lon = (person1_longitude + person2_longitude) / 2
|
|
|
|
|
- davison_dt = _jd_to_datetime(davison["date_midpoint_jd"])
|
|
|
|
|
-
|
|
|
|
|
- sky = await call_sky_state(
|
|
|
|
|
- datetime=davison_dt, lat=mid_lat, lon=mid_lon,
|
|
|
|
|
- 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)
|
|
|
|
|
- houses = extract_houses(sky)
|
|
|
|
|
-
|
|
|
|
|
- planets = []
|
|
|
|
|
- for body in raw_bodies:
|
|
|
|
|
- ecl_lon = body.get("ecliptic_lon", 0.0)
|
|
|
|
|
- z = astrology.ecliptic_to_zodiac(ecl_lon)
|
|
|
|
|
- house = astrology.get_house_placement(ecl_lon, houses)
|
|
|
|
|
- planets.append({
|
|
|
|
|
- "body": body["body"],
|
|
|
|
|
- "sign": z["sign"],
|
|
|
|
|
- "degree_within_sign": z["degree"],
|
|
|
|
|
- "absolute_lon": z["absolute_lon"],
|
|
|
|
|
- "house": house,
|
|
|
|
|
- "retrograde": astrology.is_retrograde(body.get("speed_lon")),
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- aspect_bodies = [{"name": p["body"], "lon": p["absolute_lon"]} for p in planets]
|
|
|
|
|
- aspects = astrology.compute_aspects(aspect_bodies, orb_limits)
|
|
|
|
|
- formatted_aspects = []
|
|
|
|
|
- for asp in aspects:
|
|
|
|
|
- formatted_aspects.append({
|
|
|
|
|
- "body1": asp["body1"],
|
|
|
|
|
- "body2": asp["body2"],
|
|
|
|
|
- "aspect": asp["aspect"],
|
|
|
|
|
- "orb": asp["orb"],
|
|
|
|
|
- "applying": asp["applying"],
|
|
|
|
|
- "exactness": asp["exactness"],
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- angles = extract_angles(sky)
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- "input": {
|
|
|
|
|
- "person1": {"datetime": person1_datetime, "latitude": person1_latitude, "longitude": person1_longitude},
|
|
|
|
|
- "person2": {"datetime": person2_datetime, "latitude": person2_latitude, "longitude": person2_longitude},
|
|
|
|
|
- "house_system": house_system,
|
|
|
|
|
- },
|
|
|
|
|
- "chart_type": "davison",
|
|
|
|
|
- "date_midpoint_jd": davison["date_midpoint_jd"],
|
|
|
|
|
- "location_midpoint": {"latitude": mid_lat, "longitude": mid_lon},
|
|
|
|
|
- "planets": planets,
|
|
|
|
|
- "houses": houses,
|
|
|
|
|
- "aspects": formatted_aspects,
|
|
|
|
|
- "angles": angles,
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── Tool: get_composite_transit_preview ───────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def get_composite_transit_preview(
|
|
|
|
|
- person1_datetime: str,
|
|
|
|
|
- person1_latitude: float,
|
|
|
|
|
- person1_longitude: float,
|
|
|
|
|
- person2_datetime: str,
|
|
|
|
|
- person2_latitude: float,
|
|
|
|
|
- person2_longitude: float,
|
|
|
|
|
- start_date: str,
|
|
|
|
|
- end_date: str,
|
|
|
|
|
- min_significance: float = 0.0,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Daily transit-to-composite chart aspect snapshot over a time range.
|
|
|
|
|
-
|
|
|
|
|
-Calculates the composite chart for two people, then shows transiting aspects to
|
|
|
|
|
-composite planet positions for each day. Use for timing relationship events and
|
|
|
|
|
-identifying when relationship themes are activated.
|
|
|
|
|
-
|
|
|
|
|
-For interpretation guidance, fetch resource: astro://guides/relationship-astrology
|
|
|
|
|
-
|
|
|
|
|
-Args:
|
|
|
|
|
- person1_datetime, person1_latitude, person1_longitude: Person 1 birth data.
|
|
|
|
|
- person2_datetime, person2_latitude, person2_longitude: Person 2 birth data.
|
|
|
|
|
- start_date: Start of range (YYYY-MM-DD).
|
|
|
|
|
- end_date: End of range (YYYY-MM-DD). Maximum 365 days.
|
|
|
|
|
- min_significance: Minimum significance score (0-10) to include. Default 0 = all.
|
|
|
|
|
-
|
|
|
|
|
-Returns:
|
|
|
|
|
- Daily snapshots with active transit-to-composite aspects, sorted by date.
|
|
|
|
|
- Each day: date, aspects (transiting, composite, orb, applying, significance), count."""
|
|
|
|
|
- from datetime import datetime, timedelta, timezone
|
|
|
|
|
-
|
|
|
|
|
- # Calculate composite chart
|
|
|
|
|
- sky1 = await call_sky_state(datetime=person1_datetime, lat=person1_latitude, lon=person1_longitude, geocentric=True)
|
|
|
|
|
- sky2 = await call_sky_state(datetime=person2_datetime, lat=person2_latitude, lon=person2_longitude, geocentric=True)
|
|
|
|
|
- if "error" in sky1:
|
|
|
|
|
- return {"error": f"person1: {sky1['error']}"}
|
|
|
|
|
- if "error" in sky2:
|
|
|
|
|
- return {"error": f"person2: {sky2['error']}"}
|
|
|
|
|
-
|
|
|
|
|
- bodies1 = extract_bodies(sky1)
|
|
|
|
|
- bodies2 = extract_bodies(sky2)
|
|
|
|
|
- composite_bodies = astrology.compute_composite_chart(
|
|
|
|
|
- [{"name": b["body"], "lon": b.get("ecliptic_lon", 0.0)} for b in bodies1],
|
|
|
|
|
- [{"name": b["body"], "lon": b.get("ecliptic_lon", 0.0)} for b in bodies2],
|
|
|
|
|
- )
|
|
|
|
|
- composite_lons = {b["name"]: b["lon"] for b in composite_bodies}
|
|
|
|
|
-
|
|
|
|
|
- # Parse date range
|
|
|
|
|
- try:
|
|
|
|
|
- start = datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc)
|
|
|
|
|
- end = datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc)
|
|
|
|
|
- except Exception:
|
|
|
|
|
- return {"error": "Invalid date format. Use ISO format (YYYY-MM-DD)."}
|
|
|
|
|
- if end <= start:
|
|
|
|
|
- return {"error": "end_date must be after start_date"}
|
|
|
|
|
- if (end - start).days > 365:
|
|
|
|
|
- return {"error": "Date range too large. Maximum 365 days."}
|
|
|
|
|
-
|
|
|
|
|
- # Build aspect checks
|
|
|
|
|
- aspect_checks = []
|
|
|
|
|
- for t_name in astrology.TRANSIT_ORB_RADII:
|
|
|
|
|
- for c_name in composite_lons:
|
|
|
|
|
- for asp_def in astrology.ASPECT_DEFINITIONS:
|
|
|
|
|
- asp_name = asp_def["name"]
|
|
|
|
|
- asp_angle = asp_def["angle"]
|
|
|
|
|
- max_orb = astrology.get_transit_orb(t_name, c_name, asp_name)
|
|
|
|
|
- aspect_checks.append((t_name, c_name, asp_name, asp_angle, max_orb))
|
|
|
|
|
-
|
|
|
|
|
- # Daily scan
|
|
|
|
|
- days = []
|
|
|
|
|
- current_day = start
|
|
|
|
|
- while current_day <= end:
|
|
|
|
|
- iso = current_day.strftime("%Y-%m-%dT12:00:00Z")
|
|
|
|
|
- sky = await call_sky_state(datetime=iso, lat=0.0, lon=0.0, geocentric=True)
|
|
|
|
|
- if "error" in sky:
|
|
|
|
|
- current_day += timedelta(days=1)
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- 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}
|
|
|
|
|
-
|
|
|
|
|
- day_aspects = []
|
|
|
|
|
- for t_name, c_name, asp_name, asp_angle, max_orb in aspect_checks:
|
|
|
|
|
- if t_name not in transit_lons or c_name not in composite_lons:
|
|
|
|
|
- continue
|
|
|
|
|
- t_lon = transit_lons[t_name]
|
|
|
|
|
- c_lon = composite_lons[c_name]
|
|
|
|
|
- diff = abs(t_lon - c_lon)
|
|
|
|
|
- diff = min(diff, 360.0 - diff)
|
|
|
|
|
- orb = abs(diff - asp_angle)
|
|
|
|
|
- if orb > max_orb:
|
|
|
|
|
- continue
|
|
|
|
|
- significance = astrology.get_transit_significance(t_name, c_name, asp_name, orb, max_orb)
|
|
|
|
|
- if significance < min_significance:
|
|
|
|
|
- continue
|
|
|
|
|
- t_speed = transit_speeds.get(t_name, 0.0)
|
|
|
|
|
- applying = astrology._is_applying(t_lon, c_lon, t_speed, 0.0, asp_angle)
|
|
|
|
|
- day_aspects.append({
|
|
|
|
|
- "transiting": t_name,
|
|
|
|
|
- "composite": c_name,
|
|
|
|
|
- "aspect": asp_name,
|
|
|
|
|
- "orb": round(orb, 4),
|
|
|
|
|
- "applying": applying,
|
|
|
|
|
- "significance": significance,
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- day_aspects.sort(key=lambda a: a["significance"], reverse=True)
|
|
|
|
|
- days.append({
|
|
|
|
|
- "date": current_day.strftime("%Y-%m-%d"),
|
|
|
|
|
- "aspects": day_aspects,
|
|
|
|
|
- "count": len(day_aspects),
|
|
|
|
|
- })
|
|
|
|
|
- current_day += timedelta(days=1)
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- "input": {
|
|
|
|
|
- "person1": {"datetime": person1_datetime, "latitude": person1_latitude, "longitude": person1_longitude},
|
|
|
|
|
- "person2": {"datetime": person2_datetime, "latitude": person2_latitude, "longitude": person2_longitude},
|
|
|
|
|
- "start_date": start_date,
|
|
|
|
|
- "end_date": end_date,
|
|
|
|
|
- },
|
|
|
|
|
- "days": days,
|
|
|
|
|
- "total_aspects": sum(d["count"] for d in days),
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── Tool: get_davison_transit_preview ─────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def get_davison_transit_preview(
|
|
|
|
|
- person1_datetime: str,
|
|
|
|
|
- person1_latitude: float,
|
|
|
|
|
- person1_longitude: float,
|
|
|
|
|
- person2_datetime: str,
|
|
|
|
|
- person2_latitude: float,
|
|
|
|
|
- person2_longitude: float,
|
|
|
|
|
- start_date: str,
|
|
|
|
|
- end_date: str,
|
|
|
|
|
- min_significance: float = 0.0,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Daily transit-to-Davison chart aspect snapshot over a time range.
|
|
|
|
|
-
|
|
|
|
|
-Calculates the Davison chart for two people, then shows transiting aspects to
|
|
|
|
|
-Davison planet positions for each day. Use for timing relationship milestones
|
|
|
|
|
-and long-term evolution tracking.
|
|
|
|
|
-
|
|
|
|
|
-For interpretation guidance, fetch resource: astro://guides/relationship-astrology
|
|
|
|
|
-
|
|
|
|
|
-Args:
|
|
|
|
|
- person1_datetime, person1_latitude, person1_longitude: Person 1 birth data.
|
|
|
|
|
- person2_datetime, person2_latitude, person2_longitude: Person 2 birth data.
|
|
|
|
|
- start_date: Start of range (YYYY-MM-DD).
|
|
|
|
|
- end_date: End of range (YYYY-MM-DD). Maximum 365 days.
|
|
|
|
|
- min_significance: Minimum significance score (0-10) to include. Default 0 = all.
|
|
|
|
|
-
|
|
|
|
|
-Returns:
|
|
|
|
|
- Daily snapshots with active transit-to-Davison aspects, sorted by date.
|
|
|
|
|
- Each day: date, aspects (transiting, davison, orb, applying, significance), count."""
|
|
|
|
|
- from datetime import datetime, timedelta, timezone
|
|
|
|
|
-
|
|
|
|
|
- # Calculate Davison chart
|
|
|
|
|
- davison = astrology.compute_davison_chart(0.0, 0.0, person1_datetime, person2_datetime)
|
|
|
|
|
- mid_lat = (person1_latitude + person2_latitude) / 2
|
|
|
|
|
- mid_lon = (person1_longitude + person2_longitude) / 2
|
|
|
|
|
- davison_dt = _jd_to_datetime(davison["date_midpoint_jd"])
|
|
|
|
|
-
|
|
|
|
|
- sky = await call_sky_state(datetime=davison_dt, lat=mid_lat, lon=mid_lon, geocentric=True)
|
|
|
|
|
- if "error" in sky:
|
|
|
|
|
- return {"error": f"davison ephemeris error: {sky['error']}"}
|
|
|
|
|
-
|
|
|
|
|
- raw_bodies = extract_bodies(sky)
|
|
|
|
|
- davison_lons = {b["body"]: b.get("ecliptic_lon", 0.0) for b in raw_bodies}
|
|
|
|
|
-
|
|
|
|
|
- # Parse date range
|
|
|
|
|
- try:
|
|
|
|
|
- start = datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc)
|
|
|
|
|
- end = datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc)
|
|
|
|
|
- except Exception:
|
|
|
|
|
- return {"error": "Invalid date format. Use ISO format (YYYY-MM-DD)."}
|
|
|
|
|
- if end <= start:
|
|
|
|
|
- return {"error": "end_date must be after start_date"}
|
|
|
|
|
- if (end - start).days > 365:
|
|
|
|
|
- return {"error": "Date range too large. Maximum 365 days."}
|
|
|
|
|
-
|
|
|
|
|
- # Build aspect checks
|
|
|
|
|
- aspect_checks = []
|
|
|
|
|
- for t_name in astrology.TRANSIT_ORB_RADII:
|
|
|
|
|
- for d_name in davison_lons:
|
|
|
|
|
- for asp_def in astrology.ASPECT_DEFINITIONS:
|
|
|
|
|
- asp_name = asp_def["name"]
|
|
|
|
|
- asp_angle = asp_def["angle"]
|
|
|
|
|
- max_orb = astrology.get_transit_orb(t_name, d_name, asp_name)
|
|
|
|
|
- aspect_checks.append((t_name, d_name, asp_name, asp_angle, max_orb))
|
|
|
|
|
-
|
|
|
|
|
- # Daily scan
|
|
|
|
|
- days = []
|
|
|
|
|
- current_day = start
|
|
|
|
|
- while current_day <= end:
|
|
|
|
|
- iso = current_day.strftime("%Y-%m-%dT12:00:00Z")
|
|
|
|
|
- sky = await call_sky_state(datetime=iso, lat=0.0, lon=0.0, geocentric=True)
|
|
|
|
|
- if "error" in sky:
|
|
|
|
|
- current_day += timedelta(days=1)
|
|
|
|
|
- continue
|
|
|
|
|
-
|
|
|
|
|
- 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}
|
|
|
|
|
-
|
|
|
|
|
- day_aspects = []
|
|
|
|
|
- for t_name, d_name, asp_name, asp_angle, max_orb in aspect_checks:
|
|
|
|
|
- if t_name not in transit_lons or d_name not in davison_lons:
|
|
|
|
|
- continue
|
|
|
|
|
- t_lon = transit_lons[t_name]
|
|
|
|
|
- d_lon = davison_lons[d_name]
|
|
|
|
|
- diff = abs(t_lon - d_lon)
|
|
|
|
|
- diff = min(diff, 360.0 - diff)
|
|
|
|
|
- orb = abs(diff - asp_angle)
|
|
|
|
|
- if orb > max_orb:
|
|
|
|
|
- continue
|
|
|
|
|
- significance = astrology.get_transit_significance(t_name, d_name, asp_name, orb, max_orb)
|
|
|
|
|
- if significance < min_significance:
|
|
|
|
|
- continue
|
|
|
|
|
- t_speed = transit_speeds.get(t_name, 0.0)
|
|
|
|
|
- applying = astrology._is_applying(t_lon, d_lon, t_speed, 0.0, asp_angle)
|
|
|
|
|
- day_aspects.append({
|
|
|
|
|
- "transiting": t_name,
|
|
|
|
|
- "davison": d_name,
|
|
|
|
|
- "aspect": asp_name,
|
|
|
|
|
- "orb": round(orb, 4),
|
|
|
|
|
- "applying": applying,
|
|
|
|
|
- "significance": significance,
|
|
|
|
|
- })
|
|
|
|
|
-
|
|
|
|
|
- day_aspects.sort(key=lambda a: a["significance"], reverse=True)
|
|
|
|
|
- days.append({
|
|
|
|
|
- "date": current_day.strftime("%Y-%m-%d"),
|
|
|
|
|
- "aspects": day_aspects,
|
|
|
|
|
- "count": len(day_aspects),
|
|
|
|
|
- })
|
|
|
|
|
- current_day += timedelta(days=1)
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- "input": {
|
|
|
|
|
- "person1": {"datetime": person1_datetime, "latitude": person1_latitude, "longitude": person1_longitude},
|
|
|
|
|
- "person2": {"datetime": person2_datetime, "latitude": person2_latitude, "longitude": person2_longitude},
|
|
|
|
|
- "start_date": start_date,
|
|
|
|
|
- "end_date": end_date,
|
|
|
|
|
- },
|
|
|
|
|
- "days": days,
|
|
|
|
|
- "total_aspects": sum(d["count"] for d in days),
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── Tool: get_karmic_relationship_summary ─────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def get_karmic_relationship_summary(
|
|
|
|
|
- person1_id: str,
|
|
|
|
|
- person2_id: str,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Generate a karmic relationship summary from synastry, composite, and Davison charts.
|
|
|
|
|
-
|
|
|
|
|
-Combines karmic indicators across all three relationship chart layers:
|
|
|
|
|
-- Synastry: Saturn/Pluto/Node interchart aspects
|
|
|
|
|
-- Composite: Saturn, Pluto, Node positions
|
|
|
|
|
-- Davison: Saturn, Pluto, Node positions
|
|
|
|
|
-
|
|
|
|
|
-This is the PRIMARY tool for karmic relationship analysis. It consolidates
|
|
|
|
|
-the key indicators into a single structured report with a karmic weight score.
|
|
|
|
|
-
|
|
|
|
|
-For interpretation guidance, fetch resources:
|
|
|
|
|
-- astro://guides/karmic-astrology
|
|
|
|
|
-- astro://guides/relationship-astrology
|
|
|
|
|
-
|
|
|
|
|
-Args:
|
|
|
|
|
- person1_id: ID or nickname of person 1 in the persons database.
|
|
|
|
|
- person2_id: ID or nickname of person 2 in the persons database.
|
|
|
|
|
- house_system: House system (default: Placidus).
|
|
|
|
|
-
|
|
|
|
|
-Returns:
|
|
|
|
|
- Dict with: karmic_weight (composite score), synastry_karmic_aspects,
|
|
|
|
|
- composite_karmic_planets, davison_karmic_planets, summary
|
|
|
|
|
- (saturn/pluto/node contact counts)."""
|
|
|
|
|
- # Get synastry with karmic filter
|
|
|
|
|
- synastry = await calculate_synastry_chart_by_id(
|
|
|
|
|
- person1_id, person2_id,
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- karmic_filter=True,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" in synastry:
|
|
|
|
|
- return synastry
|
|
|
|
|
-
|
|
|
|
|
- # Get composite chart
|
|
|
|
|
- composite = await calculate_composite_chart_by_id(
|
|
|
|
|
- person1_id, person2_id,
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" in composite:
|
|
|
|
|
- return composite
|
|
|
|
|
-
|
|
|
|
|
- # Get Davison chart
|
|
|
|
|
- davison = await calculate_davison_chart_by_id(
|
|
|
|
|
- person1_id, person2_id,
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" in davison:
|
|
|
|
|
- return davison
|
|
|
|
|
-
|
|
|
|
|
- def _extract_karmic_planets(chart_data: dict, key: str) -> dict:
|
|
|
|
|
- """Extract Saturn, Pluto, Node from a chart's planet list."""
|
|
|
|
|
- result = {}
|
|
|
|
|
- for p in chart_data.get(key, []):
|
|
|
|
|
- if p["body"] in ("saturn", "pluto", "true_node"):
|
|
|
|
|
- result[p["body"]] = {
|
|
|
|
|
- "sign": p.get("sign"),
|
|
|
|
|
- "house": p.get("house"),
|
|
|
|
|
- "retrograde": p.get("retrograde"),
|
|
|
|
|
- }
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
- synastry_karmic_aspects = synastry.get("interaspects", [])
|
|
|
|
|
- composite_karmic = _extract_karmic_planets(composite, "planets")
|
|
|
|
|
- davison_karmic = _extract_karmic_planets(davison, "planets")
|
|
|
|
|
-
|
|
|
|
|
- # Count karmic weight
|
|
|
|
|
- karmic_weight = len(synastry_karmic_aspects)
|
|
|
|
|
- if composite_karmic.get("saturn"):
|
|
|
|
|
- karmic_weight += 1
|
|
|
|
|
- if composite_karmic.get("pluto"):
|
|
|
|
|
- karmic_weight += 1
|
|
|
|
|
- if davison_karmic.get("saturn"):
|
|
|
|
|
- karmic_weight += 1
|
|
|
|
|
- if davison_karmic.get("pluto"):
|
|
|
|
|
- karmic_weight += 1
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- "karmic_weight": karmic_weight,
|
|
|
|
|
- "synastry_karmic_aspects": synastry_karmic_aspects,
|
|
|
|
|
- "composite_karmic_planets": composite_karmic,
|
|
|
|
|
- "davison_karmic_planets": davison_karmic,
|
|
|
|
|
- "summary": {
|
|
|
|
|
- "saturn_contacts": len([a for a in synastry_karmic_aspects if "saturn" in (a["person1_planet"], a["person2_planet"])]),
|
|
|
|
|
- "pluto_contacts": len([a for a in synastry_karmic_aspects if "pluto" in (a["person1_planet"], a["person2_planet"])]),
|
|
|
|
|
- "node_contacts": len([a for a in synastry_karmic_aspects if "true_node" in (a["person1_planet"], a["person2_planet"])]),
|
|
|
|
|
- },
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── Tool: list_house_systems ─────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-def list_house_systems() -> dict[str, Any]:
|
|
|
|
|
- """List supported house systems.
|
|
|
|
|
-
|
|
|
|
|
- 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."""
|
|
|
|
|
- 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. 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."},
|
|
|
|
|
- ],
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── _byId convenience tools ──────────────────────────────────────────
|
|
|
|
|
-# These tools accept a person_id (from the DB) and optional overrides,
|
|
|
|
|
-# then call the core chart tools with the person's birth data.
|
|
|
|
|
-# Unprovided optional params fall back to the default of the core tool.
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-async def _get_person_birth_data(person_id: str) -> dict[str, Any]:
|
|
|
|
|
- """Fetch birth data from the persons database and convert datetime to UTC.
|
|
|
|
|
-
|
|
|
|
|
- This is the SINGLE POINT where stored birth datetime is converted to UTC.
|
|
|
|
|
- All _byId chart tools call this function and receive UTC datetimes — they
|
|
|
|
|
- must NOT perform any additional timezone conversion.
|
|
|
|
|
-
|
|
|
|
|
- The DB stores birth_datetime as naive local time (no offset) and timezone
|
|
|
|
|
- as an IANA name (e.g. "Europe/Vienna"). This function combines them to
|
|
|
|
|
- produce the correct UTC datetime, using zoneinfo for modern dates and
|
|
|
|
|
- LMT (longitude/15) as fallback when no timezone is set.
|
|
|
|
|
-
|
|
|
|
|
- Rule: birth_datetime in the returned dict is ALWAYS naive local time.
|
|
|
|
|
- birth_datetime_utc is for internal ephemeris calls only.
|
|
|
|
|
- """
|
|
|
|
|
- from . import storage
|
|
|
|
|
- from .ephemeris_client import _normalize_datetime
|
|
|
|
|
- person = await storage.get_person(person_id=person_id)
|
|
|
|
|
- if not person:
|
|
|
|
|
- person = await storage.get_person(nickname=person_id)
|
|
|
|
|
- if not person:
|
|
|
|
|
- return {"error": f"person not found: {person_id}"}
|
|
|
|
|
- utc_dt = _normalize_datetime(
|
|
|
|
|
- person["birth_datetime"],
|
|
|
|
|
- tz_name=person.get("timezone"),
|
|
|
|
|
- lon=person["longitude"],
|
|
|
|
|
- )
|
|
|
|
|
- return {
|
|
|
|
|
- "birth_datetime": person["birth_datetime"], # naive local time
|
|
|
|
|
- "timezone": person.get("timezone"),
|
|
|
|
|
- "birth_datetime_utc": utc_dt, # for ephemeris calls
|
|
|
|
|
- "birthplace": person.get("birthplace"),
|
|
|
|
|
- "latitude": person["latitude"],
|
|
|
|
|
- "longitude": person["longitude"],
|
|
|
|
|
- "elevation": person.get("elevation", 0.0),
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def calculate_natal_chart_by_id(
|
|
|
|
|
- person_id: str,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
- include_overview: bool = False,
|
|
|
|
|
- include_patterns: bool = False,
|
|
|
|
|
- include_karmic: bool = False,
|
|
|
|
|
- top_n_aspects: int | None = None,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Calculate natal chart for a person from the database.
|
|
|
|
|
-
|
|
|
|
|
- Same as calculate_natal_chart but fetches birth data from the persons database
|
|
|
|
|
- by ID or nickname. See calculate_natal_chart for full documentation.
|
|
|
|
|
-
|
|
|
|
|
- For interpretation guidance, fetch resource: astro://guides/natal-astrology
|
|
|
|
|
-
|
|
|
|
|
- Args:
|
|
|
|
|
- person_id: ID or nickname of a person in the persons database.
|
|
|
|
|
- house_system: House system (default: Placidus).
|
|
|
|
|
- orb_limits: Optional orb configuration.
|
|
|
|
|
- include_overview: Add element/modality/hemisphere balance, stelliums, etc.
|
|
|
|
|
- include_patterns: Add aspect pattern detection and chart shape.
|
|
|
|
|
- include_karmic: Add nodal axis, Saturn, Pluto polarity point, etc.
|
|
|
|
|
- top_n_aspects: Limit aspects to the N tightest by orb.
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Complete natal chart structure (see calculate_natal_chart)."""
|
|
|
|
|
- birth = await _get_person_birth_data(person_id)
|
|
|
|
|
- if "error" in birth:
|
|
|
|
|
- return birth
|
|
|
|
|
- result = await calculate_natal_chart(
|
|
|
|
|
- birth_datetime=birth["birth_datetime_utc"],
|
|
|
|
|
- latitude=birth["latitude"],
|
|
|
|
|
- longitude=birth["longitude"],
|
|
|
|
|
- elevation=birth.get("elevation", 0.0),
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- orb_limits=orb_limits,
|
|
|
|
|
- include_overview=include_overview,
|
|
|
|
|
- include_patterns=include_patterns,
|
|
|
|
|
- include_karmic=include_karmic,
|
|
|
|
|
- top_n_aspects=top_n_aspects,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" not in result:
|
|
|
|
|
- result["input"]["birth_datetime"] = birth["birth_datetime"]
|
|
|
|
|
- result["input"]["timezone"] = birth.get("timezone")
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def calculate_transit_chart_by_id(
|
|
|
|
|
- person_id: str,
|
|
|
|
|
- transit_datetime: str,
|
|
|
|
|
- transit_latitude: float | None = None,
|
|
|
|
|
- transit_longitude: float | None = None,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Calculate transit chart for a person from the database.
|
|
|
|
|
-
|
|
|
|
|
-Same as calculate_transit_chart but fetches birth data from the persons database.
|
|
|
|
|
-
|
|
|
|
|
-For interpretation guidance, fetch resource: astro://guides/natal-astrology
|
|
|
|
|
-
|
|
|
|
|
-Args:
|
|
|
|
|
- person_id: ID of a person in the persons database.
|
|
|
|
|
- transit_datetime: ISO 8601 transit datetime (UTC).
|
|
|
|
|
- transit_latitude: Current location latitude. Defaults to birth latitude.
|
|
|
|
|
- transit_longitude: Current location longitude. Defaults to birth longitude.
|
|
|
|
|
- house_system: House system for natal houses (default: Placidus).
|
|
|
|
|
- orb_limits: Optional orb configuration.
|
|
|
|
|
-
|
|
|
|
|
-Returns:
|
|
|
|
|
- Transit chart structure (see calculate_transit_chart)."""
|
|
|
|
|
- birth = await _get_person_birth_data(person_id)
|
|
|
|
|
- if "error" in birth:
|
|
|
|
|
- return birth
|
|
|
|
|
- result = await calculate_transit_chart(
|
|
|
|
|
- birth_datetime=birth["birth_datetime_utc"],
|
|
|
|
|
- transit_datetime=transit_datetime,
|
|
|
|
|
- latitude=birth["latitude"],
|
|
|
|
|
- longitude=birth["longitude"],
|
|
|
|
|
- transit_latitude=transit_latitude,
|
|
|
|
|
- transit_longitude=transit_longitude,
|
|
|
|
|
- elevation=birth.get("elevation", 0.0),
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- orb_limits=orb_limits,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" not in result:
|
|
|
|
|
- result["input"]["birth_datetime"] = birth["birth_datetime"]
|
|
|
|
|
- result["input"]["timezone"] = birth.get("timezone")
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def calculate_synastry_chart_by_id(
|
|
|
|
|
- person1_id: str,
|
|
|
|
|
- person2_id: str,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
- top_n_aspects: int | None = None,
|
|
|
|
|
- karmic_filter: bool = False,
|
|
|
|
|
- significator_filter: bool = False,
|
|
|
|
|
- include_davison_full: bool = False,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Calculate synastry chart for two persons from the database.
|
|
|
|
|
-
|
|
|
|
|
- Same as calculate_synastry_chart but fetches birth data from the persons database
|
|
|
|
|
- by ID or nickname. See calculate_synastry_chart for full documentation.
|
|
|
|
|
-
|
|
|
|
|
- For interpretation guidance, fetch resource: astro://guides/relationship-astrology
|
|
|
|
|
-
|
|
|
|
|
- Args:
|
|
|
|
|
- person1_id: ID of person 1 in the persons database.
|
|
|
|
|
- person2_id: ID of person 2 in the persons database.
|
|
|
|
|
- house_system: House system (default: Placidus).
|
|
|
|
|
- orb_limits: Optional orb configuration.
|
|
|
|
|
- top_n_aspects: Limit interaspects to top N by orb.
|
|
|
|
|
- karmic_filter: Only return Saturn/Pluto/Node interaspects.
|
|
|
|
|
- significator_filter: Only return Venus-Mars, Moon-Venus, Sun-Moon, Sun-Saturn.
|
|
|
|
|
- include_davison_full: Compute full Davison chart.
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Synastry chart structure (see calculate_synastry_chart)."""
|
|
|
|
|
- p1 = await _get_person_birth_data(person1_id)
|
|
|
|
|
- if "error" in p1:
|
|
|
|
|
- return p1
|
|
|
|
|
- p2 = await _get_person_birth_data(person2_id)
|
|
|
|
|
- if "error" in p2:
|
|
|
|
|
- return p2
|
|
|
|
|
- result = await calculate_synastry_chart(
|
|
|
|
|
- person1_datetime=p1["birth_datetime_utc"],
|
|
|
|
|
- person1_latitude=p1["latitude"],
|
|
|
|
|
- person1_longitude=p1["longitude"],
|
|
|
|
|
- person2_datetime=p2["birth_datetime_utc"],
|
|
|
|
|
- person2_latitude=p2["latitude"],
|
|
|
|
|
- person2_longitude=p2["longitude"],
|
|
|
|
|
- elevation=p1.get("elevation", 0.0),
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- orb_limits=orb_limits,
|
|
|
|
|
- top_n_aspects=top_n_aspects,
|
|
|
|
|
- karmic_filter=karmic_filter,
|
|
|
|
|
- significator_filter=significator_filter,
|
|
|
|
|
- include_davison_full=include_davison_full,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" not in result:
|
|
|
|
|
- result["input"]["person1_birth_datetime"] = p1["birth_datetime"]
|
|
|
|
|
- result["input"]["person1_timezone"] = p1.get("timezone")
|
|
|
|
|
- result["input"]["person2_birth_datetime"] = p2["birth_datetime"]
|
|
|
|
|
- result["input"]["person2_timezone"] = p2.get("timezone")
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def calculate_composite_chart_by_id(
|
|
|
|
|
- person1_id: str,
|
|
|
|
|
- person2_id: str,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Calculate composite chart for two persons from the database.
|
|
|
|
|
-
|
|
|
|
|
-Same as calculate_composite_chart but fetches birth data from the persons database.
|
|
|
|
|
-
|
|
|
|
|
-For interpretation guidance, fetch resource: astro://guides/relationship-astrology
|
|
|
|
|
-
|
|
|
|
|
-Args:
|
|
|
|
|
- person1_id: ID of person 1 in the persons database.
|
|
|
|
|
- person2_id: ID of person 2 in the persons database.
|
|
|
|
|
- house_system: House system (default: Placidus).
|
|
|
|
|
- orb_limits: Optional orb configuration.
|
|
|
|
|
-
|
|
|
|
|
-Returns:
|
|
|
|
|
- Composite chart structure (see calculate_composite_chart)."""
|
|
|
|
|
- p1 = await _get_person_birth_data(person1_id)
|
|
|
|
|
- if "error" in p1:
|
|
|
|
|
- return p1
|
|
|
|
|
- p2 = await _get_person_birth_data(person2_id)
|
|
|
|
|
- if "error" in p2:
|
|
|
|
|
- return p2
|
|
|
|
|
- result = await calculate_composite_chart(
|
|
|
|
|
- person1_datetime=p1["birth_datetime_utc"],
|
|
|
|
|
- person1_latitude=p1["latitude"],
|
|
|
|
|
- person1_longitude=p1["longitude"],
|
|
|
|
|
- person2_datetime=p2["birth_datetime_utc"],
|
|
|
|
|
- person2_latitude=p2["latitude"],
|
|
|
|
|
- person2_longitude=p2["longitude"],
|
|
|
|
|
- elevation=p1.get("elevation", 0.0),
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- orb_limits=orb_limits,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" not in result:
|
|
|
|
|
- result["input"]["person1_birth_datetime"] = p1["birth_datetime"]
|
|
|
|
|
- result["input"]["person1_timezone"] = p1.get("timezone")
|
|
|
|
|
- result["input"]["person2_birth_datetime"] = p2["birth_datetime"]
|
|
|
|
|
- result["input"]["person2_timezone"] = p2.get("timezone")
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def calculate_davison_chart_by_id(
|
|
|
|
|
- person1_id: str,
|
|
|
|
|
- person2_id: str,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Calculate Davison chart for two persons from the database.
|
|
|
|
|
-
|
|
|
|
|
-Same as calculate_davison_chart but fetches birth data from the persons database.
|
|
|
|
|
-
|
|
|
|
|
-For interpretation guidance, fetch resource: astro://guides/relationship-astrology
|
|
|
|
|
-
|
|
|
|
|
-Args:
|
|
|
|
|
- person1_id: ID of person 1 in the persons database.
|
|
|
|
|
- person2_id: ID of person 2 in the persons database.
|
|
|
|
|
- house_system: House system (default: Placidus).
|
|
|
|
|
- orb_limits: Optional orb configuration.
|
|
|
|
|
-
|
|
|
|
|
-Returns:
|
|
|
|
|
- Davison chart structure (see calculate_davison_chart)."""
|
|
|
|
|
- p1 = await _get_person_birth_data(person1_id)
|
|
|
|
|
- if "error" in p1:
|
|
|
|
|
- return p1
|
|
|
|
|
- p2 = await _get_person_birth_data(person2_id)
|
|
|
|
|
- if "error" in p2:
|
|
|
|
|
- return p2
|
|
|
|
|
- result = await calculate_davison_chart(
|
|
|
|
|
- person1_datetime=p1["birth_datetime_utc"],
|
|
|
|
|
- person1_latitude=p1["latitude"],
|
|
|
|
|
- person1_longitude=p1["longitude"],
|
|
|
|
|
- person2_datetime=p2["birth_datetime_utc"],
|
|
|
|
|
- person2_latitude=p2["latitude"],
|
|
|
|
|
- person2_longitude=p2["longitude"],
|
|
|
|
|
- elevation=p1.get("elevation", 0.0),
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- orb_limits=orb_limits,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" not in result:
|
|
|
|
|
- result["input"]["person1_birth_datetime"] = p1["birth_datetime"]
|
|
|
|
|
- result["input"]["person1_timezone"] = p1.get("timezone")
|
|
|
|
|
- result["input"]["person2_birth_datetime"] = p2["birth_datetime"]
|
|
|
|
|
- result["input"]["person2_timezone"] = p2.get("timezone")
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def get_transit_preview_by_id(
|
|
|
|
|
- person_id: str,
|
|
|
|
|
- start_date: str,
|
|
|
|
|
- end_date: str,
|
|
|
|
|
- transit_latitude: float | None = None,
|
|
|
|
|
- transit_longitude: float | None = None,
|
|
|
|
|
- min_significance: float = 0.0,
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Daily transit-to-natal aspect snapshot for a person from the database.
|
|
|
|
|
-
|
|
|
|
|
-Same as get_transit_preview but fetches birth data from the persons database.
|
|
|
|
|
-
|
|
|
|
|
-For interpretation guidance, fetch resource: astro://guides/natal-astrology
|
|
|
|
|
-
|
|
|
|
|
-Args:
|
|
|
|
|
- person_id: ID or nickname of a person in the persons database.
|
|
|
|
|
- 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.
|
|
|
|
|
- min_significance: Minimum significance score (0-10). Default 0 = all.
|
|
|
|
|
-
|
|
|
|
|
-Returns:
|
|
|
|
|
- Daily transit snapshots (see get_transit_preview)."""
|
|
|
|
|
- birth = await _get_person_birth_data(person_id)
|
|
|
|
|
- if "error" in birth:
|
|
|
|
|
- return birth
|
|
|
|
|
- result = await get_transit_preview(
|
|
|
|
|
- birth_datetime=birth["birth_datetime_utc"],
|
|
|
|
|
- latitude=birth["latitude"],
|
|
|
|
|
- longitude=birth["longitude"],
|
|
|
|
|
- start_date=start_date,
|
|
|
|
|
- end_date=end_date,
|
|
|
|
|
- transit_latitude=transit_latitude,
|
|
|
|
|
- transit_longitude=transit_longitude,
|
|
|
|
|
- min_significance=min_significance,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" not in result:
|
|
|
|
|
- result["input"]["birth_datetime"] = birth["birth_datetime"]
|
|
|
|
|
- result["input"]["timezone"] = birth.get("timezone")
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
-# CHART RENDERING TOOLS
|
|
|
|
|
-# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
-# These tools render visual chart wheels from birth data.
|
|
|
|
|
-# They combine calculation + rendering in one step.
|
|
|
|
|
-# For data-only output, use the calculate_* tools instead.
|
|
|
|
|
-# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
-
|
|
|
|
|
-# ── Shared render options (used by all render_* tools) ────────────────
|
|
|
|
|
-
|
|
|
|
|
-_RENDER_STYLE_HELP = (
|
|
|
|
|
- "Chart visual style: 'modern' (clean, minimal), 'traditional' "
|
|
|
|
|
- "(ornate, classical), or 'minimal' (bare bones)."
|
|
|
|
|
|
|
+# Re-export call_sky_state so existing test patches targeting
|
|
|
|
|
+# src.astro_mcp.tools.call_sky_state continue to work.
|
|
|
|
|
+from .ephemeris_client import call_sky_state # noqa: F401
|
|
|
|
|
+
|
|
|
|
|
+# Import submodules to trigger @mcp.tool() decorator registration.
|
|
|
|
|
+from . import chart_tools # noqa: F401
|
|
|
|
|
+from . import by_id_tools # noqa: F401
|
|
|
|
|
+from . import person_tools # noqa: F401
|
|
|
|
|
+from . import render_tools # noqa: F401
|
|
|
|
|
+
|
|
|
|
|
+# Re-export all public tools for backward compatibility.
|
|
|
|
|
+from .chart_tools import ( # noqa: F401
|
|
|
|
|
+ get_planetary_positions,
|
|
|
|
|
+ calculate_natal_chart,
|
|
|
|
|
+ calculate_transit_chart,
|
|
|
|
|
+ calculate_synastry_chart,
|
|
|
|
|
+ calculate_composite_chart,
|
|
|
|
|
+ calculate_davison_chart,
|
|
|
|
|
+ get_transit_preview,
|
|
|
|
|
+ get_composite_transit_preview,
|
|
|
|
|
+ get_davison_transit_preview,
|
|
|
|
|
+ DEFAULT_ORBS,
|
|
|
)
|
|
)
|
|
|
-_RENDER_COLOR_HELP = (
|
|
|
|
|
- "Color mode: 'color' (full color with element-themed zodiac ring), "
|
|
|
|
|
- "'bw' (black/white, aspect lines distinguished by style), or "
|
|
|
|
|
- "'dark' (dark background for web display)."
|
|
|
|
|
|
|
+from .by_id_tools import ( # noqa: F401
|
|
|
|
|
+ _get_person_birth_data,
|
|
|
|
|
+ calculate_natal_chart_by_id,
|
|
|
|
|
+ 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,
|
|
|
|
|
+ get_karmic_relationship_summary,
|
|
|
|
|
+)
|
|
|
|
|
+from .person_tools import ( # noqa: F401
|
|
|
|
|
+ person_manage,
|
|
|
|
|
+ list_house_systems,
|
|
|
|
|
+)
|
|
|
|
|
+from .render_tools import ( # noqa: F401
|
|
|
|
|
+ render_natal_chart,
|
|
|
|
|
+ render_natal_chart_by_id,
|
|
|
|
|
+ render_transit_chart,
|
|
|
|
|
+ render_transit_chart_by_id,
|
|
|
|
|
+ render_synastry_chart,
|
|
|
|
|
+ render_synastry_chart_by_id,
|
|
|
|
|
+ render_composite_chart,
|
|
|
|
|
+ render_composite_chart_by_id,
|
|
|
|
|
+ render_davison_chart,
|
|
|
|
|
+ render_davison_chart_by_id,
|
|
|
)
|
|
)
|
|
|
-_RENDER_SIZE_HELP = "SVG width/height in pixels (default: 600)."
|
|
|
|
|
-_RENDER_TABLE_HELP = "Include an aspect table below the wheel."
|
|
|
|
|
-_RENDER_PLANETS_HELP = "Include a planet data table below the wheel."
|
|
|
|
|
-_RENDER_HOUSES_HELP = "Include a house cusp table below the wheel."
|
|
|
|
|
-_RENDER_TITLE_HELP = "Custom chart title. Auto-generated if not provided."
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── render_natal_chart ────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def render_natal_chart(
|
|
|
|
|
- # ── Birth data (same as calculate_natal_chart) ──────────────────
|
|
|
|
|
- birth_datetime: str,
|
|
|
|
|
- latitude: float,
|
|
|
|
|
- longitude: float,
|
|
|
|
|
- elevation: float = 0.0,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
- top_n_aspects: int | None = None,
|
|
|
|
|
- # ── Rendering options ───────────────────────────────────────────
|
|
|
|
|
- style: str = "modern",
|
|
|
|
|
- color_mode: str = "color",
|
|
|
|
|
- size: int = 600,
|
|
|
|
|
- table_position: str = "none",
|
|
|
|
|
- include_planets: bool = False,
|
|
|
|
|
- include_houses: bool = False,
|
|
|
|
|
- title: str | None = None,
|
|
|
|
|
- subtitle: str | None = None,
|
|
|
|
|
- format: str = "svg",
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Render a natal chart wheel.
|
|
|
|
|
-
|
|
|
|
|
- Calculates planetary positions and renders a visual zodiac wheel with
|
|
|
|
|
- planets, houses, and aspect lines. Output as SVG or raster image (PNG/JPG).
|
|
|
|
|
-
|
|
|
|
|
- BIRTH DATA (required):
|
|
|
|
|
- birth_datetime: ISO 8601 datetime with timezone (e.g. "1990-05-15T10:30:00+01:00").
|
|
|
|
|
- latitude: Birth latitude in decimal degrees (-90 to 90).
|
|
|
|
|
- longitude: Birth longitude in decimal degrees (-180 to 180).
|
|
|
|
|
-
|
|
|
|
|
- BIRTH DATA (optional):
|
|
|
|
|
- elevation: Birth elevation in meters (default: 0).
|
|
|
|
|
- house_system: "placidus" (default), "equal", or "whole_sign".
|
|
|
|
|
- orb_limits: Per-aspect orb overrides, e.g. {"conjunction": 10}.
|
|
|
|
|
- top_n_aspects: Limit aspects to the N tightest by orb.
|
|
|
|
|
-
|
|
|
|
|
- RENDERING OPTIONS:
|
|
|
|
|
- style: {_RENDER_STYLE_HELP}
|
|
|
|
|
- color_mode: {_RENDER_COLOR_HELP}
|
|
|
|
|
- size: {_RENDER_SIZE_HELP}
|
|
|
|
|
- table_position: "none" (wheel only), "below" (portrait layout with tables
|
|
|
|
|
- under the wheel), or "right" (landscape layout with tables to the right).
|
|
|
|
|
- include_planets: Include a planet data table (requires table_position != "none").
|
|
|
|
|
- include_houses: Include a house cusp table (requires table_position != "none").
|
|
|
|
|
- title: Custom chart title. Auto-generated if not provided.
|
|
|
|
|
- subtitle: Custom subtitle. Auto-generated from birth data if not provided.
|
|
|
|
|
- format: Output format — "svg" (default), "png", or "jpg".
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Dict with "content", "format", "content_type", "width", "height",
|
|
|
|
|
- and "included" (list of what's in the chart).
|
|
|
|
|
- """
|
|
|
|
|
- from .chart_renderer import render_natal_wheel
|
|
|
|
|
-
|
|
|
|
|
- chart_data = await calculate_natal_chart(
|
|
|
|
|
- birth_datetime=birth_datetime,
|
|
|
|
|
- latitude=latitude,
|
|
|
|
|
- longitude=longitude,
|
|
|
|
|
- elevation=elevation,
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- orb_limits=orb_limits,
|
|
|
|
|
- top_n_aspects=top_n_aspects,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" in chart_data:
|
|
|
|
|
- return chart_data
|
|
|
|
|
-
|
|
|
|
|
- result = render_natal_wheel(
|
|
|
|
|
- chart_data,
|
|
|
|
|
- style=style,
|
|
|
|
|
- color_mode=color_mode,
|
|
|
|
|
- size=size,
|
|
|
|
|
- table_position=table_position,
|
|
|
|
|
- include_planets=include_planets,
|
|
|
|
|
- include_houses=include_houses,
|
|
|
|
|
- title=title,
|
|
|
|
|
- subtitle=subtitle,
|
|
|
|
|
- format=format,
|
|
|
|
|
- )
|
|
|
|
|
- result["included"] = _included_list(table_position, include_planets, include_houses)
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── render_natal_chart_by_id ──────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def render_natal_chart_by_id(
|
|
|
|
|
- # ── Person lookup (same as calculate_natal_chart_by_id) ─────────
|
|
|
|
|
- person_id: str,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
- top_n_aspects: int | None = None,
|
|
|
|
|
- # ── Rendering options ───────────────────────────────────────────
|
|
|
|
|
- style: str = "modern",
|
|
|
|
|
- color_mode: str = "color",
|
|
|
|
|
- size: int = 600,
|
|
|
|
|
- table_position: str = "none",
|
|
|
|
|
- include_planets: bool = False,
|
|
|
|
|
- include_houses: bool = False,
|
|
|
|
|
- title: str | None = None,
|
|
|
|
|
- format: str = "svg",
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Render a natal chart wheel for a person from the database.
|
|
|
|
|
-
|
|
|
|
|
- Looks up birth data by person_id or nickname, calculates the chart,
|
|
|
|
|
- and renders it as a wheel. Output as SVG or raster image (PNG/JPG).
|
|
|
|
|
-
|
|
|
|
|
- PERSON LOOKUP (required):
|
|
|
|
|
- person_id: ID or nickname of a person in the persons database.
|
|
|
|
|
-
|
|
|
|
|
- PERSON LOOKUP (optional):
|
|
|
|
|
- house_system: "placidus" (default), "equal", or "whole_sign".
|
|
|
|
|
- orb_limits: Per-aspect orb overrides.
|
|
|
|
|
- top_n_aspects: Limit aspects to the N tightest by orb.
|
|
|
|
|
-
|
|
|
|
|
- RENDERING OPTIONS:
|
|
|
|
|
- style: {_RENDER_STYLE_HELP}
|
|
|
|
|
- color_mode: {_RENDER_COLOR_HELP}
|
|
|
|
|
- size: {_RENDER_SIZE_HELP}
|
|
|
|
|
- table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
|
|
|
|
|
- include_planets: {_RENDER_PLANETS_HELP}
|
|
|
|
|
- include_houses: {_RENDER_HOUSES_HELP}
|
|
|
|
|
- title: {_RENDER_TITLE_HELP}
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Dict with "svg" (SVG string), "format", "width", "height", "included".
|
|
|
|
|
- """
|
|
|
|
|
- from .chart_renderer import render_natal_wheel
|
|
|
|
|
-
|
|
|
|
|
- chart_data = await calculate_natal_chart_by_id(
|
|
|
|
|
- person_id=person_id,
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- orb_limits=orb_limits,
|
|
|
|
|
- top_n_aspects=top_n_aspects,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" in chart_data:
|
|
|
|
|
- return chart_data
|
|
|
|
|
-
|
|
|
|
|
- result = render_natal_wheel(
|
|
|
|
|
- chart_data,
|
|
|
|
|
- style=style,
|
|
|
|
|
- color_mode=color_mode,
|
|
|
|
|
- size=size,
|
|
|
|
|
- table_position=table_position,
|
|
|
|
|
- include_planets=include_planets,
|
|
|
|
|
- include_houses=include_houses,
|
|
|
|
|
- title=title,
|
|
|
|
|
- format=format,
|
|
|
|
|
- )
|
|
|
|
|
- result["included"] = _included_list(table_position, include_planets, include_houses)
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── render_transit_chart ──────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def render_transit_chart(
|
|
|
|
|
- # ── Birth data + transit date (same as calculate_transit_chart) ─
|
|
|
|
|
- birth_datetime: str,
|
|
|
|
|
- transit_datetime: str,
|
|
|
|
|
- latitude: float,
|
|
|
|
|
- longitude: float,
|
|
|
|
|
- transit_latitude: float | None = None,
|
|
|
|
|
- transit_longitude: float | None = None,
|
|
|
|
|
- elevation: float = 0.0,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
- # ── Rendering options ───────────────────────────────────────────
|
|
|
|
|
- style: str = "modern",
|
|
|
|
|
- color_mode: str = "color",
|
|
|
|
|
- size: int = 600,
|
|
|
|
|
- title: str | None = None,
|
|
|
|
|
- format: str = "svg",
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Render a transit chart as a bi-wheel (natal inner, transit outer).
|
|
|
|
|
-
|
|
|
|
|
- Calculates natal and transiting planet positions, then renders a bi-wheel
|
|
|
|
|
- showing natal planets inside and transiting planets outside, with
|
|
|
|
|
- transit-to-natal aspect lines. Output as SVG or raster image (PNG/JPG).
|
|
|
|
|
-
|
|
|
|
|
- BIRTH DATA (required):
|
|
|
|
|
- birth_datetime: ISO 8601 birth datetime with timezone.
|
|
|
|
|
- transit_datetime: ISO 8601 transit datetime (the "now" or future date).
|
|
|
|
|
- latitude: Birth latitude in decimal degrees.
|
|
|
|
|
- longitude: Birth longitude in decimal degrees.
|
|
|
|
|
-
|
|
|
|
|
- TRANSIT LOCATION (optional):
|
|
|
|
|
- transit_latitude: Location latitude for transit calculation. Defaults to birth latitude.
|
|
|
|
|
- transit_longitude: Location longitude for transit calculation. Defaults to birth longitude.
|
|
|
|
|
-
|
|
|
|
|
- CHART OPTIONS:
|
|
|
|
|
- elevation: Birth elevation in meters (default: 0).
|
|
|
|
|
- house_system: "placidus" (default), "equal", or "whole_sign".
|
|
|
|
|
- orb_limits: Per-aspect orb overrides.
|
|
|
|
|
-
|
|
|
|
|
- RENDERING OPTIONS:
|
|
|
|
|
- style: {_RENDER_STYLE_HELP}
|
|
|
|
|
- color_mode: {_RENDER_COLOR_HELP}
|
|
|
|
|
- size: {_RENDER_SIZE_HELP}
|
|
|
|
|
- title: {_RENDER_TITLE_HELP}
|
|
|
|
|
- format: Output format — "svg" (default), "png", or "jpg".
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Dict with "content", "format", "content_type", "width", "height", "included".
|
|
|
|
|
- """
|
|
|
|
|
- from .chart_renderer import render_transit_wheel
|
|
|
|
|
-
|
|
|
|
|
- chart_data = await calculate_transit_chart(
|
|
|
|
|
- birth_datetime=birth_datetime,
|
|
|
|
|
- transit_datetime=transit_datetime,
|
|
|
|
|
- latitude=latitude,
|
|
|
|
|
- longitude=longitude,
|
|
|
|
|
- transit_latitude=transit_latitude,
|
|
|
|
|
- transit_longitude=transit_longitude,
|
|
|
|
|
- elevation=elevation,
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- orb_limits=orb_limits,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" in chart_data:
|
|
|
|
|
- return chart_data
|
|
|
|
|
-
|
|
|
|
|
- result = render_transit_wheel(
|
|
|
|
|
- chart_data,
|
|
|
|
|
- style=style,
|
|
|
|
|
- color_mode=color_mode,
|
|
|
|
|
- size=size,
|
|
|
|
|
- title=title,
|
|
|
|
|
- format=format,
|
|
|
|
|
- )
|
|
|
|
|
- result["included"] = ["wheel"]
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── render_transit_chart_by_id ────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def render_transit_chart_by_id(
|
|
|
|
|
- # ── Person lookup + transit date ────────────────────────────────
|
|
|
|
|
- person_id: str,
|
|
|
|
|
- transit_datetime: str,
|
|
|
|
|
- transit_latitude: float | None = None,
|
|
|
|
|
- transit_longitude: float | None = None,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
- # ── Rendering options ───────────────────────────────────────────
|
|
|
|
|
- style: str = "modern",
|
|
|
|
|
- color_mode: str = "color",
|
|
|
|
|
- size: int = 600,
|
|
|
|
|
- title: str | None = None,
|
|
|
|
|
- format: str = "svg",
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Render a transit bi-wheel for a person from the database.
|
|
|
|
|
-
|
|
|
|
|
- Looks up birth data by person_id, calculates transits for the given
|
|
|
|
|
- date, and renders a bi-wheel chart. Output as SVG or raster image (PNG/JPG).
|
|
|
|
|
-
|
|
|
|
|
- PERSON LOOKUP (required):
|
|
|
|
|
- person_id: ID or nickname of a person in the persons database.
|
|
|
|
|
- transit_datetime: ISO 8601 transit datetime.
|
|
|
|
|
-
|
|
|
|
|
- TRANSIT LOCATION (optional):
|
|
|
|
|
- transit_latitude: Location latitude. Defaults to birth latitude.
|
|
|
|
|
- transit_longitude: Location longitude. Defaults to birth longitude.
|
|
|
|
|
-
|
|
|
|
|
- CHART OPTIONS:
|
|
|
|
|
- house_system: "placidus" (default), "equal", or "whole_sign".
|
|
|
|
|
- orb_limits: Per-aspect orb overrides.
|
|
|
|
|
-
|
|
|
|
|
- RENDERING OPTIONS:
|
|
|
|
|
- style: {_RENDER_STYLE_HELP}
|
|
|
|
|
- color_mode: {_RENDER_COLOR_HELP}
|
|
|
|
|
- size: {_RENDER_SIZE_HELP}
|
|
|
|
|
- title: {_RENDER_TITLE_HELP}
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Dict with "svg", "format", "width", "height", "included".
|
|
|
|
|
- """
|
|
|
|
|
- from .chart_renderer import render_transit_wheel
|
|
|
|
|
-
|
|
|
|
|
- chart_data = await calculate_transit_chart_by_id(
|
|
|
|
|
- person_id=person_id,
|
|
|
|
|
- transit_datetime=transit_datetime,
|
|
|
|
|
- transit_latitude=transit_latitude,
|
|
|
|
|
- transit_longitude=transit_longitude,
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- orb_limits=orb_limits,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" in chart_data:
|
|
|
|
|
- return chart_data
|
|
|
|
|
-
|
|
|
|
|
- result = render_transit_wheel(
|
|
|
|
|
- chart_data,
|
|
|
|
|
- style=style,
|
|
|
|
|
- color_mode=color_mode,
|
|
|
|
|
- size=size,
|
|
|
|
|
- title=title,
|
|
|
|
|
- format=format,
|
|
|
|
|
- )
|
|
|
|
|
- result["included"] = ["wheel"]
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── render_synastry_chart ─────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def render_synastry_chart(
|
|
|
|
|
- # ── Two people's birth data (same as calculate_synastry_chart) ──
|
|
|
|
|
- person1_datetime: str,
|
|
|
|
|
- person1_latitude: float,
|
|
|
|
|
- person1_longitude: float,
|
|
|
|
|
- person2_datetime: str,
|
|
|
|
|
- person2_latitude: float,
|
|
|
|
|
- person2_longitude: float,
|
|
|
|
|
- elevation: float = 0.0,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
- top_n_aspects: int | None = None,
|
|
|
|
|
- # ── Rendering options ───────────────────────────────────────────
|
|
|
|
|
- style: str = "modern",
|
|
|
|
|
- color_mode: str = "color",
|
|
|
|
|
- size: int = 800,
|
|
|
|
|
- title: str | None = None,
|
|
|
|
|
- format: str = "svg",
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Render a synastry (relationship) chart with two side-by-side wheels.
|
|
|
|
|
-
|
|
|
|
|
- Calculates both natal charts and renders them side by side with
|
|
|
|
|
- interaspect lines between the two charts. Output as SVG or raster image (PNG/JPG).
|
|
|
|
|
-
|
|
|
|
|
- PERSON 1 (required):
|
|
|
|
|
- person1_datetime: ISO 8601 birth datetime with timezone.
|
|
|
|
|
- person1_latitude: Birth latitude in decimal degrees.
|
|
|
|
|
- person1_longitude: Birth longitude in decimal degrees.
|
|
|
|
|
-
|
|
|
|
|
- PERSON 2 (required):
|
|
|
|
|
- person2_datetime: ISO 8601 birth datetime with timezone.
|
|
|
|
|
- person2_latitude: Birth latitude in decimal degrees.
|
|
|
|
|
- person2_longitude: Birth longitude in decimal degrees.
|
|
|
|
|
-
|
|
|
|
|
- CHART OPTIONS:
|
|
|
|
|
- elevation: Birth elevation in meters (default: 0).
|
|
|
|
|
- house_system: "placidus" (default), "equal", or "whole_sign".
|
|
|
|
|
- orb_limits: Per-aspect orb overrides.
|
|
|
|
|
- top_n_aspects: Limit interaspects to the N tightest by orb.
|
|
|
|
|
-
|
|
|
|
|
- RENDERING OPTIONS:
|
|
|
|
|
- style: {_RENDER_STYLE_HELP}
|
|
|
|
|
- color_mode: {_RENDER_COLOR_HELP}
|
|
|
|
|
- size: {_RENDER_SIZE_HELP}
|
|
|
|
|
- title: {_RENDER_TITLE_HELP}
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Dict with "svg", "format", "width", "height", "included".
|
|
|
|
|
- """
|
|
|
|
|
- from .chart_renderer import render_synastry_wheel
|
|
|
|
|
-
|
|
|
|
|
- chart_data = await calculate_synastry_chart(
|
|
|
|
|
- person1_datetime=person1_datetime,
|
|
|
|
|
- person1_latitude=person1_latitude,
|
|
|
|
|
- person1_longitude=person1_longitude,
|
|
|
|
|
- person2_datetime=person2_datetime,
|
|
|
|
|
- person2_latitude=person2_latitude,
|
|
|
|
|
- person2_longitude=person2_longitude,
|
|
|
|
|
- elevation=elevation,
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- orb_limits=orb_limits,
|
|
|
|
|
- top_n_aspects=top_n_aspects,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" in chart_data:
|
|
|
|
|
- return chart_data
|
|
|
|
|
-
|
|
|
|
|
- result = render_synastry_wheel(
|
|
|
|
|
- chart_data,
|
|
|
|
|
- style=style,
|
|
|
|
|
- color_mode=color_mode,
|
|
|
|
|
- size=size,
|
|
|
|
|
- title=title,
|
|
|
|
|
- format=format,
|
|
|
|
|
- )
|
|
|
|
|
- result["included"] = ["wheel"]
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── render_synastry_chart_by_id ───────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def render_synastry_chart_by_id(
|
|
|
|
|
- # ── Two person IDs ──────────────────────────────────────────────
|
|
|
|
|
- person1_id: str,
|
|
|
|
|
- person2_id: str,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
- top_n_aspects: int | None = None,
|
|
|
|
|
- # ── Rendering options ───────────────────────────────────────────
|
|
|
|
|
- style: str = "modern",
|
|
|
|
|
- color_mode: str = "color",
|
|
|
|
|
- size: int = 800,
|
|
|
|
|
- title: str | None = None,
|
|
|
|
|
- format: str = "svg",
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Render a synastry chart for two people from the database.
|
|
|
|
|
-
|
|
|
|
|
- Looks up both persons by ID or nickname, calculates their synastry,
|
|
|
|
|
- and renders side-by-side natal wheels with interaspect lines.
|
|
|
|
|
- Output as SVG or raster image (PNG/JPG).
|
|
|
|
|
-
|
|
|
|
|
- PERSON LOOKUP (required):
|
|
|
|
|
- person1_id: ID or nickname of person 1 in the persons database.
|
|
|
|
|
- person2_id: ID or nickname of person 2 in the persons database.
|
|
|
|
|
-
|
|
|
|
|
- CHART OPTIONS:
|
|
|
|
|
- house_system: "placidus" (default), "equal", or "whole_sign".
|
|
|
|
|
- orb_limits: Per-aspect orb overrides.
|
|
|
|
|
- top_n_aspects: Limit interaspects to the N tightest by orb.
|
|
|
|
|
-
|
|
|
|
|
- RENDERING OPTIONS:
|
|
|
|
|
- style: {_RENDER_STYLE_HELP}
|
|
|
|
|
- color_mode: {_RENDER_COLOR_HELP}
|
|
|
|
|
- size: {_RENDER_SIZE_HELP}
|
|
|
|
|
- title: {_RENDER_TITLE_HELP}
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Dict with "svg", "format", "width", "height", "included".
|
|
|
|
|
- """
|
|
|
|
|
- from .chart_renderer import render_synastry_wheel
|
|
|
|
|
-
|
|
|
|
|
- chart_data = await calculate_synastry_chart_by_id(
|
|
|
|
|
- person1_id=person1_id,
|
|
|
|
|
- person2_id=person2_id,
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- orb_limits=orb_limits,
|
|
|
|
|
- top_n_aspects=top_n_aspects,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" in chart_data:
|
|
|
|
|
- return chart_data
|
|
|
|
|
-
|
|
|
|
|
- result = render_synastry_wheel(
|
|
|
|
|
- chart_data,
|
|
|
|
|
- style=style,
|
|
|
|
|
- color_mode=color_mode,
|
|
|
|
|
- size=size,
|
|
|
|
|
- title=title,
|
|
|
|
|
- format=format,
|
|
|
|
|
- )
|
|
|
|
|
- result["included"] = ["wheel"]
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── render_composite_chart ────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def render_composite_chart(
|
|
|
|
|
- # ── Two people's birth data (same as calculate_composite_chart) ─
|
|
|
|
|
- person1_datetime: str,
|
|
|
|
|
- person1_latitude: float,
|
|
|
|
|
- person1_longitude: float,
|
|
|
|
|
- person2_datetime: str,
|
|
|
|
|
- person2_latitude: float,
|
|
|
|
|
- person2_longitude: float,
|
|
|
|
|
- elevation: float = 0.0,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
- # ── Rendering options ───────────────────────────────────────────
|
|
|
|
|
- style: str = "modern",
|
|
|
|
|
- color_mode: str = "color",
|
|
|
|
|
- size: int = 600,
|
|
|
|
|
- table_position: str = "none",
|
|
|
|
|
- include_planets: bool = False,
|
|
|
|
|
- include_houses: bool = False,
|
|
|
|
|
- title: str | None = None,
|
|
|
|
|
- format: str = "svg",
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Render a composite chart (midpoint method) as a single wheel.
|
|
|
|
|
-
|
|
|
|
|
- Calculates the composite chart from two people's birth data and renders
|
|
|
|
|
- it as a standard natal-style wheel representing the relationship.
|
|
|
|
|
- Output as SVG or raster image (PNG/JPG).
|
|
|
|
|
-
|
|
|
|
|
- PERSON 1 (required):
|
|
|
|
|
- person1_datetime: ISO 8601 birth datetime with timezone.
|
|
|
|
|
- person1_latitude: Birth latitude in decimal degrees.
|
|
|
|
|
- person1_longitude: Birth longitude in decimal degrees.
|
|
|
|
|
-
|
|
|
|
|
- PERSON 2 (required):
|
|
|
|
|
- person2_datetime: ISO 8601 birth datetime with timezone.
|
|
|
|
|
- person2_latitude: Birth latitude in decimal degrees.
|
|
|
|
|
- person2_longitude: Birth longitude in decimal degrees.
|
|
|
|
|
-
|
|
|
|
|
- CHART OPTIONS:
|
|
|
|
|
- elevation: Birth elevation in meters (default: 0).
|
|
|
|
|
- house_system: "placidus" (default), "equal", or "whole_sign".
|
|
|
|
|
- orb_limits: Per-aspect orb overrides.
|
|
|
|
|
-
|
|
|
|
|
- RENDERING OPTIONS:
|
|
|
|
|
- style: {_RENDER_STYLE_HELP}
|
|
|
|
|
- color_mode: {_RENDER_COLOR_HELP}
|
|
|
|
|
- size: {_RENDER_SIZE_HELP}
|
|
|
|
|
- table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
|
|
|
|
|
- include_planets: {_RENDER_PLANETS_HELP}
|
|
|
|
|
- include_houses: {_RENDER_HOUSES_HELP}
|
|
|
|
|
- title: {_RENDER_TITLE_HELP}
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Dict with "svg", "format", "width", "height", "included".
|
|
|
|
|
- """
|
|
|
|
|
- from .chart_renderer import render_natal_wheel
|
|
|
|
|
-
|
|
|
|
|
- chart_data = await calculate_composite_chart(
|
|
|
|
|
- person1_datetime=person1_datetime,
|
|
|
|
|
- person1_latitude=person1_latitude,
|
|
|
|
|
- person1_longitude=person1_longitude,
|
|
|
|
|
- person2_datetime=person2_datetime,
|
|
|
|
|
- person2_latitude=person2_latitude,
|
|
|
|
|
- person2_longitude=person2_longitude,
|
|
|
|
|
- elevation=elevation,
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- orb_limits=orb_limits,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" in chart_data:
|
|
|
|
|
- return chart_data
|
|
|
|
|
-
|
|
|
|
|
- result = render_natal_wheel(
|
|
|
|
|
- chart_data,
|
|
|
|
|
- style=style,
|
|
|
|
|
- color_mode=color_mode,
|
|
|
|
|
- size=size,
|
|
|
|
|
- table_position=table_position,
|
|
|
|
|
- include_planets=include_planets,
|
|
|
|
|
- include_houses=include_houses,
|
|
|
|
|
- title=title,
|
|
|
|
|
- format=format,
|
|
|
|
|
- )
|
|
|
|
|
- result["included"] = _included_list(table_position, include_planets, include_houses)
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── render_composite_chart_by_id ──────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def render_composite_chart_by_id(
|
|
|
|
|
- # ── Two person IDs ──────────────────────────────────────────────
|
|
|
|
|
- person1_id: str,
|
|
|
|
|
- person2_id: str,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
- # ── Rendering options ───────────────────────────────────────────
|
|
|
|
|
- style: str = "modern",
|
|
|
|
|
- color_mode: str = "color",
|
|
|
|
|
- size: int = 600,
|
|
|
|
|
- table_position: str = "none",
|
|
|
|
|
- include_planets: bool = False,
|
|
|
|
|
- include_houses: bool = False,
|
|
|
|
|
- title: str | None = None,
|
|
|
|
|
- format: str = "svg",
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Render a composite chart for two people from the database.
|
|
|
|
|
-
|
|
|
|
|
- Looks up both persons by ID, calculates the composite chart, and
|
|
|
|
|
- renders it as a single natal-style wheel. Output as SVG or raster image (PNG/JPG).
|
|
|
|
|
-
|
|
|
|
|
- PERSON LOOKUP (required):
|
|
|
|
|
- person1_id: ID or nickname of person 1 in the persons database.
|
|
|
|
|
- person2_id: ID or nickname of person 2 in the persons database.
|
|
|
|
|
-
|
|
|
|
|
- CHART OPTIONS:
|
|
|
|
|
- house_system: "placidus" (default), "equal", or "whole_sign".
|
|
|
|
|
- orb_limits: Per-aspect orb overrides.
|
|
|
|
|
-
|
|
|
|
|
- RENDERING OPTIONS:
|
|
|
|
|
- style: {_RENDER_STYLE_HELP}
|
|
|
|
|
- color_mode: {_RENDER_COLOR_HELP}
|
|
|
|
|
- size: {_RENDER_SIZE_HELP}
|
|
|
|
|
- table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
|
|
|
|
|
- include_planets: {_RENDER_PLANETS_HELP}
|
|
|
|
|
- include_houses: {_RENDER_HOUSES_HELP}
|
|
|
|
|
- title: {_RENDER_TITLE_HELP}
|
|
|
|
|
- format: Output format — "svg" (default), "png", or "jpg".
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Dict with "content", "format", "content_type", "width", "height", "included".
|
|
|
|
|
- """
|
|
|
|
|
- from .chart_renderer import render_natal_wheel
|
|
|
|
|
-
|
|
|
|
|
- chart_data = await calculate_composite_chart_by_id(
|
|
|
|
|
- person1_id=person1_id,
|
|
|
|
|
- person2_id=person2_id,
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- orb_limits=orb_limits,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" in chart_data:
|
|
|
|
|
- return chart_data
|
|
|
|
|
-
|
|
|
|
|
- result = render_natal_wheel(
|
|
|
|
|
- chart_data,
|
|
|
|
|
- style=style,
|
|
|
|
|
- color_mode=color_mode,
|
|
|
|
|
- size=size,
|
|
|
|
|
- table_position=table_position,
|
|
|
|
|
- include_planets=include_planets,
|
|
|
|
|
- include_houses=include_houses,
|
|
|
|
|
- title=title,
|
|
|
|
|
- format=format,
|
|
|
|
|
- )
|
|
|
|
|
- result["included"] = _included_list(table_position, include_planets, include_houses)
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── render_davison_chart ──────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def render_davison_chart(
|
|
|
|
|
- # ── Two people's birth data (same as calculate_davison_chart) ───
|
|
|
|
|
- person1_datetime: str,
|
|
|
|
|
- person1_latitude: float,
|
|
|
|
|
- person1_longitude: float,
|
|
|
|
|
- person2_datetime: str,
|
|
|
|
|
- person2_latitude: float,
|
|
|
|
|
- person2_longitude: float,
|
|
|
|
|
- elevation: float = 0.0,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
- # ── Rendering options ───────────────────────────────────────────
|
|
|
|
|
- style: str = "modern",
|
|
|
|
|
- color_mode: str = "color",
|
|
|
|
|
- size: int = 600,
|
|
|
|
|
- table_position: str = "none",
|
|
|
|
|
- include_planets: bool = False,
|
|
|
|
|
- include_houses: bool = False,
|
|
|
|
|
- title: str | None = None,
|
|
|
|
|
- format: str = "svg",
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Render a Davison chart (midpoint in time and space) as a single wheel.
|
|
|
|
|
-
|
|
|
|
|
- Calculates the Davison chart from two people's birth data and renders
|
|
|
|
|
- it as a standard natal-style wheel. Output as SVG or raster image (PNG/JPG).
|
|
|
|
|
-
|
|
|
|
|
- PERSON 1 (required):
|
|
|
|
|
- person1_datetime: ISO 8601 birth datetime with timezone.
|
|
|
|
|
- person1_latitude: Birth latitude in decimal degrees.
|
|
|
|
|
- person1_longitude: Birth longitude in decimal degrees.
|
|
|
|
|
-
|
|
|
|
|
- PERSON 2 (required):
|
|
|
|
|
- person2_datetime: ISO 8601 birth datetime with timezone.
|
|
|
|
|
- person2_latitude: Birth latitude in decimal degrees.
|
|
|
|
|
- person2_longitude: Birth longitude in decimal degrees.
|
|
|
|
|
-
|
|
|
|
|
- CHART OPTIONS:
|
|
|
|
|
- elevation: Birth elevation in meters (default: 0).
|
|
|
|
|
- house_system: "placidus" (default), "equal", or "whole_sign".
|
|
|
|
|
- orb_limits: Per-aspect orb overrides.
|
|
|
|
|
-
|
|
|
|
|
- RENDERING OPTIONS:
|
|
|
|
|
- style: {_RENDER_STYLE_HELP}
|
|
|
|
|
- color_mode: {_RENDER_COLOR_HELP}
|
|
|
|
|
- size: {_RENDER_SIZE_HELP}
|
|
|
|
|
- table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
|
|
|
|
|
- include_planets: {_RENDER_PLANETS_HELP}
|
|
|
|
|
- include_houses: {_RENDER_HOUSES_HELP}
|
|
|
|
|
- title: {_RENDER_TITLE_HELP}
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Dict with "svg", "format", "width", "height", "included".
|
|
|
|
|
- """
|
|
|
|
|
- from .chart_renderer import render_natal_wheel
|
|
|
|
|
-
|
|
|
|
|
- chart_data = await calculate_davison_chart(
|
|
|
|
|
- person1_datetime=person1_datetime,
|
|
|
|
|
- person1_latitude=person1_latitude,
|
|
|
|
|
- person1_longitude=person1_longitude,
|
|
|
|
|
- person2_datetime=person2_datetime,
|
|
|
|
|
- person2_latitude=person2_latitude,
|
|
|
|
|
- person2_longitude=person2_longitude,
|
|
|
|
|
- elevation=elevation,
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- orb_limits=orb_limits,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" in chart_data:
|
|
|
|
|
- return chart_data
|
|
|
|
|
-
|
|
|
|
|
- result = render_natal_wheel(
|
|
|
|
|
- chart_data,
|
|
|
|
|
- style=style,
|
|
|
|
|
- color_mode=color_mode,
|
|
|
|
|
- size=size,
|
|
|
|
|
- table_position=table_position,
|
|
|
|
|
- include_planets=include_planets,
|
|
|
|
|
- include_houses=include_houses,
|
|
|
|
|
- title=title,
|
|
|
|
|
- format=format,
|
|
|
|
|
- )
|
|
|
|
|
- result["included"] = _included_list(table_position, include_planets, include_houses)
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── render_davison_chart_by_id ────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-@mcp.tool()
|
|
|
|
|
-async def render_davison_chart_by_id(
|
|
|
|
|
- # ── Two person IDs ──────────────────────────────────────────────
|
|
|
|
|
- person1_id: str,
|
|
|
|
|
- person2_id: str,
|
|
|
|
|
- house_system: str = "placidus",
|
|
|
|
|
- orb_limits: dict[str, float] | None = None,
|
|
|
|
|
- # ── Rendering options ───────────────────────────────────────────
|
|
|
|
|
- style: str = "modern",
|
|
|
|
|
- color_mode: str = "color",
|
|
|
|
|
- size: int = 600,
|
|
|
|
|
- table_position: str = "none",
|
|
|
|
|
- include_planets: bool = False,
|
|
|
|
|
- include_houses: bool = False,
|
|
|
|
|
- title: str | None = None,
|
|
|
|
|
- format: str = "svg",
|
|
|
|
|
-) -> dict[str, Any]:
|
|
|
|
|
- """Render a Davison chart for two people from the database.
|
|
|
|
|
-
|
|
|
|
|
- Looks up both persons by ID, calculates the Davison chart, and
|
|
|
|
|
- renders it as a single natal-style wheel. Output as SVG or raster image.
|
|
|
|
|
-
|
|
|
|
|
- PERSON LOOKUP (required):
|
|
|
|
|
- person1_id: ID or nickname of person 1 in the persons database.
|
|
|
|
|
- person2_id: ID or nickname of person 2 in the persons database.
|
|
|
|
|
-
|
|
|
|
|
- CHART OPTIONS:
|
|
|
|
|
- house_system: "placidus" (default), "equal", or "whole_sign".
|
|
|
|
|
- orb_limits: Per-aspect orb overrides.
|
|
|
|
|
-
|
|
|
|
|
- RENDERING OPTIONS:
|
|
|
|
|
- style: {_RENDER_STYLE_HELP}
|
|
|
|
|
- color_mode: {_RENDER_COLOR_HELP}
|
|
|
|
|
- size: {_RENDER_SIZE_HELP}
|
|
|
|
|
- table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
|
|
|
|
|
- include_planets: {_RENDER_PLANETS_HELP}
|
|
|
|
|
- include_houses: {_RENDER_HOUSES_HELP}
|
|
|
|
|
- title: {_RENDER_TITLE_HELP}
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Dict with "svg", "format", "width", "height", "included".
|
|
|
|
|
- """
|
|
|
|
|
- from .chart_renderer import render_natal_wheel
|
|
|
|
|
-
|
|
|
|
|
- chart_data = await calculate_davison_chart_by_id(
|
|
|
|
|
- person1_id=person1_id,
|
|
|
|
|
- person2_id=person2_id,
|
|
|
|
|
- house_system=house_system,
|
|
|
|
|
- orb_limits=orb_limits,
|
|
|
|
|
- )
|
|
|
|
|
- if "error" in chart_data:
|
|
|
|
|
- return chart_data
|
|
|
|
|
-
|
|
|
|
|
- result = render_natal_wheel(
|
|
|
|
|
- chart_data,
|
|
|
|
|
- style=style,
|
|
|
|
|
- color_mode=color_mode,
|
|
|
|
|
- size=size,
|
|
|
|
|
- table_position=table_position,
|
|
|
|
|
- include_planets=include_planets,
|
|
|
|
|
- include_houses=include_houses,
|
|
|
|
|
- title=title,
|
|
|
|
|
- format=format,
|
|
|
|
|
- )
|
|
|
|
|
- result["included"] = _included_list(table_position, include_planets, include_houses)
|
|
|
|
|
- return result
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-# ── Helper ────────────────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-def _included_list(table_position: str, planets: bool, houses: bool) -> list[str]:
|
|
|
|
|
- result = ["wheel"]
|
|
|
|
|
- if table_position in ("below", "right"):
|
|
|
|
|
- if planets:
|
|
|
|
|
- result.append("planet_table")
|
|
|
|
|
- if houses:
|
|
|
|
|
- result.append("house_table")
|
|
|
|
|
- return result
|
|
|