astrology.py 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335
  1. """
  2. Pure astrological calculation module.
  3. No I/O, no async -- just math on ecliptic longitudes and latitudes.
  4. All functions operate on plain dicts/lists for easy testing.
  5. Input conventions:
  6. - All longitudes in degrees (0-360 ecliptic)
  7. - All latitudes in degrees (-90 to +90 ecliptic)
  8. - Sidereal time in hours (0-24)
  9. - All angles in degrees (0-360) unless noted
  10. Output conventions:
  11. - Zodiac positions: {"sign": str, "degree": float} where degree is 0-30 within sign
  12. - House cusps: array of 12 {"sign": str, "degree": float} (cusp 1 = ASC, cusp 10 = MC)
  13. - Aspects: (body1, body2, aspect_name, orb, applying|separating, exactness)
  14. - Angles: {asc: {sign, degree}, mc: {sign, degree}, dsc: {sign, degree}, ic: {sign, degree}}
  15. """
  16. from __future__ import annotations
  17. import math
  18. from typing import Any
  19. # ── Zodiac Signs ─────────────────────────────────────────────────────
  20. ZODIAC_SIGNS = [
  21. "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
  22. "Libra", "Scorpius", "Sagittarius", "Capricornus", "Aquarius", "Pisces",
  23. ]
  24. SIGN_ABBREVIATIONS = [
  25. "Ar", "Ta", "Ge", "Cn", "Le", "Vi",
  26. "Li", "Sc", "Sg", "Cp", "Aq", "Pi",
  27. ]
  28. def normalize_degrees(lon: float) -> float:
  29. """Normalize longitude to 0-360."""
  30. return lon % 360.0
  31. def ecliptic_to_zodiac(lon: float) -> dict[str, Any]:
  32. """Convert ecliptic longitude to zodiac sign + degree within sign.
  33. Args:
  34. lon: Ecliptic longitude in degrees (0-360, tropical).
  35. Returns:
  36. {"sign": str, "abbreviation": str, "degree": float, "absolute_lon": float}
  37. """
  38. lon = normalize_degrees(lon)
  39. sign_index = int(lon // 30)
  40. degree = lon - (sign_index * 30)
  41. return {
  42. "sign": ZODIAC_SIGNS[sign_index],
  43. "abbreviation": SIGN_ABBREVIATIONS[sign_index],
  44. "degree": round(degree, 6),
  45. "absolute_lon": round(lon, 6),
  46. }
  47. def zodiac_to_ecliptic(sign: str, degree: float) -> float:
  48. """Convert zodiac sign + degree to ecliptic longitude."""
  49. sign_index = ZODIAC_SIGNS.index(sign.capitalize())
  50. return normalize_degrees(sign_index * 30 + degree)
  51. # ── Aspect Definitions ───────────────────────────────────────────────
  52. ASPECT_DEFINITIONS: list[dict[str, Any]] = [
  53. {"name": "conjunction", "angle": 0.0, "default_orb": 8.0, "symbol": "Co"},
  54. {"name": "sextile", "angle": 60.0, "default_orb": 6.0, "symbol": "Sx"},
  55. {"name": "square", "angle": 90.0, "default_orb": 8.0, "symbol": "Sq"},
  56. {"name": "trine", "angle": 120.0, "default_orb": 8.0, "symbol": "Tr"},
  57. {"name": "quincunx", "angle": 150.0, "default_orb": 3.0, "symbol": "Qx"},
  58. {"name": "opposition", "angle": 180.0, "default_orb": 8.0, "symbol": "Op"},
  59. ]
  60. DEFAULT_ORBS: dict[str, float] = {
  61. a["name"]: a["default_orb"] for a in ASPECT_DEFINITIONS
  62. }
  63. # Slow planets get wider orbs for transit significance
  64. SLOW_PLANETS = {"jupiter", "saturn", "uranus", "neptune", "pluto"}
  65. # Transit-to-natal orb radii per planet (degrees).
  66. # These represent the maximum orb when a transiting planet aspects a natal planet.
  67. # Based on standard practice: Sun/Moon get the widest (1.5°), personal planets 1.0°,
  68. # social/slow 1.5°, outer planets 1.0°.
  69. TRANSIT_ORB_RADII: dict[str, float] = {
  70. "sun": 1.5,
  71. "moon": 1.5,
  72. "mercury": 1.0,
  73. "venus": 1.0,
  74. "mars": 1.0,
  75. "jupiter": 1.5,
  76. "saturn": 1.5,
  77. "uranus": 1.0,
  78. "neptune": 1.0,
  79. "pluto": 1.0,
  80. "chiron": 0.5,
  81. "true_node": 1.0,
  82. }
  83. # Aspect type multipliers applied to the base orb.
  84. # Conjunction/opposition are strongest (1.0), trine/square medium (0.75), sextile weakest (0.5).
  85. ASPECT_TYPE_MULTIPLIERS: dict[str, float] = {
  86. "conjunction": 1.0,
  87. "opposition": 1.0,
  88. "square": 0.75,
  89. "trine": 0.75,
  90. "sextile": 0.5,
  91. }
  92. # Significance weights for transit interpretation.
  93. # Higher = more important for forecasting.
  94. ASPECT_SIGNIFICANCE: dict[str, float] = {
  95. "conjunction": 3.0,
  96. "opposition": 3.0,
  97. "square": 2.5,
  98. "trine": 1.5,
  99. "sextile": 1.0,
  100. }
  101. # Significance weight for transiting planet speed.
  102. # Slow planets = longer-lasting, more significant transits.
  103. TRANSITING_PLANET_SIGNIFICANCE: dict[str, float] = {
  104. "sun": 2.0,
  105. "moon": 0.5, # too fast, less individually significant
  106. "mercury": 1.0,
  107. "venus": 1.0,
  108. "mars": 1.5,
  109. "jupiter": 3.0,
  110. "saturn": 3.5,
  111. "uranus": 2.5,
  112. "neptune": 2.5,
  113. "pluto": 3.0,
  114. "chiron": 1.5,
  115. "true_node": 1.5,
  116. }
  117. # Natal planets considered especially important as targets.
  118. # Being aspected by a slow planet is a major transit.
  119. NATAL_TARGET_IMPORTANCE: dict[str, float] = {
  120. "sun": 3.0,
  121. "moon": 3.0,
  122. "mercury": 1.5,
  123. "venus": 1.5,
  124. "mars": 1.5,
  125. "jupiter": 2.0,
  126. "saturn": 2.0,
  127. "uranus": 1.5,
  128. "neptune": 1.5,
  129. "pluto": 1.5,
  130. "chiron": 1.0,
  131. "true_node": 2.0,
  132. }
  133. def get_transit_orb(transiting: str, natal: str, aspect: str) -> float:
  134. """Calculate the maximum allowable orb for a transit-to-natal aspect.
  135. The orb is the sum of the transiting planet's radius and the natal planet's radius,
  136. multiplied by the aspect type multiplier.
  137. """
  138. t_radius = TRANSIT_ORB_RADII.get(transiting, 1.0)
  139. n_radius = TRANSIT_ORB_RADII.get(natal, 1.0)
  140. base = t_radius + n_radius
  141. multiplier = ASPECT_TYPE_MULTIPLIERS.get(aspect, 1.0)
  142. return base * multiplier
  143. def get_transit_significance(transiting: str, natal: str, aspect: str, orb: float, max_orb: float) -> float:
  144. """Calculate a significance score (0..10) for a transit-to-natal aspect.
  145. Factors:
  146. - Aspect type (hard aspects score higher)
  147. - Transiting planet importance (slow planets score higher)
  148. - Natal planet importance (angles, luminaries score higher)
  149. - Orb tightness (tighter = higher score)
  150. """
  151. score = 0.0
  152. score += ASPECT_SIGNIFICANCE.get(aspect, 1.0)
  153. score += TRANSITING_PLANET_SIGNIFICANCE.get(transiting, 1.0)
  154. score += NATAL_TARGET_IMPORTANCE.get(natal, 1.0)
  155. # Orb tightness bonus: 0 orb = +2, at max_orb = +0
  156. if max_orb > 0:
  157. orb_factor = 1.0 - (orb / max_orb)
  158. score += orb_factor * 2.0
  159. return round(min(score, 10.0), 2)
  160. def compute_aspects(
  161. bodies: list[dict[str, Any]],
  162. orb_limits: dict[str, float] | None = None,
  163. ) -> list[dict[str, Any]]:
  164. """Compute all aspects between a set of bodies.
  165. Args:
  166. bodies: List of dicts with at least {"name": str, "lon": float}.
  167. Optionally "speed_lon": float for applying/separating detection.
  168. orb_limits: Optional dict of {aspect_name: max_orb_degrees}.
  169. Defaults to standard orbs.
  170. Returns:
  171. List of aspect dicts, sorted by orb (tightest first).
  172. Each aspect: {
  173. "body1": str,
  174. "body2": str,
  175. "aspect": str,
  176. "orb": float, # degrees from exact
  177. "applying": bool, # True if applying, False if separating
  178. "exactness": float, # 1.0 = exact, 0.0 = at orb limit
  179. "angle": float, # the theoretical angle (0, 60, 90, 120, 180)
  180. }
  181. """
  182. if orb_limits is None:
  183. orb_limits = DEFAULT_ORBS.copy()
  184. else:
  185. orb_limits = {**DEFAULT_ORBS, **orb_limits}
  186. aspects: list[dict[str, Any]] = []
  187. for i, b1 in enumerate(bodies):
  188. for b2 in bodies[i + 1:]:
  189. lon1 = normalize_degrees(b1["lon"])
  190. lon2 = normalize_degrees(b2["lon"])
  191. diff = abs(lon1 - lon2)
  192. diff = min(diff, 360.0 - diff)
  193. for asp_def in ASPECT_DEFINITIONS:
  194. asp_name = asp_def["name"]
  195. asp_angle = asp_def["angle"]
  196. max_orb = orb_limits.get(asp_name, asp_def["default_orb"])
  197. orb = abs(diff - asp_angle)
  198. if orb <= max_orb:
  199. # Determine applying/separating
  200. applying = _is_applying(lon1, lon2, b1.get("speed_lon"), b2.get("speed_lon"), asp_angle)
  201. exactness = 1.0 - (orb / max_orb)
  202. aspects.append({
  203. "body1": b1["name"],
  204. "body2": b2["name"],
  205. "aspect": asp_name,
  206. "orb": round(orb, 6),
  207. "applying": applying,
  208. "exactness": round(max(0.0, exactness), 6),
  209. "angle": asp_angle,
  210. })
  211. # Sort by orb (tightest aspects first)
  212. aspects.sort(key=lambda a: a["orb"])
  213. return aspects
  214. def _is_applying(
  215. lon1: float, lon2: float,
  216. speed1: float | None, speed2: float | None,
  217. target_angle: float,
  218. ) -> bool | None:
  219. """Determine if an aspect is applying or separating.
  220. Returns True if applying, False if separating, None if speeds unknown.
  221. """
  222. if speed1 is None or speed2 is None:
  223. return None
  224. current_diff = abs(lon1 - lon2)
  225. current_diff = min(current_diff, 360.0 - current_diff)
  226. # Project positions forward by 0.1 day
  227. fwd1 = normalize_degrees(lon1 + speed1 * 0.1)
  228. fwd2 = normalize_degrees(lon2 + speed2 * 0.1)
  229. fwd_diff = abs(fwd1 - fwd2)
  230. fwd_diff = min(fwd_diff, 360.0 - fwd_diff)
  231. target = target_angle
  232. # For opposition, also account for the 180 point
  233. current_dist_to_exact = abs(current_diff - target)
  234. fwd_dist_to_exact = abs(fwd_diff - target)
  235. return fwd_dist_to_exact < current_dist_to_exact
  236. # ── House Placement ──────────────────────────────────────────────────
  237. def get_house_placement(
  238. ecliptic_lon: float,
  239. houses: list[dict[str, Any]],
  240. ) -> int:
  241. """Determine which house a given ecliptic longitude falls in.
  242. Args:
  243. ecliptic_lon: Ecliptic longitude in degrees (0-360).
  244. houses: House cusp array from the ephemeris server (via extract_houses).
  245. Returns:
  246. House number (1-12).
  247. """
  248. lon = normalize_degrees(ecliptic_lon)
  249. cusps = [(h["house"], h["absolute_lon"]) for h in houses]
  250. cusps.sort()
  251. # Iterate through houses; a point is in house N if it's between cusp N and cusp N+1
  252. for i in range(12):
  253. start_house, start_lon = cusps[i]
  254. end_house, end_lon = cusps[(i + 1) % 12]
  255. if start_lon < end_lon:
  256. if start_lon <= lon < end_lon:
  257. return start_house
  258. else:
  259. # Wraps through 0°
  260. if lon >= start_lon or lon < end_lon:
  261. return start_house
  262. return 1 # fallback
  263. # ── Retrograde ───────────────────────────────────────────────────────
  264. def is_retrograde(speed_lon: float | None) -> bool:
  265. """Determine if a body is retrograde based on its ecliptic longitude speed.
  266. Args:
  267. speed_lon: Speed in ecliptic longitude in degrees/day.
  268. Returns:
  269. True if retrograde (speed < 0).
  270. """
  271. if speed_lon is None:
  272. return False
  273. return speed_lon < 0
  274. # ── Composite / Davison ──────────────────────────────────────────────
  275. def compute_composite_chart(
  276. bodies1: list[dict[str, Any]],
  277. bodies2: list[dict[str, Any]],
  278. ) -> list[dict[str, Any]]:
  279. """Compute a composite chart via midpoint method.
  280. For each body present in both charts, compute the midpoint longitude.
  281. Midpoint = average of the two positions, taking the shorter arc.
  282. Args:
  283. bodies1: First chart bodies, each with {"name": str, "lon": float, ...}
  284. bodies2: Second chart bodies, same format.
  285. Returns:
  286. List of composite bodies with {"name": str, "lon": float}.
  287. """
  288. lookup1 = {b["name"]: b["lon"] for b in bodies1}
  289. lookup2 = {b["name"]: b["lon"] for b in bodies2}
  290. common = set(lookup1.keys()) & set(lookup2.keys())
  291. result = []
  292. for name in sorted(common):
  293. lon1 = lookup1[name]
  294. lon2 = lookup2[name]
  295. mid = _midpoint(lon1, lon2)
  296. result.append({"name": name, "lon": round(mid, 6)})
  297. return result
  298. def compute_davison_chart(
  299. mid1: float, mid2: float,
  300. dt1: str, dt2: str,
  301. ) -> dict[str, float]:
  302. """Compute Davison chart midpoints (date and location).
  303. The Davison chart uses the midpoint in time and location between two birth dates.
  304. Args:
  305. mid1: Birth datetime 1 as ISO string or Julian Day number.
  306. mid2: Birth datetime 2 as ISO string or Julian Day number.
  307. dt1, dt2: Used for date midpoint.
  308. Returns:
  309. {"date_midpoint_jd": float, "lat_midpoint": float, "lon_midpoint": float}
  310. """
  311. from datetime import datetime, timezone, timedelta
  312. if isinstance(mid1, (int, float)):
  313. # Assume it's already a JD or ordinal
  314. jd1 = float(mid1)
  315. jd2 = float(mid2)
  316. date_mid = (jd1 + jd2) / 2.0
  317. else:
  318. d1 = datetime.fromisoformat(str(mid1).replace("Z", "+00:00"))
  319. d2 = datetime.fromisoformat(str(mid2).replace("Z", "+00:00"))
  320. mid_dt = d1 + (d2 - d1) / 2
  321. # Convert to JD approximation
  322. date_mid = 2440587.5 + mid_dt.timestamp() / 86400.0
  323. return {
  324. "date_midpoint_jd": date_mid,
  325. }
  326. def _midpoint(lon1: float, lon2: float) -> float:
  327. """Midpoint of two ecliptic longitudes along the shorter arc."""
  328. d = abs(lon1 - lon2)
  329. if d <= 180:
  330. return normalize_degrees((lon1 + lon2) / 2.0)
  331. else:
  332. return normalize_degrees((lon1 + lon2) / 2.0 + 180.0)
  333. # ── Sign Ruler Lookup ────────────────────────────────────────────────
  334. # Modern rulership (Placidus/Modern Western)
  335. SIGN_RULERS: dict[str, str] = {
  336. "Aries": "mars",
  337. "Taurus": "venus",
  338. "Gemini": "mercury",
  339. "Cancer": "moon",
  340. "Leo": "sun",
  341. "Virgo": "mercury",
  342. "Libra": "venus",
  343. "Scorpius": "pluto",
  344. "Sagittarius": "jupiter",
  345. "Capricornus": "saturn",
  346. "Aquarius": "uranus",
  347. "Pisces": "neptune",
  348. }
  349. # Traditional rulership (pre-discovery of outer planets)
  350. SIGN_RULERS_TRADITIONAL: dict[str, str] = {
  351. "Aries": "mars",
  352. "Taurus": "venus",
  353. "Gemini": "mercury",
  354. "Cancer": "moon",
  355. "Leo": "sun",
  356. "Virgo": "mercury",
  357. "Libra": "venus",
  358. "Scorpius": "mars",
  359. "Sagittarius": "jupiter",
  360. "Capricornus": "saturn",
  361. "Aquarius": "saturn",
  362. "Pisces": "jupiter",
  363. }
  364. # Element lookup by sign
  365. SIGN_ELEMENTS: dict[str, str] = {
  366. "Aries": "fire", "Taurus": "earth", "Gemini": "air", "Cancer": "water",
  367. "Leo": "fire", "Virgo": "earth", "Libra": "air", "Scorpius": "water",
  368. "Sagittarius": "fire", "Capricornus": "earth", "Aquarius": "air", "Pisces": "water",
  369. }
  370. # Modality lookup by sign
  371. SIGN_MODALITIES: dict[str, str] = {
  372. "Aries": "cardinal", "Taurus": "fixed", "Gemini": "mutable", "Cancer": "cardinal",
  373. "Leo": "fixed", "Virgo": "mutable", "Libra": "cardinal", "Scorpius": "fixed",
  374. "Sagittarius": "mutable", "Capricornus": "cardinal", "Aquarius": "fixed", "Pisces": "mutable",
  375. }
  376. # Element to signs
  377. ELEMENT_SIGNS: dict[str, list[str]] = {
  378. "fire": ["Aries", "Leo", "Sagittarius"],
  379. "earth": ["Taurus", "Virgo", "Capricornus"],
  380. "air": ["Gemini", "Libra", "Aquarius"],
  381. "water": ["Cancer", "Scorpius", "Pisces"],
  382. }
  383. # Modality to signs
  384. MODALITY_SIGNS: dict[str, list[str]] = {
  385. "cardinal": ["Aries", "Cancer", "Libra", "Capricornus"],
  386. "fixed": ["Taurus", "Leo", "Scorpius", "Aquarius"],
  387. "mutable": ["Gemini", "Virgo", "Sagittarius", "Pisces"],
  388. }
  389. # Angular houses
  390. ANGULAR_HOUSES = {1, 4, 7, 10}
  391. SUCCEDENT_HOUSES = {2, 5, 8, 11}
  392. CADENT_HOUSES = {3, 6, 9, 12}
  393. # Personal planets (for karmic filtering)
  394. PERSONAL_PLANETS = {"sun", "moon", "mercury", "venus", "mars"}
  395. # Hard aspects (for karmic filtering)
  396. HARD_ASPECTS = {"conjunction", "square", "opposition"}
  397. def _planet_sign(planet_name: str, planets: list[dict[str, Any]]) -> str | None:
  398. """Look up the sign of a planet from a planet list."""
  399. for p in planets:
  400. if p["body"] == planet_name:
  401. return p.get("sign")
  402. return None
  403. def _planet_house(planet_name: str, planets: list[dict[str, Any]]) -> int | None:
  404. """Look up the house of a planet from a planet list."""
  405. for p in planets:
  406. if p["body"] == planet_name:
  407. return p.get("house")
  408. return None
  409. def _planet_lon(planet_name: str, planets: list[dict[str, Any]]) -> float | None:
  410. """Look up the absolute_lon of a planet from a planet list."""
  411. for p in planets:
  412. if p["body"] == planet_name:
  413. return p.get("absolute_lon")
  414. return None
  415. # ── Chart Overview Functions ──────────────────────────────────────────
  416. def get_element_balance(planets: list[dict[str, Any]]) -> dict[str, Any]:
  417. """Count planets by element (fire/earth/air/water).
  418. Args:
  419. planets: Planet list from calculate_natal_chart output.
  420. Returns:
  421. Dict with counts and percentages per element.
  422. """
  423. counts: dict[str, int] = {"fire": 0, "earth": 0, "air": 0, "water": 0}
  424. for p in planets:
  425. sign = p.get("sign", "")
  426. element = SIGN_ELEMENTS.get(sign)
  427. if element:
  428. counts[element] += 1
  429. total = sum(counts.values())
  430. percentages = {k: round(v / total * 100, 1) if total > 0 else 0.0 for k, v in counts.items()}
  431. return {"counts": counts, "percentages": percentages, "total": total}
  432. def get_modality_balance(planets: list[dict[str, Any]]) -> dict[str, Any]:
  433. """Count planets by modality (cardinal/fixed/mutable).
  434. Args:
  435. planets: Planet list from calculate_natal_chart output.
  436. Returns:
  437. Dict with counts and percentages per modality.
  438. """
  439. counts: dict[str, int] = {"cardinal": 0, "fixed": 0, "mutable": 0}
  440. for p in planets:
  441. sign = p.get("sign", "")
  442. modality = SIGN_MODALITIES.get(sign)
  443. if modality:
  444. counts[modality] += 1
  445. total = sum(counts.values())
  446. percentages = {k: round(v / total * 100, 1) if total > 0 else 0.0 for k, v in counts.items()}
  447. return {"counts": counts, "percentages": percentages, "total": total}
  448. def get_hemisphere_emphasis(planets: list[dict[str, Any]]) -> dict[str, int]:
  449. """Count planets by hemisphere (upper/lower/east/west).
  450. Upper = houses 7-12, Lower = houses 1-6.
  451. East = houses 10-3 (via 12/1/2), West = houses 4-9.
  452. Args:
  453. planets: Planet list from calculate_natal_chart output.
  454. Returns:
  455. Dict with counts per hemisphere.
  456. """
  457. upper = 0
  458. lower = 0
  459. east = 0
  460. west = 0
  461. for p in planets:
  462. house = p.get("house", 1)
  463. if 7 <= house <= 12:
  464. upper += 1
  465. else:
  466. lower += 1
  467. if house in (10, 11, 12, 1, 2, 3):
  468. east += 1
  469. else:
  470. west += 1
  471. return {"upper": upper, "lower": lower, "east": east, "west": west}
  472. def detect_stelliums(planets: list[dict[str, Any]]) -> list[dict[str, Any]]:
  473. """Detect stelliums: 3+ planets in the same sign or house.
  474. Args:
  475. planets: Planet list from calculate_natal_chart output.
  476. Returns:
  477. List of stellium dicts with type, key, and involved planets.
  478. """
  479. result = []
  480. # By sign
  481. sign_groups: dict[str, list[str]] = {}
  482. for p in planets:
  483. sign = p.get("sign", "")
  484. if sign not in sign_groups:
  485. sign_groups[sign] = []
  486. sign_groups[sign].append(p["body"])
  487. for sign, bodies in sign_groups.items():
  488. if len(bodies) >= 3:
  489. result.append({"type": "sign", "key": sign, "planets": bodies})
  490. # By house
  491. house_groups: dict[int, list[str]] = {}
  492. for p in planets:
  493. house = p.get("house", 1)
  494. if house not in house_groups:
  495. house_groups[house] = []
  496. house_groups[house].append(p["body"])
  497. for house, bodies in house_groups.items():
  498. if len(bodies) >= 3:
  499. result.append({"type": "house", "key": str(house), "planets": bodies})
  500. return result
  501. def get_empty_houses(planets: list[dict[str, Any]]) -> list[int]:
  502. """Return house numbers (1-12) that have no planets.
  503. Args:
  504. planets: Planet list from calculate_natal_chart output.
  505. Returns:
  506. Sorted list of empty house numbers.
  507. """
  508. occupied = {p.get("house", 1) for p in planets}
  509. return sorted(h for h in range(1, 13) if h not in occupied)
  510. def get_chart_ruler(
  511. ascendant_sign: str,
  512. planets: list[dict[str, Any]],
  513. traditional: bool = False,
  514. ) -> dict[str, Any] | None:
  515. """Identify the chart ruler: the planet ruling the Ascendant sign.
  516. Args:
  517. ascendant_sign: Sign on the Ascendant (e.g., "Aries").
  518. planets: Planet list from calculate_natal_chart output.
  519. traditional: If True, use traditional rulership (no outer planets).
  520. Returns:
  521. Dict with ruler name, sign, house, retrograde, and absolute_lon.
  522. None if the ruler is not found in the planet list.
  523. """
  524. rulers = SIGN_RULERS_TRADITIONAL if traditional else SIGN_RULERS
  525. ruler_name = rulers.get(ascendant_sign)
  526. if not ruler_name:
  527. return None
  528. for p in planets:
  529. if p["body"] == ruler_name:
  530. return {
  531. "body": p["body"],
  532. "sign": p.get("sign"),
  533. "house": p.get("house"),
  534. "retrograde": p.get("retrograde", False),
  535. "absolute_lon": p.get("absolute_lon"),
  536. }
  537. return None
  538. def get_house_rulers(
  539. houses: list[dict[str, Any]],
  540. planets: list[dict[str, Any]],
  541. traditional: bool = False,
  542. ) -> list[dict[str, Any]]:
  543. """For each house, find the ruling planet and its condition.
  544. Args:
  545. houses: House list from calculate_natal_chart output.
  546. planets: Planet list from calculate_natal_chart output.
  547. traditional: If True, use traditional rulership.
  548. Returns:
  549. List of dicts with house, cusp_sign, ruler, ruler_sign, ruler_house, ruler_retrograde.
  550. """
  551. rulers = SIGN_RULERS_TRADITIONAL if traditional else SIGN_RULERS
  552. result = []
  553. for h in houses:
  554. cusp_sign = h.get("sign", "")
  555. ruler_name = rulers.get(cusp_sign, "")
  556. ruler_info = {
  557. "house": h["house"],
  558. "cusp_sign": cusp_sign,
  559. "ruler": ruler_name,
  560. "ruler_sign": None,
  561. "ruler_house": None,
  562. "ruler_retrograde": None,
  563. }
  564. if ruler_name:
  565. for p in planets:
  566. if p["body"] == ruler_name:
  567. ruler_info["ruler_sign"] = p.get("sign")
  568. ruler_info["ruler_house"] = p.get("house")
  569. ruler_info["ruler_retrograde"] = p.get("retrograde", False)
  570. break
  571. result.append(ruler_info)
  572. return result
  573. def group_planets_by_house(planets: list[dict[str, Any]]) -> dict[int, list[str]]:
  574. """Group planet names by house number.
  575. Args:
  576. planets: Planet list from calculate_natal_chart output.
  577. Returns:
  578. Dict mapping house number (1-12) to list of planet names.
  579. """
  580. result: dict[int, list[str]] = {}
  581. for p in planets:
  582. house = p.get("house", 1)
  583. if house not in result:
  584. result[house] = []
  585. result[house].append(p["body"])
  586. return result
  587. def group_planets_by_sign(planets: list[dict[str, Any]]) -> dict[str, list[str]]:
  588. """Group planet names by sign.
  589. Args:
  590. planets: Planet list from calculate_natal_chart output.
  591. Returns:
  592. Dict mapping sign name to list of planet names.
  593. """
  594. result: dict[str, list[str]] = {}
  595. for p in planets:
  596. sign = p.get("sign", "")
  597. if sign not in result:
  598. result[sign] = []
  599. result[sign].append(p["body"])
  600. return result
  601. def get_house_type_counts(planets: list[dict[str, Any]]) -> dict[str, int]:
  602. """Count planets by house type: angular, succedent, cadent.
  603. Args:
  604. planets: Planet list from calculate_natal_chart output.
  605. Returns:
  606. Dict with counts for each house type.
  607. """
  608. counts = {"angular": 0, "succedent": 0, "cadent": 0}
  609. for p in planets:
  610. house = p.get("house", 1)
  611. if house in ANGULAR_HOUSES:
  612. counts["angular"] += 1
  613. elif house in SUCCEDENT_HOUSES:
  614. counts["succedent"] += 1
  615. elif house in CADENT_HOUSES:
  616. counts["cadent"] += 1
  617. return counts
  618. def get_retrograde_planets(planets: list[dict[str, Any]]) -> list[dict[str, Any]]:
  619. """Return all retrograde planets with their sign and house.
  620. Args:
  621. planets: Planet list from calculate_natal_chart output.
  622. Returns:
  623. List of retrograde planet dicts with body, sign, house.
  624. """
  625. return [
  626. {"body": p["body"], "sign": p.get("sign"), "house": p.get("house")}
  627. for p in planets
  628. if p.get("retrograde", False)
  629. ]
  630. # ── Karmic Helper Functions ───────────────────────────────────────────
  631. def get_nodal_axis(
  632. planets: list[dict[str, Any]],
  633. houses: list[dict[str, Any]] | None = None,
  634. ) -> dict[str, Any]:
  635. """Extract the nodal axis (North + South Node) from planet data.
  636. Args:
  637. planets: Planet list from calculate_natal_chart output.
  638. houses: Optional house list for house placement.
  639. Returns:
  640. Dict with north_node and south_node, each having sign, house, degree, absolute_lon.
  641. """
  642. result: dict[str, Any] = {"north_node": None, "south_node": None}
  643. for p in planets:
  644. if p["body"] == "true_node":
  645. north = {
  646. "body": "true_node",
  647. "sign": p.get("sign"),
  648. "degree_within_sign": p.get("degree_within_sign"),
  649. "absolute_lon": p.get("absolute_lon"),
  650. "house": p.get("house"),
  651. }
  652. # South Node = opposite point
  653. south_lon = normalize_degrees(p.get("absolute_lon", 0.0) + 180.0)
  654. south_zodiac = ecliptic_to_zodiac(south_lon)
  655. south = {
  656. "body": "south_node",
  657. "sign": south_zodiac["sign"],
  658. "degree_within_sign": south_zodiac["degree"],
  659. "absolute_lon": south_zodiac["absolute_lon"],
  660. }
  661. if houses:
  662. south["house"] = get_house_placement(south_lon, houses)
  663. north["house"] = p.get("house")
  664. result["north_node"] = north
  665. result["south_node"] = south
  666. break
  667. return result
  668. def get_saturn_info(planets: list[dict[str, Any]]) -> dict[str, Any] | None:
  669. """Extract Saturn's position data from planet list.
  670. Args:
  671. planets: Planet list from calculate_natal_chart output.
  672. Returns:
  673. Dict with Saturn's body, sign, house, degree, retrograde, absolute_lon.
  674. None if Saturn not found.
  675. """
  676. for p in planets:
  677. if p["body"] == "saturn":
  678. return {
  679. "body": p["body"],
  680. "sign": p.get("sign"),
  681. "house": p.get("house"),
  682. "degree_within_sign": p.get("degree_within_sign"),
  683. "retrograde": p.get("retrograde", False),
  684. "absolute_lon": p.get("absolute_lon"),
  685. }
  686. return None
  687. def get_pluto_polarity_point(
  688. planets: list[dict[str, Any]],
  689. houses: list[dict[str, Any]] | None = None,
  690. ) -> dict[str, Any] | None:
  691. """Calculate Pluto's Polarity Point (PPP) -- the point opposite Pluto.
  692. Args:
  693. planets: Planet list from calculate_natal_chart output.
  694. houses: Optional house list for house placement.
  695. Returns:
  696. Dict with PPP sign, house, degree, absolute_lon. None if Pluto not found.
  697. """
  698. for p in planets:
  699. if p["body"] == "pluto":
  700. ppp_lon = normalize_degrees(p.get("absolute_lon", 0.0) + 180.0)
  701. z = ecliptic_to_zodiac(ppp_lon)
  702. result = {
  703. "body": "pluto_polarity_point",
  704. "sign": z["sign"],
  705. "degree_within_sign": z["degree"],
  706. "absolute_lon": z["absolute_lon"],
  707. }
  708. if houses:
  709. result["house"] = get_house_placement(ppp_lon, houses)
  710. return result
  711. return None
  712. def get_part_of_fortune(
  713. ascendant_lon: float,
  714. sun_lon: float,
  715. moon_lon: float,
  716. houses: list[dict[str, Any]] | None = None,
  717. ) -> dict[str, Any]:
  718. """Calculate the Arabic Part of Fortune.
  719. Formula: ASC + Moon - Sun (in ecliptic longitude).
  720. Args:
  721. ascendant_lon: Ascendant absolute longitude.
  722. sun_lon: Sun absolute longitude.
  723. moon_lon: Moon absolute longitude.
  724. houses: Optional house list for house placement.
  725. Returns:
  726. Dict with sign, degree, absolute_lon, and optionally house.
  727. """
  728. pof_lon = normalize_degrees(ascendant_lon + moon_lon - sun_lon)
  729. z = ecliptic_to_zodiac(pof_lon)
  730. result = {
  731. "body": "part_of_fortune",
  732. "sign": z["sign"],
  733. "degree_within_sign": z["degree"],
  734. "absolute_lon": z["absolute_lon"],
  735. }
  736. if houses:
  737. result["house"] = get_house_placement(pof_lon, houses)
  738. return result
  739. def get_twelfth_house_analysis(
  740. houses: list[dict[str, Any]],
  741. planets: list[dict[str, Any]],
  742. traditional: bool = False,
  743. ) -> dict[str, Any]:
  744. """Analyze the 12th house for karmic/spiritual themes.
  745. Args:
  746. houses: House list from calculate_natal_chart output.
  747. planets: Planet list from calculate_natal_chart output.
  748. traditional: If True, use traditional rulership for 12th house ruler.
  749. Returns:
  750. Dict with cusp_sign, planets_in_12th, ruler info.
  751. """
  752. cusp_sign = None
  753. for h in houses:
  754. if h["house"] == 12:
  755. cusp_sign = h.get("sign")
  756. break
  757. planets_in_12th = [
  758. {"body": p["body"], "sign": p.get("sign"), "degree_within_sign": p.get("degree_within_sign")}
  759. for p in planets
  760. if p.get("house") == 12
  761. ]
  762. ruler_info = None
  763. if cusp_sign:
  764. rulers = SIGN_RULERS_TRADITIONAL if traditional else SIGN_RULERS
  765. ruler_name = rulers.get(cusp_sign)
  766. if ruler_name:
  767. for p in planets:
  768. if p["body"] == ruler_name:
  769. ruler_info = {
  770. "ruler": ruler_name,
  771. "sign": p.get("sign"),
  772. "house": p.get("house"),
  773. "retrograde": p.get("retrograde", False),
  774. }
  775. break
  776. if not ruler_info:
  777. ruler_info = {"ruler": ruler_name, "sign": None, "house": None, "retrograde": None}
  778. return {
  779. "cusp_sign": cusp_sign,
  780. "planets": planets_in_12th,
  781. "ruler": ruler_info,
  782. }
  783. def filter_aspects_by_planets(
  784. aspects: list[dict[str, Any]],
  785. planet_names: set[str],
  786. aspect_types: set[str] | None = None,
  787. ) -> list[dict[str, Any]]:
  788. """Filter aspects to only those involving specific planets and optionally specific aspect types.
  789. Args:
  790. aspects: Aspect list from compute_aspects or calculate_natal_chart.
  791. planet_names: Set of planet names to filter for (either body1 or body2).
  792. aspect_types: Optional set of aspect names to filter for (e.g., {"conjunction", "square"}).
  793. Returns:
  794. Filtered list of aspects.
  795. """
  796. result = []
  797. for asp in aspects:
  798. b1 = asp.get("body1", "")
  799. b2 = asp.get("body2", "")
  800. # Strip transit_ and natal_ prefixes for matching
  801. b1_clean = b1.replace("transit_", "").replace("natal_", "").replace("p1_", "").replace("p2_", "")
  802. b2_clean = b2.replace("transit_", "").replace("natal_", "").replace("p1_", "").replace("p2_", "")
  803. if b1_clean in planet_names or b2_clean in planet_names:
  804. if aspect_types is None or asp.get("aspect") in aspect_types:
  805. result.append(asp)
  806. return result
  807. def get_natal_aspects_to_planets(
  808. aspects: list[dict[str, Any]],
  809. target_planets: set[str],
  810. aspect_types: set[str] | None = None,
  811. ) -> list[dict[str, Any]]:
  812. """Get all natal aspects involving specific target planets.
  813. Convenience wrapper around filter_aspects_by_planets for natal chart aspects.
  814. Args:
  815. aspects: Aspect list from calculate_natal_chart.
  816. target_planets: Set of planet names (e.g., {"sun", "moon", "true_node"}).
  817. aspect_types: Optional set of aspect types to filter for.
  818. Returns:
  819. Filtered aspects sorted by orb (tightest first).
  820. """
  821. filtered = filter_aspects_by_planets(aspects, target_planets, aspect_types)
  822. filtered.sort(key=lambda a: a.get("orb", 999))
  823. return filtered
  824. # ── Aspect Pattern Detection ─────────────────────────────────────────
  825. def detect_aspect_patterns(
  826. planets: list[dict[str, Any]],
  827. aspects: list[dict[str, Any]],
  828. orb_limit: float = 8.0,
  829. ) -> list[dict[str, Any]]:
  830. """Detect major aspect patterns in a natal chart.
  831. Scans the aspect list for T-squares, Grand Trines, Grand Crosses, and Yods.
  832. Args:
  833. planets: Planet list from calculate_natal_chart output.
  834. aspects: Aspect list (formatted, with body1/body2 keys).
  835. orb_limit: Maximum orb for pattern detection (default 8°).
  836. Returns:
  837. List of pattern dicts with type, planets, apex, element, modality.
  838. """
  839. patterns: list[dict[str, Any]] = []
  840. # Build a set of aspect pairs for fast lookup
  841. # Key: (body_a, body_b, aspect_type) where body_a < body_b alphabetically
  842. aspect_set: dict[tuple[str, str, str], dict[str, Any]] = {}
  843. for asp in aspects:
  844. b1 = asp.get("body1", "")
  845. b2 = asp.get("body2", "")
  846. asp_type = asp.get("aspect", "")
  847. orb = asp.get("orb", 999)
  848. if orb > orb_limit:
  849. continue
  850. key = (min(b1, b2), max(b1, b2), asp_type)
  851. aspect_set[key] = asp
  852. def _has_aspect(b1: str, b2: str, asp_type: str) -> bool:
  853. return (min(b1, b2), max(b1, b2), asp_type) in aspect_set
  854. def _get_orb(b1: str, b2: str, asp_type: str) -> float:
  855. key = (min(b1, b2), max(b1, b2), asp_type)
  856. return aspect_set[key].get("orb", 999) if key in aspect_set else 999
  857. planet_names = [p["body"] for p in planets]
  858. # ── T-square: two planets in opposition, both squaring a third ──
  859. for asp in aspects:
  860. if asp.get("aspect") != "opposition" or asp.get("orb", 999) > orb_limit:
  861. continue
  862. a = asp["body1"]
  863. b = asp["body2"]
  864. for c in planet_names:
  865. if c == a or c == b:
  866. continue
  867. if _has_aspect(a, c, "square") and _has_aspect(b, c, "square"):
  868. orb_ac = _get_orb(a, c, "square")
  869. orb_bc = _get_orb(b, c, "square")
  870. if orb_ac <= orb_limit and orb_bc <= orb_limit:
  871. # Determine modality from the signs
  872. signs = []
  873. for pname in [a, b, c]:
  874. for p in planets:
  875. if p["body"] == pname:
  876. signs.append(p.get("sign", ""))
  877. break
  878. modality = None
  879. if len(signs) == 3:
  880. mods = [SIGN_MODALITIES.get(s) for s in signs]
  881. if len(set(mods)) == 1 and mods[0]:
  882. modality = mods[0]
  883. patterns.append({
  884. "type": "T-square",
  885. "planets": sorted([a, b, c]),
  886. "apex": c,
  887. "apex_house": _planet_house(c, planets),
  888. "houses": sorted([_planet_house(a, planets) or 0, _planet_house(b, planets) or 0, _planet_house(c, planets) or 0]),
  889. "signs": signs,
  890. "modality": modality,
  891. "orb_max": max(asp.get("orb", 0), orb_ac, orb_bc),
  892. })
  893. # ── Grand Trine: three planets all in trine (same element) ──
  894. trine_pairs: list[tuple[str, str, float]] = []
  895. for asp in aspects:
  896. if asp.get("aspect") == "trine" and asp.get("orb", 999) <= orb_limit:
  897. trine_pairs.append((asp["body1"], asp["body2"], asp.get("orb", 999)))
  898. # Find triangles in trine pairs
  899. trine_graph: dict[str, list[str]] = {}
  900. for b1, b2, _ in trine_pairs:
  901. if b1 not in trine_graph:
  902. trine_graph[b1] = []
  903. if b2 not in trine_graph:
  904. trine_graph[b2] = []
  905. trine_graph[b1].append(b2)
  906. trine_graph[b2].append(b1)
  907. found_trines: set[frozenset] = set()
  908. for a in trine_graph:
  909. for b in trine_graph.get(a, []):
  910. for c in trine_graph.get(b, []):
  911. if c != a and a in trine_graph.get(c, []):
  912. trio = frozenset([a, b, c])
  913. if trio not in found_trines:
  914. found_trines.add(trio)
  915. # Determine element
  916. signs = []
  917. for pname in [a, b, c]:
  918. for p in planets:
  919. if p["body"] == pname:
  920. signs.append(p.get("sign", ""))
  921. break
  922. elements = [SIGN_ELEMENTS.get(s) for s in signs]
  923. element = elements[0] if elements and len(set(elements)) == 1 else None
  924. max_orb = max(
  925. _get_orb(a, b, "trine"),
  926. _get_orb(b, c, "trine"),
  927. _get_orb(a, c, "trine"),
  928. )
  929. patterns.append({
  930. "type": "Grand Trine",
  931. "planets": sorted([a, b, c]),
  932. "houses": sorted([_planet_house(a, planets) or 0, _planet_house(b, planets) or 0, _planet_house(c, planets) or 0]),
  933. "signs": signs,
  934. "element": element,
  935. "orb_max": max_orb,
  936. })
  937. # ── Grand Cross: four planets forming 2 oppositions + 4 squares ──
  938. opp_pairs = [(asp["body1"], asp["body2"]) for asp in aspects
  939. if asp.get("aspect") == "opposition" and asp.get("orb", 999) <= orb_limit]
  940. found_crosses: set[frozenset] = set()
  941. for i, (a, b) in enumerate(opp_pairs):
  942. for c, d in opp_pairs[i + 1:]:
  943. quartet = frozenset([a, b, c, d])
  944. if quartet in found_crosses:
  945. continue
  946. # Check that each planet in pair 1 squares both planets in pair 2
  947. if (_has_aspect(a, c, "square") and _has_aspect(a, d, "square") and
  948. _has_aspect(b, c, "square") and _has_aspect(b, d, "square")):
  949. found_crosses.add(quartet)
  950. signs = []
  951. for pname in sorted([a, b, c, d]):
  952. for p in planets:
  953. if p["body"] == pname:
  954. signs.append(p.get("sign", ""))
  955. break
  956. mods = [SIGN_MODALITIES.get(s) for s in signs]
  957. modality = mods[0] if len(set(mods)) == 1 and mods[0] else None
  958. max_orb = max(
  959. _get_orb(a, b, "opposition"),
  960. _get_orb(c, d, "opposition"),
  961. _get_orb(a, c, "square"),
  962. _get_orb(a, d, "square"),
  963. _get_orb(b, c, "square"),
  964. _get_orb(b, d, "square"),
  965. )
  966. patterns.append({
  967. "type": "Grand Cross",
  968. "planets": sorted([a, b, c, d]),
  969. "houses": sorted([_planet_house(x, planets) or 0 for x in [a, b, c, d]]),
  970. "signs": signs,
  971. "modality": modality,
  972. "orb_max": max_orb,
  973. })
  974. # ── Yod: two planets in sextile, both quincunx a third ──
  975. sextile_pairs = [(asp["body1"], asp["body2"]) for asp in aspects
  976. if asp.get("aspect") == "sextile" and asp.get("orb", 999) <= orb_limit]
  977. found_yods: set[frozenset] = set()
  978. for a, b in sextile_pairs:
  979. for c in planet_names:
  980. if c == a or c == b:
  981. continue
  982. if _has_aspect(a, c, "quincunx") and _has_aspect(b, c, "quincunx"):
  983. trio = frozenset([a, b, c])
  984. if trio not in found_yods:
  985. found_yods.add(trio)
  986. signs = []
  987. for pname in [a, b, c]:
  988. for p in planets:
  989. if p["body"] == pname:
  990. signs.append(p.get("sign", ""))
  991. break
  992. max_orb = max(
  993. _get_orb(a, b, "sextile"),
  994. _get_orb(a, c, "quincunx"),
  995. _get_orb(b, c, "quincunx"),
  996. )
  997. patterns.append({
  998. "type": "Yod",
  999. "planets": sorted([a, b, c]),
  1000. "apex": c,
  1001. "apex_house": _planet_house(c, planets),
  1002. "houses": sorted([_planet_house(x, planets) or 0 for x in [a, b, c]]),
  1003. "signs": signs,
  1004. "orb_max": max_orb,
  1005. })
  1006. return patterns
  1007. # ── Chart Shape Detection ────────────────────────────────────────────
  1008. def detect_chart_shape(planets: list[dict[str, Any]]) -> dict[str, Any]:
  1009. """Detect the chart shape pattern.
  1010. Analyzes the angular distribution of planets to classify the chart shape:
  1011. - Bundle: all planets within 120° arc
  1012. - Bowl: all planets within 180° arc
  1013. - Bucket: all planets within 250° arc with a singleton opposite
  1014. - Splash: planets distributed around the full chart
  1015. - Locomotive: planets within ~250° with a leading "locomotive" planet
  1016. - Seesaw: two opposing clusters
  1017. - Splay: three or more distributed pairs/groups
  1018. Args:
  1019. planets: Planet list from calculate_natal_chart output (must have absolute_lon).
  1020. Returns:
  1021. Dict with shape name, largest gap, and gap boundaries.
  1022. """
  1023. if len(planets) < 2:
  1024. return {"shape": "unknown", "largest_gap": 360.0, "gap_start": 0.0, "gap_end": 0.0}
  1025. lons = sorted([normalize_degrees(p.get("absolute_lon", 0.0)) for p in planets])
  1026. n = len(lons)
  1027. # Compute gaps between adjacent planets (including wraparound)
  1028. gaps: list[tuple[float, float, float]] = [] # (gap_size, start, end)
  1029. for i in range(n):
  1030. next_i = (i + 1) % n
  1031. gap = normalize_degrees(lons[next_i] - lons[i])
  1032. gaps.append((gap, lons[i], lons[next_i]))
  1033. # Find largest gap
  1034. largest_gap, gap_start, gap_end = max(gaps, key=lambda g: g[0])
  1035. # The occupied arc is 360 - largest_gap
  1036. occupied_arc = 360.0 - largest_gap
  1037. # Count planets in the largest gap
  1038. planets_in_gap = sum(1 for lon in lons if _is_in_arc(lon, gap_start, gap_end, largest_gap))
  1039. planets_in_occupied = n - planets_in_gap
  1040. # Classify
  1041. shape = "splash" # default
  1042. if occupied_arc <= 120.0:
  1043. shape = "bundle"
  1044. elif occupied_arc <= 180.0:
  1045. shape = "bowl"
  1046. elif largest_gap < 90.0:
  1047. # No large empty arc -- planets are spread around the chart
  1048. shape = "splash"
  1049. elif occupied_arc <= 250.0:
  1050. # There's a significant empty arc (>= 90°) with all planets in the rest
  1051. if planets_in_gap == 0:
  1052. # All planets in occupied arc, clear empty zone
  1053. shape = "locomotive"
  1054. elif planets_in_gap == 1:
  1055. # Check if the singleton is truly isolated (not at the gap boundary)
  1056. gap_planet_lon = None
  1057. for lon in lons:
  1058. if _is_in_arc(lon, gap_start, gap_end, largest_gap):
  1059. gap_planet_lon = lon
  1060. break
  1061. # A true bucket singleton should not be exactly at gap_end
  1062. if gap_planet_lon is not None and abs(gap_planet_lon - gap_end) < 0.01:
  1063. # Planet is at the boundary -- check if gaps are even (splash)
  1064. second_largest = sorted(gaps, key=lambda g: g[0], reverse=True)[1][0] if len(gaps) > 1 else 0
  1065. if largest_gap - second_largest < 30:
  1066. shape = "splash"
  1067. else:
  1068. shape = "locomotive"
  1069. else:
  1070. shape = "bucket"
  1071. else:
  1072. # Multiple planets in the gap -- check if they form a pair
  1073. gap_planets = [lon for lon in lons if _is_in_arc(lon, gap_end, gap_start, largest_gap)]
  1074. if len(gap_planets) == 2:
  1075. gap_between_pair = abs(gap_planets[1] - gap_planets[0])
  1076. if gap_between_pair < 60:
  1077. shape = "bucket"
  1078. else:
  1079. shape = "seesaw"
  1080. else:
  1081. shape = "locomotive"
  1082. else:
  1083. # occupied_arc > 250, largest_gap < 110
  1084. # Check for seesaw: two clusters on opposite sides
  1085. second_largest = sorted(gaps, key=lambda g: g[0], reverse=True)[1] if len(gaps) > 1 else (0, 0, 0)
  1086. if second_largest[0] > 90:
  1087. shape = "seesaw"
  1088. else:
  1089. shape = "splay"
  1090. return {
  1091. "shape": shape,
  1092. "largest_gap": round(largest_gap, 2),
  1093. "gap_start": round(gap_start, 2),
  1094. "gap_end": round(gap_end, 2),
  1095. "occupied_arc": round(occupied_arc, 2),
  1096. }
  1097. def _is_in_arc(lon: float, arc_start: float, arc_end: float, arc_size: float) -> bool:
  1098. """Check if a longitude falls strictly within an arc (going clockwise from start to end).
  1099. arc_start is exclusive, arc_end is inclusive. This avoids double-counting planets
  1100. that sit exactly on gap boundaries.
  1101. """
  1102. if arc_size >= 360.0:
  1103. return True
  1104. if arc_size <= 0.0:
  1105. return False
  1106. lon = normalize_degrees(lon)
  1107. arc_start = normalize_degrees(arc_start)
  1108. arc_end = normalize_degrees(arc_end)
  1109. if arc_start < arc_end:
  1110. return arc_start < lon <= arc_end
  1111. else:
  1112. # Wraps through 0
  1113. return lon > arc_start or lon <= arc_end