""" Pure astrological calculation module. No I/O, no async -- just math on ecliptic longitudes and latitudes. All functions operate on plain dicts/lists for easy testing. Input conventions: - All longitudes in degrees (0-360 ecliptic) - All latitudes in degrees (-90 to +90 ecliptic) - Sidereal time in hours (0-24) - All angles in degrees (0-360) unless noted Output conventions: - Zodiac positions: {"sign": str, "degree": float} where degree is 0-30 within sign - House cusps: array of 12 {"sign": str, "degree": float} (cusp 1 = ASC, cusp 10 = MC) - Aspects: (body1, body2, aspect_name, orb, applying|separating, exactness) - Angles: {asc: {sign, degree}, mc: {sign, degree}, dsc: {sign, degree}, ic: {sign, degree}} """ from __future__ import annotations import math from typing import Any # ── Zodiac Signs ───────────────────────────────────────────────────── ZODIAC_SIGNS = [ "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpius", "Sagittarius", "Capricornus", "Aquarius", "Pisces", ] SIGN_ABBREVIATIONS = [ "Ar", "Ta", "Ge", "Cn", "Le", "Vi", "Li", "Sc", "Sg", "Cp", "Aq", "Pi", ] def normalize_degrees(lon: float) -> float: """Normalize longitude to 0-360.""" return lon % 360.0 def ecliptic_to_zodiac(lon: float) -> dict[str, Any]: """Convert ecliptic longitude to zodiac sign + degree within sign. Args: lon: Ecliptic longitude in degrees (0-360, tropical). Returns: {"sign": str, "abbreviation": str, "degree": float, "absolute_lon": float} """ lon = normalize_degrees(lon) sign_index = int(lon // 30) degree = lon - (sign_index * 30) return { "sign": ZODIAC_SIGNS[sign_index], "abbreviation": SIGN_ABBREVIATIONS[sign_index], "degree": round(degree, 6), "absolute_lon": round(lon, 6), } def zodiac_to_ecliptic(sign: str, degree: float) -> float: """Convert zodiac sign + degree to ecliptic longitude.""" sign_index = ZODIAC_SIGNS.index(sign.capitalize()) return normalize_degrees(sign_index * 30 + degree) # ── Aspect Definitions ─────────────────────────────────────────────── ASPECT_DEFINITIONS: list[dict[str, Any]] = [ {"name": "conjunction", "angle": 0.0, "default_orb": 8.0, "symbol": "Co"}, {"name": "sextile", "angle": 60.0, "default_orb": 6.0, "symbol": "Sx"}, {"name": "square", "angle": 90.0, "default_orb": 8.0, "symbol": "Sq"}, {"name": "trine", "angle": 120.0, "default_orb": 8.0, "symbol": "Tr"}, {"name": "quincunx", "angle": 150.0, "default_orb": 3.0, "symbol": "Qx"}, {"name": "opposition", "angle": 180.0, "default_orb": 8.0, "symbol": "Op"}, ] DEFAULT_ORBS: dict[str, float] = { a["name"]: a["default_orb"] for a in ASPECT_DEFINITIONS } # Slow planets get wider orbs for transit significance SLOW_PLANETS = {"jupiter", "saturn", "uranus", "neptune", "pluto"} # Transit-to-natal orb radii per planet (degrees). # These represent the maximum orb when a transiting planet aspects a natal planet. # Based on standard practice: Sun/Moon get the widest (1.5°), personal planets 1.0°, # social/slow 1.5°, outer planets 1.0°. TRANSIT_ORB_RADII: dict[str, float] = { "sun": 1.5, "moon": 1.5, "mercury": 1.0, "venus": 1.0, "mars": 1.0, "jupiter": 1.5, "saturn": 1.5, "uranus": 1.0, "neptune": 1.0, "pluto": 1.0, "chiron": 0.5, "true_node": 1.0, } # Aspect type multipliers applied to the base orb. # Conjunction/opposition are strongest (1.0), trine/square medium (0.75), sextile weakest (0.5). ASPECT_TYPE_MULTIPLIERS: dict[str, float] = { "conjunction": 1.0, "opposition": 1.0, "square": 0.75, "trine": 0.75, "sextile": 0.5, } # Significance weights for transit interpretation. # Higher = more important for forecasting. ASPECT_SIGNIFICANCE: dict[str, float] = { "conjunction": 3.0, "opposition": 3.0, "square": 2.5, "trine": 1.5, "sextile": 1.0, } # Significance weight for transiting planet speed. # Slow planets = longer-lasting, more significant transits. TRANSITING_PLANET_SIGNIFICANCE: dict[str, float] = { "sun": 2.0, "moon": 0.5, # too fast, less individually significant "mercury": 1.0, "venus": 1.0, "mars": 1.5, "jupiter": 3.0, "saturn": 3.5, "uranus": 2.5, "neptune": 2.5, "pluto": 3.0, "chiron": 1.5, "true_node": 1.5, } # Natal planets considered especially important as targets. # Being aspected by a slow planet is a major transit. NATAL_TARGET_IMPORTANCE: dict[str, float] = { "sun": 3.0, "moon": 3.0, "mercury": 1.5, "venus": 1.5, "mars": 1.5, "jupiter": 2.0, "saturn": 2.0, "uranus": 1.5, "neptune": 1.5, "pluto": 1.5, "chiron": 1.0, "true_node": 2.0, } def get_transit_orb(transiting: str, natal: str, aspect: str) -> float: """Calculate the maximum allowable orb for a transit-to-natal aspect. The orb is the sum of the transiting planet's radius and the natal planet's radius, multiplied by the aspect type multiplier. """ t_radius = TRANSIT_ORB_RADII.get(transiting, 1.0) n_radius = TRANSIT_ORB_RADII.get(natal, 1.0) base = t_radius + n_radius multiplier = ASPECT_TYPE_MULTIPLIERS.get(aspect, 1.0) return base * multiplier def get_transit_significance(transiting: str, natal: str, aspect: str, orb: float, max_orb: float) -> float: """Calculate a significance score (0..10) for a transit-to-natal aspect. Factors: - Aspect type (hard aspects score higher) - Transiting planet importance (slow planets score higher) - Natal planet importance (angles, luminaries score higher) - Orb tightness (tighter = higher score) """ score = 0.0 score += ASPECT_SIGNIFICANCE.get(aspect, 1.0) score += TRANSITING_PLANET_SIGNIFICANCE.get(transiting, 1.0) score += NATAL_TARGET_IMPORTANCE.get(natal, 1.0) # Orb tightness bonus: 0 orb = +2, at max_orb = +0 if max_orb > 0: orb_factor = 1.0 - (orb / max_orb) score += orb_factor * 2.0 return round(min(score, 10.0), 2) def compute_aspects( bodies: list[dict[str, Any]], orb_limits: dict[str, float] | None = None, ) -> list[dict[str, Any]]: """Compute all aspects between a set of bodies. Args: bodies: List of dicts with at least {"name": str, "lon": float}. Optionally "speed_lon": float for applying/separating detection. orb_limits: Optional dict of {aspect_name: max_orb_degrees}. Defaults to standard orbs. Returns: List of aspect dicts, sorted by orb (tightest first). Each aspect: { "body1": str, "body2": str, "aspect": str, "orb": float, # degrees from exact "applying": bool, # True if applying, False if separating "exactness": float, # 1.0 = exact, 0.0 = at orb limit "angle": float, # the theoretical angle (0, 60, 90, 120, 180) } """ if orb_limits is None: orb_limits = DEFAULT_ORBS.copy() else: orb_limits = {**DEFAULT_ORBS, **orb_limits} aspects: list[dict[str, Any]] = [] for i, b1 in enumerate(bodies): for b2 in bodies[i + 1:]: lon1 = normalize_degrees(b1["lon"]) lon2 = normalize_degrees(b2["lon"]) diff = abs(lon1 - lon2) diff = min(diff, 360.0 - diff) for asp_def in ASPECT_DEFINITIONS: asp_name = asp_def["name"] asp_angle = asp_def["angle"] max_orb = orb_limits.get(asp_name, asp_def["default_orb"]) orb = abs(diff - asp_angle) if orb <= max_orb: # Determine applying/separating applying = _is_applying(lon1, lon2, b1.get("speed_lon"), b2.get("speed_lon"), asp_angle) exactness = 1.0 - (orb / max_orb) aspects.append({ "body1": b1["name"], "body2": b2["name"], "aspect": asp_name, "orb": round(orb, 6), "applying": applying, "exactness": round(max(0.0, exactness), 6), "angle": asp_angle, }) # Sort by orb (tightest aspects first) aspects.sort(key=lambda a: a["orb"]) return aspects def _is_applying( lon1: float, lon2: float, speed1: float | None, speed2: float | None, target_angle: float, ) -> bool | None: """Determine if an aspect is applying or separating. Returns True if applying, False if separating, None if speeds unknown. """ if speed1 is None or speed2 is None: return None current_diff = abs(lon1 - lon2) current_diff = min(current_diff, 360.0 - current_diff) # Project positions forward by 0.1 day fwd1 = normalize_degrees(lon1 + speed1 * 0.1) fwd2 = normalize_degrees(lon2 + speed2 * 0.1) fwd_diff = abs(fwd1 - fwd2) fwd_diff = min(fwd_diff, 360.0 - fwd_diff) target = target_angle # For opposition, also account for the 180 point current_dist_to_exact = abs(current_diff - target) fwd_dist_to_exact = abs(fwd_diff - target) return fwd_dist_to_exact < current_dist_to_exact # ── House Placement ────────────────────────────────────────────────── def get_house_placement( ecliptic_lon: float, houses: list[dict[str, Any]], ) -> int: """Determine which house a given ecliptic longitude falls in. Args: ecliptic_lon: Ecliptic longitude in degrees (0-360). houses: House cusp array from the ephemeris server (via extract_houses). Returns: House number (1-12). """ lon = normalize_degrees(ecliptic_lon) cusps = [(h["house"], h["absolute_lon"]) for h in houses] cusps.sort() # Iterate through houses; a point is in house N if it's between cusp N and cusp N+1 for i in range(12): start_house, start_lon = cusps[i] end_house, end_lon = cusps[(i + 1) % 12] if start_lon < end_lon: if start_lon <= lon < end_lon: return start_house else: # Wraps through 0° if lon >= start_lon or lon < end_lon: return start_house return 1 # fallback # ── Retrograde ─────────────────────────────────────────────────────── def is_retrograde(speed_lon: float | None) -> bool: """Determine if a body is retrograde based on its ecliptic longitude speed. Args: speed_lon: Speed in ecliptic longitude in degrees/day. Returns: True if retrograde (speed < 0). """ if speed_lon is None: return False return speed_lon < 0 # ── Composite / Davison ────────────────────────────────────────────── def compute_composite_chart( bodies1: list[dict[str, Any]], bodies2: list[dict[str, Any]], ) -> list[dict[str, Any]]: """Compute a composite chart via midpoint method. For each body present in both charts, compute the midpoint longitude. Midpoint = average of the two positions, taking the shorter arc. Args: bodies1: First chart bodies, each with {"name": str, "lon": float, ...} bodies2: Second chart bodies, same format. Returns: List of composite bodies with {"name": str, "lon": float}. """ lookup1 = {b["name"]: b["lon"] for b in bodies1} lookup2 = {b["name"]: b["lon"] for b in bodies2} common = set(lookup1.keys()) & set(lookup2.keys()) result = [] for name in sorted(common): lon1 = lookup1[name] lon2 = lookup2[name] mid = _midpoint(lon1, lon2) result.append({"name": name, "lon": round(mid, 6)}) return result def compute_davison_chart( mid1: float, mid2: float, dt1: str, dt2: str, ) -> dict[str, float]: """Compute Davison chart midpoints (date and location). The Davison chart uses the midpoint in time and location between two birth dates. Args: mid1: Birth datetime 1 as ISO string or Julian Day number. mid2: Birth datetime 2 as ISO string or Julian Day number. dt1, dt2: Used for date midpoint. Returns: {"date_midpoint_jd": float, "lat_midpoint": float, "lon_midpoint": float} """ from datetime import datetime, timezone, timedelta if isinstance(mid1, (int, float)): # Assume it's already a JD or ordinal jd1 = float(mid1) jd2 = float(mid2) date_mid = (jd1 + jd2) / 2.0 else: d1 = datetime.fromisoformat(str(mid1).replace("Z", "+00:00")) d2 = datetime.fromisoformat(str(mid2).replace("Z", "+00:00")) mid_dt = d1 + (d2 - d1) / 2 # Convert to JD approximation date_mid = 2440587.5 + mid_dt.timestamp() / 86400.0 return { "date_midpoint_jd": date_mid, } def _midpoint(lon1: float, lon2: float) -> float: """Midpoint of two ecliptic longitudes along the shorter arc.""" d = abs(lon1 - lon2) if d <= 180: return normalize_degrees((lon1 + lon2) / 2.0) else: return normalize_degrees((lon1 + lon2) / 2.0 + 180.0) # ── Sign Ruler Lookup ──────────────────────────────────────────────── # Modern rulership (Placidus/Modern Western) SIGN_RULERS: dict[str, str] = { "Aries": "mars", "Taurus": "venus", "Gemini": "mercury", "Cancer": "moon", "Leo": "sun", "Virgo": "mercury", "Libra": "venus", "Scorpius": "pluto", "Sagittarius": "jupiter", "Capricornus": "saturn", "Aquarius": "uranus", "Pisces": "neptune", } # Traditional rulership (pre-discovery of outer planets) SIGN_RULERS_TRADITIONAL: dict[str, str] = { "Aries": "mars", "Taurus": "venus", "Gemini": "mercury", "Cancer": "moon", "Leo": "sun", "Virgo": "mercury", "Libra": "venus", "Scorpius": "mars", "Sagittarius": "jupiter", "Capricornus": "saturn", "Aquarius": "saturn", "Pisces": "jupiter", } # Element lookup by sign SIGN_ELEMENTS: dict[str, str] = { "Aries": "fire", "Taurus": "earth", "Gemini": "air", "Cancer": "water", "Leo": "fire", "Virgo": "earth", "Libra": "air", "Scorpius": "water", "Sagittarius": "fire", "Capricornus": "earth", "Aquarius": "air", "Pisces": "water", } # Modality lookup by sign SIGN_MODALITIES: dict[str, str] = { "Aries": "cardinal", "Taurus": "fixed", "Gemini": "mutable", "Cancer": "cardinal", "Leo": "fixed", "Virgo": "mutable", "Libra": "cardinal", "Scorpius": "fixed", "Sagittarius": "mutable", "Capricornus": "cardinal", "Aquarius": "fixed", "Pisces": "mutable", } # Element to signs ELEMENT_SIGNS: dict[str, list[str]] = { "fire": ["Aries", "Leo", "Sagittarius"], "earth": ["Taurus", "Virgo", "Capricornus"], "air": ["Gemini", "Libra", "Aquarius"], "water": ["Cancer", "Scorpius", "Pisces"], } # Modality to signs MODALITY_SIGNS: dict[str, list[str]] = { "cardinal": ["Aries", "Cancer", "Libra", "Capricornus"], "fixed": ["Taurus", "Leo", "Scorpius", "Aquarius"], "mutable": ["Gemini", "Virgo", "Sagittarius", "Pisces"], } # Angular houses ANGULAR_HOUSES = {1, 4, 7, 10} SUCCEDENT_HOUSES = {2, 5, 8, 11} CADENT_HOUSES = {3, 6, 9, 12} # Personal planets (for karmic filtering) PERSONAL_PLANETS = {"sun", "moon", "mercury", "venus", "mars"} # Hard aspects (for karmic filtering) HARD_ASPECTS = {"conjunction", "square", "opposition"} def _planet_sign(planet_name: str, planets: list[dict[str, Any]]) -> str | None: """Look up the sign of a planet from a planet list.""" for p in planets: if p["body"] == planet_name: return p.get("sign") return None def _planet_house(planet_name: str, planets: list[dict[str, Any]]) -> int | None: """Look up the house of a planet from a planet list.""" for p in planets: if p["body"] == planet_name: return p.get("house") return None def _planet_lon(planet_name: str, planets: list[dict[str, Any]]) -> float | None: """Look up the absolute_lon of a planet from a planet list.""" for p in planets: if p["body"] == planet_name: return p.get("absolute_lon") return None # ── Chart Overview Functions ────────────────────────────────────────── def get_element_balance(planets: list[dict[str, Any]]) -> dict[str, Any]: """Count planets by element (fire/earth/air/water). Args: planets: Planet list from calculate_natal_chart output. Returns: Dict with counts and percentages per element. """ counts: dict[str, int] = {"fire": 0, "earth": 0, "air": 0, "water": 0} for p in planets: sign = p.get("sign", "") element = SIGN_ELEMENTS.get(sign) if element: counts[element] += 1 total = sum(counts.values()) percentages = {k: round(v / total * 100, 1) if total > 0 else 0.0 for k, v in counts.items()} return {"counts": counts, "percentages": percentages, "total": total} def get_modality_balance(planets: list[dict[str, Any]]) -> dict[str, Any]: """Count planets by modality (cardinal/fixed/mutable). Args: planets: Planet list from calculate_natal_chart output. Returns: Dict with counts and percentages per modality. """ counts: dict[str, int] = {"cardinal": 0, "fixed": 0, "mutable": 0} for p in planets: sign = p.get("sign", "") modality = SIGN_MODALITIES.get(sign) if modality: counts[modality] += 1 total = sum(counts.values()) percentages = {k: round(v / total * 100, 1) if total > 0 else 0.0 for k, v in counts.items()} return {"counts": counts, "percentages": percentages, "total": total} def get_hemisphere_emphasis(planets: list[dict[str, Any]]) -> dict[str, int]: """Count planets by hemisphere (upper/lower/east/west). Upper = houses 7-12, Lower = houses 1-6. East = houses 10-3 (via 12/1/2), West = houses 4-9. Args: planets: Planet list from calculate_natal_chart output. Returns: Dict with counts per hemisphere. """ upper = 0 lower = 0 east = 0 west = 0 for p in planets: house = p.get("house", 1) if 7 <= house <= 12: upper += 1 else: lower += 1 if house in (10, 11, 12, 1, 2, 3): east += 1 else: west += 1 return {"upper": upper, "lower": lower, "east": east, "west": west} def detect_stelliums(planets: list[dict[str, Any]]) -> list[dict[str, Any]]: """Detect stelliums: 3+ planets in the same sign or house. Args: planets: Planet list from calculate_natal_chart output. Returns: List of stellium dicts with type, key, and involved planets. """ result = [] # By sign sign_groups: dict[str, list[str]] = {} for p in planets: sign = p.get("sign", "") if sign not in sign_groups: sign_groups[sign] = [] sign_groups[sign].append(p["body"]) for sign, bodies in sign_groups.items(): if len(bodies) >= 3: result.append({"type": "sign", "key": sign, "planets": bodies}) # By house house_groups: dict[int, list[str]] = {} for p in planets: house = p.get("house", 1) if house not in house_groups: house_groups[house] = [] house_groups[house].append(p["body"]) for house, bodies in house_groups.items(): if len(bodies) >= 3: result.append({"type": "house", "key": str(house), "planets": bodies}) return result def get_empty_houses(planets: list[dict[str, Any]]) -> list[int]: """Return house numbers (1-12) that have no planets. Args: planets: Planet list from calculate_natal_chart output. Returns: Sorted list of empty house numbers. """ occupied = {p.get("house", 1) for p in planets} return sorted(h for h in range(1, 13) if h not in occupied) def get_chart_ruler( ascendant_sign: str, planets: list[dict[str, Any]], traditional: bool = False, ) -> dict[str, Any] | None: """Identify the chart ruler: the planet ruling the Ascendant sign. Args: ascendant_sign: Sign on the Ascendant (e.g., "Aries"). planets: Planet list from calculate_natal_chart output. traditional: If True, use traditional rulership (no outer planets). Returns: Dict with ruler name, sign, house, retrograde, and absolute_lon. None if the ruler is not found in the planet list. """ rulers = SIGN_RULERS_TRADITIONAL if traditional else SIGN_RULERS ruler_name = rulers.get(ascendant_sign) if not ruler_name: return None for p in planets: if p["body"] == ruler_name: return { "body": p["body"], "sign": p.get("sign"), "house": p.get("house"), "retrograde": p.get("retrograde", False), "absolute_lon": p.get("absolute_lon"), } return None def get_house_rulers( houses: list[dict[str, Any]], planets: list[dict[str, Any]], traditional: bool = False, ) -> list[dict[str, Any]]: """For each house, find the ruling planet and its condition. Args: houses: House list from calculate_natal_chart output. planets: Planet list from calculate_natal_chart output. traditional: If True, use traditional rulership. Returns: List of dicts with house, cusp_sign, ruler, ruler_sign, ruler_house, ruler_retrograde. """ rulers = SIGN_RULERS_TRADITIONAL if traditional else SIGN_RULERS result = [] for h in houses: cusp_sign = h.get("sign", "") ruler_name = rulers.get(cusp_sign, "") ruler_info = { "house": h["house"], "cusp_sign": cusp_sign, "ruler": ruler_name, "ruler_sign": None, "ruler_house": None, "ruler_retrograde": None, } if ruler_name: for p in planets: if p["body"] == ruler_name: ruler_info["ruler_sign"] = p.get("sign") ruler_info["ruler_house"] = p.get("house") ruler_info["ruler_retrograde"] = p.get("retrograde", False) break result.append(ruler_info) return result def group_planets_by_house(planets: list[dict[str, Any]]) -> dict[int, list[str]]: """Group planet names by house number. Args: planets: Planet list from calculate_natal_chart output. Returns: Dict mapping house number (1-12) to list of planet names. """ result: dict[int, list[str]] = {} for p in planets: house = p.get("house", 1) if house not in result: result[house] = [] result[house].append(p["body"]) return result def group_planets_by_sign(planets: list[dict[str, Any]]) -> dict[str, list[str]]: """Group planet names by sign. Args: planets: Planet list from calculate_natal_chart output. Returns: Dict mapping sign name to list of planet names. """ result: dict[str, list[str]] = {} for p in planets: sign = p.get("sign", "") if sign not in result: result[sign] = [] result[sign].append(p["body"]) return result def get_house_type_counts(planets: list[dict[str, Any]]) -> dict[str, int]: """Count planets by house type: angular, succedent, cadent. Args: planets: Planet list from calculate_natal_chart output. Returns: Dict with counts for each house type. """ counts = {"angular": 0, "succedent": 0, "cadent": 0} for p in planets: house = p.get("house", 1) if house in ANGULAR_HOUSES: counts["angular"] += 1 elif house in SUCCEDENT_HOUSES: counts["succedent"] += 1 elif house in CADENT_HOUSES: counts["cadent"] += 1 return counts def get_retrograde_planets(planets: list[dict[str, Any]]) -> list[dict[str, Any]]: """Return all retrograde planets with their sign and house. Args: planets: Planet list from calculate_natal_chart output. Returns: List of retrograde planet dicts with body, sign, house. """ return [ {"body": p["body"], "sign": p.get("sign"), "house": p.get("house")} for p in planets if p.get("retrograde", False) ] # ── Karmic Helper Functions ─────────────────────────────────────────── def get_nodal_axis( planets: list[dict[str, Any]], houses: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """Extract the nodal axis (North + South Node) from planet data. Args: planets: Planet list from calculate_natal_chart output. houses: Optional house list for house placement. Returns: Dict with north_node and south_node, each having sign, house, degree, absolute_lon. """ result: dict[str, Any] = {"north_node": None, "south_node": None} for p in planets: if p["body"] == "true_node": north = { "body": "true_node", "sign": p.get("sign"), "degree_within_sign": p.get("degree_within_sign"), "absolute_lon": p.get("absolute_lon"), "house": p.get("house"), } # South Node = opposite point south_lon = normalize_degrees(p.get("absolute_lon", 0.0) + 180.0) south_zodiac = ecliptic_to_zodiac(south_lon) south = { "body": "south_node", "sign": south_zodiac["sign"], "degree_within_sign": south_zodiac["degree"], "absolute_lon": south_zodiac["absolute_lon"], } if houses: south["house"] = get_house_placement(south_lon, houses) north["house"] = p.get("house") result["north_node"] = north result["south_node"] = south break return result def get_saturn_info(planets: list[dict[str, Any]]) -> dict[str, Any] | None: """Extract Saturn's position data from planet list. Args: planets: Planet list from calculate_natal_chart output. Returns: Dict with Saturn's body, sign, house, degree, retrograde, absolute_lon. None if Saturn not found. """ for p in planets: if p["body"] == "saturn": return { "body": p["body"], "sign": p.get("sign"), "house": p.get("house"), "degree_within_sign": p.get("degree_within_sign"), "retrograde": p.get("retrograde", False), "absolute_lon": p.get("absolute_lon"), } return None def get_pluto_polarity_point( planets: list[dict[str, Any]], houses: list[dict[str, Any]] | None = None, ) -> dict[str, Any] | None: """Calculate Pluto's Polarity Point (PPP) -- the point opposite Pluto. Args: planets: Planet list from calculate_natal_chart output. houses: Optional house list for house placement. Returns: Dict with PPP sign, house, degree, absolute_lon. None if Pluto not found. """ for p in planets: if p["body"] == "pluto": ppp_lon = normalize_degrees(p.get("absolute_lon", 0.0) + 180.0) z = ecliptic_to_zodiac(ppp_lon) result = { "body": "pluto_polarity_point", "sign": z["sign"], "degree_within_sign": z["degree"], "absolute_lon": z["absolute_lon"], } if houses: result["house"] = get_house_placement(ppp_lon, houses) return result return None def get_part_of_fortune( ascendant_lon: float, sun_lon: float, moon_lon: float, houses: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """Calculate the Arabic Part of Fortune. Formula: ASC + Moon - Sun (in ecliptic longitude). Args: ascendant_lon: Ascendant absolute longitude. sun_lon: Sun absolute longitude. moon_lon: Moon absolute longitude. houses: Optional house list for house placement. Returns: Dict with sign, degree, absolute_lon, and optionally house. """ pof_lon = normalize_degrees(ascendant_lon + moon_lon - sun_lon) z = ecliptic_to_zodiac(pof_lon) result = { "body": "part_of_fortune", "sign": z["sign"], "degree_within_sign": z["degree"], "absolute_lon": z["absolute_lon"], } if houses: result["house"] = get_house_placement(pof_lon, houses) return result def get_twelfth_house_analysis( houses: list[dict[str, Any]], planets: list[dict[str, Any]], traditional: bool = False, ) -> dict[str, Any]: """Analyze the 12th house for karmic/spiritual themes. Args: houses: House list from calculate_natal_chart output. planets: Planet list from calculate_natal_chart output. traditional: If True, use traditional rulership for 12th house ruler. Returns: Dict with cusp_sign, planets_in_12th, ruler info. """ cusp_sign = None for h in houses: if h["house"] == 12: cusp_sign = h.get("sign") break planets_in_12th = [ {"body": p["body"], "sign": p.get("sign"), "degree_within_sign": p.get("degree_within_sign")} for p in planets if p.get("house") == 12 ] ruler_info = None if cusp_sign: rulers = SIGN_RULERS_TRADITIONAL if traditional else SIGN_RULERS ruler_name = rulers.get(cusp_sign) if ruler_name: for p in planets: if p["body"] == ruler_name: ruler_info = { "ruler": ruler_name, "sign": p.get("sign"), "house": p.get("house"), "retrograde": p.get("retrograde", False), } break if not ruler_info: ruler_info = {"ruler": ruler_name, "sign": None, "house": None, "retrograde": None} return { "cusp_sign": cusp_sign, "planets": planets_in_12th, "ruler": ruler_info, } def filter_aspects_by_planets( aspects: list[dict[str, Any]], planet_names: set[str], aspect_types: set[str] | None = None, ) -> list[dict[str, Any]]: """Filter aspects to only those involving specific planets and optionally specific aspect types. Args: aspects: Aspect list from compute_aspects or calculate_natal_chart. planet_names: Set of planet names to filter for (either body1 or body2). aspect_types: Optional set of aspect names to filter for (e.g., {"conjunction", "square"}). Returns: Filtered list of aspects. """ result = [] for asp in aspects: b1 = asp.get("body1", "") b2 = asp.get("body2", "") # Strip transit_ and natal_ prefixes for matching b1_clean = b1.replace("transit_", "").replace("natal_", "").replace("p1_", "").replace("p2_", "") b2_clean = b2.replace("transit_", "").replace("natal_", "").replace("p1_", "").replace("p2_", "") if b1_clean in planet_names or b2_clean in planet_names: if aspect_types is None or asp.get("aspect") in aspect_types: result.append(asp) return result def get_natal_aspects_to_planets( aspects: list[dict[str, Any]], target_planets: set[str], aspect_types: set[str] | None = None, ) -> list[dict[str, Any]]: """Get all natal aspects involving specific target planets. Convenience wrapper around filter_aspects_by_planets for natal chart aspects. Args: aspects: Aspect list from calculate_natal_chart. target_planets: Set of planet names (e.g., {"sun", "moon", "true_node"}). aspect_types: Optional set of aspect types to filter for. Returns: Filtered aspects sorted by orb (tightest first). """ filtered = filter_aspects_by_planets(aspects, target_planets, aspect_types) filtered.sort(key=lambda a: a.get("orb", 999)) return filtered # ── Aspect Pattern Detection ───────────────────────────────────────── def detect_aspect_patterns( planets: list[dict[str, Any]], aspects: list[dict[str, Any]], orb_limit: float = 8.0, ) -> list[dict[str, Any]]: """Detect major aspect patterns in a natal chart. Scans the aspect list for T-squares, Grand Trines, Grand Crosses, and Yods. Args: planets: Planet list from calculate_natal_chart output. aspects: Aspect list (formatted, with body1/body2 keys). orb_limit: Maximum orb for pattern detection (default 8°). Returns: List of pattern dicts with type, planets, apex, element, modality. """ patterns: list[dict[str, Any]] = [] # Build a set of aspect pairs for fast lookup # Key: (body_a, body_b, aspect_type) where body_a < body_b alphabetically aspect_set: dict[tuple[str, str, str], dict[str, Any]] = {} for asp in aspects: b1 = asp.get("body1", "") b2 = asp.get("body2", "") asp_type = asp.get("aspect", "") orb = asp.get("orb", 999) if orb > orb_limit: continue key = (min(b1, b2), max(b1, b2), asp_type) aspect_set[key] = asp def _has_aspect(b1: str, b2: str, asp_type: str) -> bool: return (min(b1, b2), max(b1, b2), asp_type) in aspect_set def _get_orb(b1: str, b2: str, asp_type: str) -> float: key = (min(b1, b2), max(b1, b2), asp_type) return aspect_set[key].get("orb", 999) if key in aspect_set else 999 planet_names = [p["body"] for p in planets] # ── T-square: two planets in opposition, both squaring a third ── for asp in aspects: if asp.get("aspect") != "opposition" or asp.get("orb", 999) > orb_limit: continue a = asp["body1"] b = asp["body2"] for c in planet_names: if c == a or c == b: continue if _has_aspect(a, c, "square") and _has_aspect(b, c, "square"): orb_ac = _get_orb(a, c, "square") orb_bc = _get_orb(b, c, "square") if orb_ac <= orb_limit and orb_bc <= orb_limit: # Determine modality from the signs signs = [] for pname in [a, b, c]: for p in planets: if p["body"] == pname: signs.append(p.get("sign", "")) break modality = None if len(signs) == 3: mods = [SIGN_MODALITIES.get(s) for s in signs] if len(set(mods)) == 1 and mods[0]: modality = mods[0] patterns.append({ "type": "T-square", "planets": sorted([a, b, c]), "apex": c, "apex_house": _planet_house(c, planets), "houses": sorted([_planet_house(a, planets) or 0, _planet_house(b, planets) or 0, _planet_house(c, planets) or 0]), "signs": signs, "modality": modality, "orb_max": max(asp.get("orb", 0), orb_ac, orb_bc), }) # ── Grand Trine: three planets all in trine (same element) ── trine_pairs: list[tuple[str, str, float]] = [] for asp in aspects: if asp.get("aspect") == "trine" and asp.get("orb", 999) <= orb_limit: trine_pairs.append((asp["body1"], asp["body2"], asp.get("orb", 999))) # Find triangles in trine pairs trine_graph: dict[str, list[str]] = {} for b1, b2, _ in trine_pairs: if b1 not in trine_graph: trine_graph[b1] = [] if b2 not in trine_graph: trine_graph[b2] = [] trine_graph[b1].append(b2) trine_graph[b2].append(b1) found_trines: set[frozenset] = set() for a in trine_graph: for b in trine_graph.get(a, []): for c in trine_graph.get(b, []): if c != a and a in trine_graph.get(c, []): trio = frozenset([a, b, c]) if trio not in found_trines: found_trines.add(trio) # Determine element signs = [] for pname in [a, b, c]: for p in planets: if p["body"] == pname: signs.append(p.get("sign", "")) break elements = [SIGN_ELEMENTS.get(s) for s in signs] element = elements[0] if elements and len(set(elements)) == 1 else None max_orb = max( _get_orb(a, b, "trine"), _get_orb(b, c, "trine"), _get_orb(a, c, "trine"), ) patterns.append({ "type": "Grand Trine", "planets": sorted([a, b, c]), "houses": sorted([_planet_house(a, planets) or 0, _planet_house(b, planets) or 0, _planet_house(c, planets) or 0]), "signs": signs, "element": element, "orb_max": max_orb, }) # ── Grand Cross: four planets forming 2 oppositions + 4 squares ── opp_pairs = [(asp["body1"], asp["body2"]) for asp in aspects if asp.get("aspect") == "opposition" and asp.get("orb", 999) <= orb_limit] found_crosses: set[frozenset] = set() for i, (a, b) in enumerate(opp_pairs): for c, d in opp_pairs[i + 1:]: quartet = frozenset([a, b, c, d]) if quartet in found_crosses: continue # Check that each planet in pair 1 squares both planets in pair 2 if (_has_aspect(a, c, "square") and _has_aspect(a, d, "square") and _has_aspect(b, c, "square") and _has_aspect(b, d, "square")): found_crosses.add(quartet) signs = [] for pname in sorted([a, b, c, d]): for p in planets: if p["body"] == pname: signs.append(p.get("sign", "")) break mods = [SIGN_MODALITIES.get(s) for s in signs] modality = mods[0] if len(set(mods)) == 1 and mods[0] else None max_orb = max( _get_orb(a, b, "opposition"), _get_orb(c, d, "opposition"), _get_orb(a, c, "square"), _get_orb(a, d, "square"), _get_orb(b, c, "square"), _get_orb(b, d, "square"), ) patterns.append({ "type": "Grand Cross", "planets": sorted([a, b, c, d]), "houses": sorted([_planet_house(x, planets) or 0 for x in [a, b, c, d]]), "signs": signs, "modality": modality, "orb_max": max_orb, }) # ── Yod: two planets in sextile, both quincunx a third ── sextile_pairs = [(asp["body1"], asp["body2"]) for asp in aspects if asp.get("aspect") == "sextile" and asp.get("orb", 999) <= orb_limit] found_yods: set[frozenset] = set() for a, b in sextile_pairs: for c in planet_names: if c == a or c == b: continue if _has_aspect(a, c, "quincunx") and _has_aspect(b, c, "quincunx"): trio = frozenset([a, b, c]) if trio not in found_yods: found_yods.add(trio) signs = [] for pname in [a, b, c]: for p in planets: if p["body"] == pname: signs.append(p.get("sign", "")) break max_orb = max( _get_orb(a, b, "sextile"), _get_orb(a, c, "quincunx"), _get_orb(b, c, "quincunx"), ) patterns.append({ "type": "Yod", "planets": sorted([a, b, c]), "apex": c, "apex_house": _planet_house(c, planets), "houses": sorted([_planet_house(x, planets) or 0 for x in [a, b, c]]), "signs": signs, "orb_max": max_orb, }) return patterns # ── Chart Shape Detection ──────────────────────────────────────────── def detect_chart_shape(planets: list[dict[str, Any]]) -> dict[str, Any]: """Detect the chart shape pattern. Analyzes the angular distribution of planets to classify the chart shape: - Bundle: all planets within 120° arc - Bowl: all planets within 180° arc - Bucket: all planets within 250° arc with a singleton opposite - Splash: planets distributed around the full chart - Locomotive: planets within ~250° with a leading "locomotive" planet - Seesaw: two opposing clusters - Splay: three or more distributed pairs/groups Args: planets: Planet list from calculate_natal_chart output (must have absolute_lon). Returns: Dict with shape name, largest gap, and gap boundaries. """ if len(planets) < 2: return {"shape": "unknown", "largest_gap": 360.0, "gap_start": 0.0, "gap_end": 0.0} lons = sorted([normalize_degrees(p.get("absolute_lon", 0.0)) for p in planets]) n = len(lons) # Compute gaps between adjacent planets (including wraparound) gaps: list[tuple[float, float, float]] = [] # (gap_size, start, end) for i in range(n): next_i = (i + 1) % n gap = normalize_degrees(lons[next_i] - lons[i]) gaps.append((gap, lons[i], lons[next_i])) # Find largest gap largest_gap, gap_start, gap_end = max(gaps, key=lambda g: g[0]) # The occupied arc is 360 - largest_gap occupied_arc = 360.0 - largest_gap # Count planets in the largest gap planets_in_gap = sum(1 for lon in lons if _is_in_arc(lon, gap_start, gap_end, largest_gap)) planets_in_occupied = n - planets_in_gap # Classify shape = "splash" # default if occupied_arc <= 120.0: shape = "bundle" elif occupied_arc <= 180.0: shape = "bowl" elif largest_gap < 90.0: # No large empty arc -- planets are spread around the chart shape = "splash" elif occupied_arc <= 250.0: # There's a significant empty arc (>= 90°) with all planets in the rest if planets_in_gap == 0: # All planets in occupied arc, clear empty zone shape = "locomotive" elif planets_in_gap == 1: # Check if the singleton is truly isolated (not at the gap boundary) gap_planet_lon = None for lon in lons: if _is_in_arc(lon, gap_start, gap_end, largest_gap): gap_planet_lon = lon break # A true bucket singleton should not be exactly at gap_end if gap_planet_lon is not None and abs(gap_planet_lon - gap_end) < 0.01: # Planet is at the boundary -- check if gaps are even (splash) second_largest = sorted(gaps, key=lambda g: g[0], reverse=True)[1][0] if len(gaps) > 1 else 0 if largest_gap - second_largest < 30: shape = "splash" else: shape = "locomotive" else: shape = "bucket" else: # Multiple planets in the gap -- check if they form a pair gap_planets = [lon for lon in lons if _is_in_arc(lon, gap_end, gap_start, largest_gap)] if len(gap_planets) == 2: gap_between_pair = abs(gap_planets[1] - gap_planets[0]) if gap_between_pair < 60: shape = "bucket" else: shape = "seesaw" else: shape = "locomotive" else: # occupied_arc > 250, largest_gap < 110 # Check for seesaw: two clusters on opposite sides second_largest = sorted(gaps, key=lambda g: g[0], reverse=True)[1] if len(gaps) > 1 else (0, 0, 0) if second_largest[0] > 90: shape = "seesaw" else: shape = "splay" return { "shape": shape, "largest_gap": round(largest_gap, 2), "gap_start": round(gap_start, 2), "gap_end": round(gap_end, 2), "occupied_arc": round(occupied_arc, 2), } def _is_in_arc(lon: float, arc_start: float, arc_end: float, arc_size: float) -> bool: """Check if a longitude falls strictly within an arc (going clockwise from start to end). arc_start is exclusive, arc_end is inclusive. This avoids double-counting planets that sit exactly on gap boundaries. """ if arc_size >= 360.0: return True if arc_size <= 0.0: return False lon = normalize_degrees(lon) arc_start = normalize_degrees(arc_start) arc_end = normalize_degrees(arc_end) if arc_start < arc_end: return arc_start < lon <= arc_end else: # Wraps through 0 return lon > arc_start or lon <= arc_end