| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335 |
- """
- 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
|