test_astrology.py 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161
  1. """
  2. Unit tests for the pure astrological calculation module.
  3. Tests cover:
  4. - Zodiac sign calculations
  5. - House system calculations (Placidus, Equal, Whole Sign)
  6. - Aspect detection with orbs
  7. - Angle calculations (ASC, MC, DSC, IC)
  8. - House placement
  9. - Retrograde detection
  10. - Composite / Davison charts
  11. """
  12. from __future__ import annotations
  13. from typing import Any
  14. import math
  15. import pytest
  16. from src.astro_mcp.astrology import (
  17. ANGULAR_HOUSES,
  18. CADENT_HOUSES,
  19. DEFAULT_ORBS,
  20. ELEMENT_SIGNS,
  21. HARD_ASPECTS,
  22. MODALITY_SIGNS,
  23. PERSONAL_PLANETS,
  24. SIGN_ABBREVIATIONS,
  25. SIGN_ELEMENTS,
  26. SIGN_MODALITIES,
  27. SIGN_RULERS,
  28. SIGN_RULERS_TRADITIONAL,
  29. SUCCEDENT_HOUSES,
  30. SUPPORTED_HOUSE_SYSTEMS,
  31. ZODIAC_SIGNS,
  32. calculate_angles,
  33. calculate_houses,
  34. compute_aspects,
  35. compute_composite_chart,
  36. detect_aspect_patterns,
  37. detect_chart_shape,
  38. detect_stelliums,
  39. ecliptic_to_zodiac,
  40. filter_aspects_by_planets,
  41. get_chart_ruler,
  42. get_element_balance,
  43. get_empty_houses,
  44. get_hemisphere_emphasis,
  45. get_house_placement,
  46. get_house_rulers,
  47. get_house_type_counts,
  48. get_modality_balance,
  49. get_nodal_axis,
  50. get_natal_aspects_to_planets,
  51. get_part_of_fortune,
  52. get_pluto_polarity_point,
  53. get_retrograde_planets,
  54. get_saturn_info,
  55. get_twelfth_house_analysis,
  56. group_planets_by_house,
  57. group_planets_by_sign,
  58. is_retrograde,
  59. normalize_degrees,
  60. zodiac_to_ecliptic,
  61. )
  62. # ── normalize_degrees ────────────────────────────────────────────────
  63. class TestNormalizeDegrees:
  64. def test_zero(self):
  65. assert normalize_degrees(0.0) == 0.0
  66. def test_positive(self):
  67. assert normalize_degrees(45.0) == 45.0
  68. def test_360_wraps_to_zero(self):
  69. assert normalize_degrees(360.0) == 0.0
  70. def test_over_360(self):
  71. assert normalize_degrees(370.0) == 10.0
  72. def test_negative(self):
  73. assert normalize_degrees(-10.0) == 350.0
  74. def test_large_negative(self):
  75. assert normalize_degrees(-370.0) == 350.0
  76. def test_720(self):
  77. assert normalize_degrees(720.0) == 0.0
  78. # ── ecliptic_to_zodiac ──────────────────────────────────────────────
  79. class TestEclipticToZodiac:
  80. def test_aries_start(self):
  81. result = ecliptic_to_zodiac(0.0)
  82. assert result["sign"] == "Aries"
  83. assert result["degree"] == 0.0
  84. def test_aries_mid(self):
  85. result = ecliptic_to_zodiac(15.0)
  86. assert result["sign"] == "Aries"
  87. assert result["degree"] == 15.0
  88. def test_taurus_start(self):
  89. result = ecliptic_to_zodiac(30.0)
  90. assert result["sign"] == "Taurus"
  91. assert result["degree"] == 0.0
  92. def test_pisces_end(self):
  93. result = ecliptic_to_zodiac(359.0)
  94. assert result["sign"] == "Pisces"
  95. assert abs(result["degree"] - 29.0) < 0.001
  96. def test_all_signs(self):
  97. for i, sign in enumerate(ZODIAC_SIGNS):
  98. lon = i * 30 + 15 # middle of each sign
  99. result = ecliptic_to_zodiac(lon)
  100. assert result["sign"] == sign
  101. assert abs(result["degree"] - 15.0) < 0.001
  102. def test_abbreviation(self):
  103. result = ecliptic_to_zodiac(0.0)
  104. assert result["abbreviation"] == "Ar"
  105. def test_absolute_lon_preserved(self):
  106. result = ecliptic_to_zodiac(45.5)
  107. assert result["absolute_lon"] == 45.5
  108. # ── zodiac_to_ecliptic (roundtrip) ──────────────────────────────────
  109. class TestZodiacToEcliptic:
  110. def test_roundtrip_all_signs(self):
  111. for i, sign in enumerate(ZODIAC_SIGNS):
  112. for deg in [0.0, 15.0, 29.9]:
  113. lon = zodiac_to_ecliptic(sign, deg)
  114. result = ecliptic_to_zodiac(lon)
  115. assert result["sign"] == sign
  116. assert abs(result["degree"] - deg) < 0.01
  117. # ── Aspect Detection ────────────────────────────────────────────────
  118. class TestComputeAspects:
  119. def test_conjunction(self):
  120. bodies = [
  121. {"name": "sun", "lon": 10.0},
  122. {"name": "moon", "lon": 12.0},
  123. ]
  124. aspects = compute_aspects(bodies)
  125. assert len(aspects) == 1
  126. assert aspects[0]["aspect"] == "conjunction"
  127. assert aspects[0]["orb"] == 2.0
  128. assert aspects[0]["body1"] == "sun"
  129. assert aspects[0]["body2"] == "moon"
  130. def test_opposition(self):
  131. bodies = [
  132. {"name": "sun", "lon": 0.0},
  133. {"name": "moon", "lon": 180.0},
  134. ]
  135. aspects = compute_aspects(bodies)
  136. assert any(a["aspect"] == "opposition" and a["orb"] == 0.0 for a in aspects)
  137. def test_trine(self):
  138. bodies = [
  139. {"name": "sun", "lon": 0.0},
  140. {"name": "jupiter", "lon": 120.0},
  141. ]
  142. aspects = compute_aspects(bodies)
  143. assert any(a["aspect"] == "trine" for a in aspects)
  144. def test_square(self):
  145. bodies = [
  146. {"name": "sun", "lon": 0.0},
  147. {"name": "mars", "lon": 90.0},
  148. ]
  149. aspects = compute_aspects(bodies)
  150. assert any(a["aspect"] == "square" for a in aspects)
  151. def test_sextile(self):
  152. bodies = [
  153. {"name": "sun", "lon": 0.0},
  154. {"name": "venus", "lon": 60.0},
  155. ]
  156. aspects = compute_aspects(bodies)
  157. assert any(a["aspect"] == "sextile" for a in aspects)
  158. def test_no_aspects_beyond_orb(self):
  159. bodies = [
  160. {"name": "sun", "lon": 0.0},
  161. {"name": "moon", "lon": 45.0},
  162. ]
  163. aspects = compute_aspects(bodies)
  164. assert len(aspects) == 0
  165. def test_multiple_bodies(self):
  166. bodies = [
  167. {"name": "sun", "lon": 0.0},
  168. {"name": "moon", "lon": 2.0},
  169. {"name": "mars", "lon": 5.0},
  170. ]
  171. aspects = compute_aspects(bodies)
  172. # sun-moon conjunction, sun-mars conjunction, moon-mars conjunction
  173. assert len(aspects) >= 2
  174. def test_sorted_by_orb(self):
  175. bodies = [
  176. {"name": "sun", "lon": 0.0},
  177. {"name": "moon", "lon": 1.0},
  178. {"name": "mars", "lon": 5.0},
  179. ]
  180. aspects = compute_aspects(bodies)
  181. orbs = [a["orb"] for a in aspects]
  182. assert orbs == sorted(orbs)
  183. def test_custom_orbs(self):
  184. bodies = [
  185. {"name": "sun", "lon": 0.0},
  186. {"name": "moon", "lon": 10.0},
  187. ]
  188. # Default conjunction orb is 8, so 10° should not aspect
  189. aspects_default = compute_aspects(bodies)
  190. assert len(aspects_default) == 0
  191. # With wider orb, it should
  192. aspects_wide = compute_aspects(bodies, orb_limits={"conjunction": 12.0})
  193. assert len(aspects_wide) == 1
  194. def test_exactness(self):
  195. bodies = [
  196. {"name": "sun", "lon": 0.0},
  197. {"name": "moon", "lon": 0.0},
  198. ]
  199. aspects = compute_aspects(bodies)
  200. assert aspects[0]["exactness"] == 1.0
  201. def test_applying_with_speeds(self):
  202. # Moon is behind sun but faster -- catching up = applying
  203. bodies = [
  204. {"name": "sun", "lon": 10.0, "speed_lon": 1.0},
  205. {"name": "moon", "lon": 5.0, "speed_lon": 13.0},
  206. ]
  207. aspects = compute_aspects(bodies)
  208. conj = [a for a in aspects if a["aspect"] == "conjunction"]
  209. assert len(conj) == 1
  210. assert conj[0]["applying"] is True
  211. def test_separating_with_speeds(self):
  212. # Sun is ahead of moon and faster -- pulling away = separating
  213. bodies = [
  214. {"name": "sun", "lon": 10.0, "speed_lon": 13.0},
  215. {"name": "moon", "lon": 5.0, "speed_lon": 1.0},
  216. ]
  217. aspects = compute_aspects(bodies)
  218. conj = [a for a in aspects if a["aspect"] == "conjunction"]
  219. assert len(conj) == 1
  220. assert conj[0]["applying"] is False
  221. def test_applying_none_without_speeds(self):
  222. bodies = [
  223. {"name": "sun", "lon": 0.0},
  224. {"name": "moon", "lon": 5.0},
  225. ]
  226. aspects = compute_aspects(bodies)
  227. conj = [a for a in aspects if a["aspect"] == "conjunction"]
  228. assert conj[0]["applying"] is None
  229. def test_wraparound_360(self):
  230. bodies = [
  231. {"name": "sun", "lon": 359.0},
  232. {"name": "moon", "lon": 1.0},
  233. ]
  234. aspects = compute_aspects(bodies)
  235. conj = [a for a in aspects if a["aspect"] == "conjunction"]
  236. assert len(conj) == 1
  237. assert abs(conj[0]["orb"] - 2.0) < 0.01
  238. # ── House Systems ───────────────────────────────────────────────────
  239. class TestCalculateHouses:
  240. def test_all_systems_return_12_cusps(self):
  241. for system in SUPPORTED_HOUSE_SYSTEMS:
  242. houses = calculate_houses(6.0, 45.0, house_system=system)
  243. assert len(houses) == 12
  244. def test_all_systems_have_required_keys(self):
  245. for system in SUPPORTED_HOUSE_SYSTEMS:
  246. houses = calculate_houses(6.0, 45.0, house_system=system)
  247. for h in houses:
  248. assert "house" in h
  249. assert "sign" in h
  250. assert "abbreviation" in h
  251. assert "degree" in h
  252. assert "absolute_lon" in h
  253. def test_house_numbers_1_to_12(self):
  254. for system in SUPPORTED_HOUSE_SYSTEMS:
  255. houses = calculate_houses(6.0, 45.0, house_system=system)
  256. numbers = [h["house"] for h in houses]
  257. assert sorted(numbers) == list(range(1, 13))
  258. def test_equal_house_30_degree_spacing(self):
  259. houses = calculate_houses(6.0, 45.0, house_system="equal")
  260. for i in range(11):
  261. lon1 = houses[i]["absolute_lon"]
  262. lon2 = houses[i + 1]["absolute_lon"]
  263. diff = normalize_degrees(lon2 - lon1)
  264. assert abs(diff - 30.0) < 0.01
  265. def test_whole_sign_each_sign_is_one_house(self):
  266. houses = calculate_houses(6.0, 45.0, house_system="whole_sign")
  267. for i, h in enumerate(houses):
  268. sign_index = int(h["absolute_lon"] // 30)
  269. expected_sign = ZODIAC_SIGNS[sign_index]
  270. assert h["sign"] == expected_sign
  271. assert h["degree"] == 0.0
  272. def test_unsupported_system_raises(self):
  273. with pytest.raises(ValueError, match="Unsupported house system"):
  274. calculate_houses(6.0, 45.0, house_system="campanus")
  275. def test_placidus_asc_is_house_1(self):
  276. houses = calculate_houses(6.0, 45.0, house_system="placidus")
  277. h1 = [h for h in houses if h["house"] == 1][0]
  278. assert h1["absolute_lon"] is not None
  279. def test_placidus_mc_is_house_10(self):
  280. houses = calculate_houses(6.0, 45.0, house_system="placidus")
  281. h10 = [h for h in houses if h["house"] == 10][0]
  282. assert h10["absolute_lon"] is not None
  283. # ── Angles ──────────────────────────────────────────────────────────
  284. class TestCalculateAngles:
  285. def test_all_angles_present(self):
  286. angles = calculate_angles(6.0, 45.0)
  287. assert "ascendant" in angles
  288. assert "midheaven" in angles
  289. assert "descendant" in angles
  290. assert "imum_coeli" in angles
  291. def test_asc_opposite_dsc(self):
  292. angles = calculate_angles(6.0, 45.0)
  293. asc_lon = angles["ascendant"]["absolute_lon"]
  294. dsc_lon = angles["descendant"]["absolute_lon"]
  295. diff = abs(asc_lon - dsc_lon)
  296. assert abs(diff - 180.0) < 1.0 # within 1 degree
  297. def test_mc_opposite_ic(self):
  298. angles = calculate_angles(6.0, 45.0)
  299. mc_lon = angles["midheaven"]["absolute_lon"]
  300. ic_lon = angles["imum_coeli"]["absolute_lon"]
  301. diff = abs(mc_lon - ic_lon)
  302. assert abs(diff - 180.0) < 1.0
  303. def test_angles_have_sign_and_degree(self):
  304. angles = calculate_angles(6.0, 45.0)
  305. for key in ("ascendant", "midheaven", "descendant", "imum_coeli"):
  306. assert "sign" in angles[key]
  307. assert "degree" in angles[key]
  308. assert "absolute_lon" in angles[key]
  309. # ── House Placement ─────────────────────────────────────────────────
  310. class TestGetHousePlacement:
  311. def test_simple_equal_houses(self):
  312. # At LST=6h, lat=0, ASC is near 90° (Cancer). House 1 = 90-120.
  313. houses = calculate_houses(6.0, 0.0, house_system="equal")
  314. asc_lon = houses[0]["absolute_lon"]
  315. # Place a point 15° after the ASC -- should be in house 1
  316. test_lon = normalize_degrees(asc_lon + 15.0)
  317. assert get_house_placement(test_lon, houses) == 1
  318. def test_350_in_last_house(self):
  319. # At LST=0, lat=0, whole sign ASC = 90° (Cancer).
  320. # Houses: 1=Cn(90), 2=Le(120), 3=Vi(150), 4=Li(180), 5=Sc(210),
  321. # 6=Sg(240), 7=Cap(270), 8=Aq(300), 9=Pi(330), 10=Ar(0/360)...
  322. # House 9 = Pisces (330-360). 350° is in house 9.
  323. houses = calculate_houses(0.0, 0.0, house_system="whole_sign")
  324. assert get_house_placement(350.0, houses) == 9
  325. def test_0_degrees(self):
  326. houses = calculate_houses(0.0, 0.0, house_system="equal")
  327. result = get_house_placement(0.0, houses)
  328. assert 1 <= result <= 12
  329. def test_180_degrees(self):
  330. houses = calculate_houses(0.0, 0.0, house_system="equal")
  331. result = get_house_placement(180.0, houses)
  332. assert 1 <= result <= 12
  333. # ── Retrograde ──────────────────────────────────────────────────────
  334. class TestIsRetrograde:
  335. def test_positive_speed_direct(self):
  336. assert is_retrograde(1.0) is False
  337. def test_negative_speed_retrograde(self):
  338. assert is_retrograde(-0.5) is True
  339. def test_zero_speed_not_retrograde(self):
  340. assert is_retrograde(0.0) is False
  341. def test_none_speed_not_retrograde(self):
  342. assert is_retrograde(None) is False
  343. # ── Composite Chart ─────────────────────────────────────────────────
  344. class TestComputeCompositeChart:
  345. def test_basic_midpoint(self):
  346. b1 = [{"name": "sun", "lon": 10.0}]
  347. b2 = [{"name": "sun", "lon": 20.0}]
  348. composite = compute_composite_chart(b1, b2)
  349. assert len(composite) == 1
  350. assert abs(composite[0]["lon"] - 15.0) < 0.01
  351. def test_wraparound_midpoint(self):
  352. b1 = [{"name": "sun", "lon": 350.0}]
  353. b2 = [{"name": "sun", "lon": 10.0}]
  354. composite = compute_composite_chart(b1, b2)
  355. # Midpoint of 350 and 10 should be 0 (or 360)
  356. assert abs(composite[0]["lon"] - 0.0) < 1.0 or abs(composite[0]["lon"] - 360.0) < 1.0
  357. def test_only_common_bodies(self):
  358. b1 = [{"name": "sun", "lon": 10.0}, {"name": "mars", "lon": 20.0}]
  359. b2 = [{"name": "sun", "lon": 30.0}, {"name": "venus", "lon": 40.0}]
  360. composite = compute_composite_chart(b1, b2)
  361. assert len(composite) == 1
  362. assert composite[0]["name"] == "sun"
  363. def test_empty_when_no_common(self):
  364. b1 = [{"name": "sun", "lon": 10.0}]
  365. b2 = [{"name": "moon", "lon": 20.0}]
  366. composite = compute_composite_chart(b1, b2)
  367. assert len(composite) == 0
  368. # ── Default Orbs ────────────────────────────────────────────────────
  369. class TestDefaultOrbs:
  370. def test_conjunction_orb(self):
  371. assert DEFAULT_ORBS["conjunction"] == 8.0
  372. def test_sextile_orb(self):
  373. assert DEFAULT_ORBS["sextile"] == 6.0
  374. def test_square_orb(self):
  375. assert DEFAULT_ORBS["square"] == 8.0
  376. def test_trine_orb(self):
  377. assert DEFAULT_ORBS["trine"] == 8.0
  378. def test_opposition_orb(self):
  379. assert DEFAULT_ORBS["opposition"] == 8.0
  380. # ── Supported House Systems ─────────────────────────────────────────
  381. class TestSupportedHouseSystems:
  382. def test_placidus_supported(self):
  383. assert "placidus" in SUPPORTED_HOUSE_SYSTEMS
  384. def test_equal_supported(self):
  385. assert "equal" in SUPPORTED_HOUSE_SYSTEMS
  386. def test_whole_sign_supported(self):
  387. assert "whole_sign" in SUPPORTED_HOUSE_SYSTEMS
  388. # ── Sign Ruler Lookup ────────────────────────────────────────────────
  389. class TestSignRulers:
  390. def test_aries_ruler(self):
  391. assert SIGN_RULERS["Aries"] == "mars"
  392. def test_scorpius_ruler_modern(self):
  393. assert SIGN_RULERS["Scorpius"] == "pluto"
  394. def test_scorpius_ruler_traditional(self):
  395. assert SIGN_RULERS_TRADITIONAL["Scorpius"] == "mars"
  396. def test_aquarius_ruler_modern(self):
  397. assert SIGN_RULERS["Aquarius"] == "uranus"
  398. def test_aquarius_ruler_traditional(self):
  399. assert SIGN_RULERS_TRADITIONAL["Aquarius"] == "saturn"
  400. def test_all_signs_have_rulers(self):
  401. for sign in ZODIAC_SIGNS:
  402. assert sign in SIGN_RULERS
  403. assert sign in SIGN_RULERS_TRADITIONAL
  404. class TestSignElements:
  405. def test_fire_signs(self):
  406. assert set(ELEMENT_SIGNS["fire"]) == {"Aries", "Leo", "Sagittarius"}
  407. def test_earth_signs(self):
  408. assert set(ELEMENT_SIGNS["earth"]) == {"Taurus", "Virgo", "Capricornus"}
  409. def test_air_signs(self):
  410. assert set(ELEMENT_SIGNS["air"]) == {"Gemini", "Libra", "Aquarius"}
  411. def test_water_signs(self):
  412. assert set(ELEMENT_SIGNS["water"]) == {"Cancer", "Scorpius", "Pisces"}
  413. def test_all_signs_have_elements(self):
  414. for sign in ZODIAC_SIGNS:
  415. assert sign in SIGN_ELEMENTS
  416. class TestSignModalities:
  417. def test_cardinal_signs(self):
  418. assert set(MODALITY_SIGNS["cardinal"]) == {"Aries", "Cancer", "Libra", "Capricornus"}
  419. def test_fixed_signs(self):
  420. assert set(MODALITY_SIGNS["fixed"]) == {"Taurus", "Leo", "Scorpius", "Aquarius"}
  421. def test_mutable_signs(self):
  422. assert set(MODALITY_SIGNS["mutable"]) == {"Gemini", "Virgo", "Sagittarius", "Pisces"}
  423. def test_all_signs_have_modalities(self):
  424. for sign in ZODIAC_SIGNS:
  425. assert sign in SIGN_MODALITIES
  426. # ── Helper Constants ─────────────────────────────────────────────────
  427. class TestHelperConstants:
  428. def test_angular_houses(self):
  429. assert ANGULAR_HOUSES == {1, 4, 7, 10}
  430. def test_succedent_houses(self):
  431. assert SUCCEDENT_HOUSES == {2, 5, 8, 11}
  432. def test_cadent_houses(self):
  433. assert CADENT_HOUSES == {3, 6, 9, 12}
  434. def test_personal_planets(self):
  435. assert PERSONAL_PLANETS == {"sun", "moon", "mercury", "venus", "mars"}
  436. def test_hard_aspects(self):
  437. assert HARD_ASPECTS == {"conjunction", "square", "opposition"}
  438. # ── Sample Planet Fixtures ───────────────────────────────────────────
  439. def _make_planets() -> list[dict[str, Any]]:
  440. """Create a realistic 12-planet list for testing."""
  441. return [
  442. {"body": "sun", "sign": "Leo", "house": 5, "absolute_lon": 135.0, "degree_within_sign": 15.0, "retrograde": False},
  443. {"body": "moon", "sign": "Cancer", "house": 4, "absolute_lon": 105.0, "degree_within_sign": 15.0, "retrograde": False},
  444. {"body": "mercury", "sign": "Leo", "house": 5, "absolute_lon": 138.0, "degree_within_sign": 18.0, "retrograde": False},
  445. {"body": "venus", "sign": "Virgo", "house": 6, "absolute_lon": 155.0, "degree_within_sign": 5.0, "retrograde": False},
  446. {"body": "mars", "sign": "Aries", "house": 1, "absolute_lon": 15.0, "degree_within_sign": 15.0, "retrograde": False},
  447. {"body": "jupiter", "sign": "Sagittarius", "house": 9, "absolute_lon": 255.0, "degree_within_sign": 15.0, "retrograde": True},
  448. {"body": "saturn", "sign": "Capricornus", "house": 10, "absolute_lon": 285.0, "degree_within_sign": 15.0, "retrograde": False},
  449. {"body": "uranus", "sign": "Aquarius", "house": 11, "absolute_lon": 315.0, "degree_within_sign": 15.0, "retrograde": False},
  450. {"body": "neptune", "sign": "Pisces", "house": 12, "absolute_lon": 345.0, "degree_within_sign": 15.0, "retrograde": False},
  451. {"body": "pluto", "sign": "Scorpius", "house": 8, "absolute_lon": 225.0, "degree_within_sign": 15.0, "retrograde": True},
  452. {"body": "chiron", "sign": "Aries", "house": 1, "absolute_lon": 12.0, "degree_within_sign": 12.0, "retrograde": False},
  453. {"body": "true_node", "sign": "Gemini", "house": 3, "absolute_lon": 75.0, "degree_within_sign": 15.0, "retrograde": False},
  454. ]
  455. def _make_stellium_planets() -> list[dict[str, Any]]:
  456. """Create a planet list with a stellium in Leo (sign) and house 5."""
  457. return [
  458. {"body": "sun", "sign": "Leo", "house": 5, "absolute_lon": 130.0, "degree_within_sign": 10.0, "retrograde": False},
  459. {"body": "moon", "sign": "Leo", "house": 5, "absolute_lon": 135.0, "degree_within_sign": 15.0, "retrograde": False},
  460. {"body": "mercury", "sign": "Leo", "house": 5, "absolute_lon": 140.0, "degree_within_sign": 20.0, "retrograde": False},
  461. {"body": "venus", "sign": "Virgo", "house": 6, "absolute_lon": 155.0, "degree_within_sign": 5.0, "retrograde": False},
  462. {"body": "mars", "sign": "Aries", "house": 1, "absolute_lon": 15.0, "degree_within_sign": 15.0, "retrograde": False},
  463. {"body": "jupiter", "sign": "Sagittarius", "house": 9, "absolute_lon": 255.0, "degree_within_sign": 15.0, "retrograde": False},
  464. {"body": "saturn", "sign": "Capricornus", "house": 10, "absolute_lon": 285.0, "degree_within_sign": 15.0, "retrograde": False},
  465. {"body": "uranus", "sign": "Aquarius", "house": 11, "absolute_lon": 315.0, "degree_within_sign": 15.0, "retrograde": False},
  466. {"body": "neptune", "sign": "Pisces", "house": 12, "absolute_lon": 345.0, "degree_within_sign": 15.0, "retrograde": False},
  467. {"body": "pluto", "sign": "Scorpius", "house": 8, "absolute_lon": 225.0, "degree_within_sign": 15.0, "retrograde": False},
  468. {"body": "chiron", "sign": "Aries", "house": 1, "absolute_lon": 12.0, "degree_within_sign": 12.0, "retrograde": False},
  469. {"body": "true_node", "sign": "Gemini", "house": 3, "absolute_lon": 75.0, "degree_within_sign": 15.0, "retrograde": False},
  470. ]
  471. # ── Element Balance ──────────────────────────────────────────────────
  472. class TestGetElementBalance:
  473. def test_basic_counts(self):
  474. planets = _make_planets()
  475. result = get_element_balance(planets)
  476. assert result["total"] == 12
  477. assert "counts" in result
  478. assert "percentages" in result
  479. def test_all_elements_present(self):
  480. planets = _make_planets()
  481. result = get_element_balance(planets)
  482. # Leo(fire), Cancer(water), Leo(fire), Virgo(earth), Aries(fire),
  483. # Sagittarius(fire), Capricornus(earth), Aquarius(air), Pisces(water),
  484. # Scorpius(water), Aries(fire), Gemini(air)
  485. assert result["counts"]["fire"] == 5 # sun, mercury, mars, jupiter, chiron
  486. assert result["counts"]["earth"] == 2 # venus, saturn
  487. assert result["counts"]["air"] == 2 # uranus, true_node
  488. assert result["counts"]["water"] == 3 # moon, neptune, pluto
  489. def test_percentages_sum_to_100(self):
  490. planets = _make_planets()
  491. result = get_element_balance(planets)
  492. pct_sum = sum(result["percentages"].values())
  493. assert abs(pct_sum - 100.0) < 1.0
  494. def test_empty_planets(self):
  495. result = get_element_balance([])
  496. assert result["total"] == 0
  497. assert all(v == 0 for v in result["counts"].values())
  498. # ── Modality Balance ─────────────────────────────────────────────────
  499. class TestGetModalityBalance:
  500. def test_basic_counts(self):
  501. planets = _make_planets()
  502. result = get_modality_balance(planets)
  503. assert result["total"] == 12
  504. assert "counts" in result
  505. assert "percentages" in result
  506. def test_modalities_present(self):
  507. planets = _make_planets()
  508. result = get_modality_balance(planets)
  509. # Leo(fixed), Cancer(cardinal), Leo(fixed), Virgo(mutable), Aries(cardinal),
  510. # Sagittarius(mutable), Capricornus(cardinal), Aquarius(fixed), Pisces(mutable),
  511. # Scorpius(fixed), Aries(cardinal), Gemini(mutable)
  512. assert result["counts"]["cardinal"] == 4 # moon, mars, saturn, chiron
  513. assert result["counts"]["fixed"] == 4 # sun, mercury, uranus, pluto
  514. assert result["counts"]["mutable"] == 4 # venus, jupiter, neptune, true_node
  515. def test_empty_planets(self):
  516. result = get_modality_balance([])
  517. assert result["total"] == 0
  518. # ── Hemisphere Emphasis ──────────────────────────────────────────────
  519. class TestGetHemisphereEmphasis:
  520. def test_basic_counts(self):
  521. planets = _make_planets()
  522. result = get_hemisphere_emphasis(planets)
  523. assert sum(result.values()) == 24 # 12 planets * 2 (each counted in upper/lower AND east/west)
  524. def test_upper_lower_sum(self):
  525. planets = _make_planets()
  526. result = get_hemisphere_emphasis(planets)
  527. assert result["upper"] + result["lower"] == 12
  528. def test_east_west_sum(self):
  529. planets = _make_planets()
  530. result = get_hemisphere_emphasis(planets)
  531. assert result["east"] + result["west"] == 12
  532. # ── Stellium Detection ───────────────────────────────────────────────
  533. class TestDetectStelliums:
  534. def test_no_stellium(self):
  535. planets = _make_planets()
  536. result = detect_stelliums(planets)
  537. assert len(result) == 0
  538. def test_sign_stellium(self):
  539. planets = _make_stellium_planets()
  540. result = detect_stelliums(planets)
  541. sign_stelliums = [s for s in result if s["type"] == "sign"]
  542. assert len(sign_stelliums) >= 1
  543. leo_stellium = [s for s in sign_stelliums if s["key"] == "Leo"]
  544. assert len(leo_stellium) == 1
  545. assert set(leo_stellium[0]["planets"]) == {"sun", "moon", "mercury"}
  546. def test_house_stellium(self):
  547. planets = _make_stellium_planets()
  548. result = detect_stelliums(planets)
  549. house_stelliums = [s for s in result if s["type"] == "house"]
  550. assert len(house_stelliums) >= 1
  551. h5_stellium = [s for s in house_stelliums if s["key"] == "5"]
  552. assert len(h5_stellium) == 1
  553. assert set(h5_stellium[0]["planets"]) == {"sun", "moon", "mercury"}
  554. # ── Empty Houses ─────────────────────────────────────────────────────
  555. class TestGetEmptyHouses:
  556. def test_some_empty(self):
  557. planets = _make_planets()
  558. result = get_empty_houses(planets)
  559. occupied = {p["house"] for p in planets}
  560. expected = sorted(h for h in range(1, 13) if h not in occupied)
  561. assert result == expected
  562. def test_all_occupied(self):
  563. # Create planets in all 12 houses
  564. planets = [{"body": f"p{i}", "house": i} for i in range(1, 13)]
  565. result = get_empty_houses(planets)
  566. assert result == []
  567. # ── Chart Ruler ──────────────────────────────────────────────────────
  568. class TestGetChartRuler:
  569. def test_aries_asc_ruler_is_mars(self):
  570. planets = _make_planets()
  571. result = get_chart_ruler("Aries", planets)
  572. assert result is not None
  573. assert result["body"] == "mars"
  574. assert result["sign"] == "Aries"
  575. def test_leo_asc_ruler_is_sun(self):
  576. planets = _make_planets()
  577. result = get_chart_ruler("Leo", planets)
  578. assert result is not None
  579. assert result["body"] == "sun"
  580. def test_scorpius_asc_modern(self):
  581. planets = _make_planets()
  582. result = get_chart_ruler("Scorpius", planets)
  583. assert result is not None
  584. assert result["body"] == "pluto"
  585. def test_scorpius_asc_traditional(self):
  586. planets = _make_planets()
  587. result = get_chart_ruler("Scorpius", planets, traditional=True)
  588. assert result is not None
  589. assert result["body"] == "mars"
  590. def test_ruler_not_found(self):
  591. planets = [{"body": "sun", "sign": "Aries", "house": 1}]
  592. result = get_chart_ruler("Aries", planets)
  593. assert result is None
  594. def test_ruler_has_retrograde(self):
  595. planets = _make_planets()
  596. result = get_chart_ruler("Sagittarius", planets)
  597. assert result is not None
  598. assert result["body"] == "jupiter"
  599. assert result["retrograde"] is True
  600. # ── House Rulers ─────────────────────────────────────────────────────
  601. class TestGetHouseRulers:
  602. def test_returns_12_entries(self):
  603. planets = _make_planets()
  604. houses = calculate_houses(6.0, 45.0, "placidus")
  605. result = get_house_rulers(houses, planets)
  606. assert len(result) == 12
  607. def test_each_has_required_keys(self):
  608. planets = _make_planets()
  609. houses = calculate_houses(6.0, 45.0, "placidus")
  610. result = get_house_rulers(houses, planets)
  611. for entry in result:
  612. assert "house" in entry
  613. assert "cusp_sign" in entry
  614. assert "ruler" in entry
  615. assert "ruler_sign" in entry
  616. assert "ruler_house" in entry
  617. assert "ruler_retrograde" in entry
  618. # ── Group Planets by House ───────────────────────────────────────────
  619. class TestGroupPlanetsByHouse:
  620. def test_grouping(self):
  621. planets = _make_planets()
  622. result = group_planets_by_house(planets)
  623. assert 5 in result # sun, mercury in house 5
  624. assert set(result[5]) == {"sun", "mercury"}
  625. def test_all_planets_grouped(self):
  626. planets = _make_planets()
  627. result = group_planets_by_house(planets)
  628. total = sum(len(v) for v in result.values())
  629. assert total == 12
  630. # ── Group Planets by Sign ────────────────────────────────────────────
  631. class TestGroupPlanetsBySign:
  632. def test_grouping(self):
  633. planets = _make_planets()
  634. result = group_planets_by_sign(planets)
  635. assert "Leo" in result
  636. assert set(result["Leo"]) == {"sun", "mercury"}
  637. def test_all_planets_grouped(self):
  638. planets = _make_planets()
  639. result = group_planets_by_sign(planets)
  640. total = sum(len(v) for v in result.values())
  641. assert total == 12
  642. # ── House Type Counts ────────────────────────────────────────────────
  643. class TestGetHouseTypeCounts:
  644. def test_counts(self):
  645. planets = _make_planets()
  646. result = get_house_type_counts(planets)
  647. assert result["angular"] + result["succedent"] + result["cadent"] == 12
  648. def test_angular_count(self):
  649. planets = _make_planets()
  650. result = get_house_type_counts(planets)
  651. # mars in h1, chiron in h1, moon in h4, saturn in h10 = 4 angular
  652. assert result["angular"] == 4
  653. # ── Retrograde Planets ───────────────────────────────────────────────
  654. class TestGetRetrogradePlanets:
  655. def test_retrograde_list(self):
  656. planets = _make_planets()
  657. result = get_retrograde_planets(planets)
  658. names = [p["body"] for p in result]
  659. assert "jupiter" in names
  660. assert "pluto" in names
  661. def test_no_retrogrades(self):
  662. planets = [{"body": "sun", "retrograde": False}]
  663. result = get_retrograde_planets(planets)
  664. assert len(result) == 0
  665. def test_includes_sign_and_house(self):
  666. planets = _make_planets()
  667. result = get_retrograde_planets(planets)
  668. jupiter = [p for p in result if p["body"] == "jupiter"][0]
  669. assert jupiter["sign"] == "Sagittarius"
  670. assert jupiter["house"] == 9
  671. # ── Nodal Axis ───────────────────────────────────────────────────────
  672. class TestGetNodalAxis:
  673. def test_north_node_found(self):
  674. planets = _make_planets()
  675. result = get_nodal_axis(planets)
  676. assert result["north_node"] is not None
  677. assert result["north_node"]["sign"] == "Gemini"
  678. def test_south_node_is_opposite(self):
  679. planets = _make_planets()
  680. result = get_nodal_axis(planets)
  681. assert result["south_node"] is not None
  682. # Gemini opposite = Sagittarius
  683. assert result["south_node"]["sign"] == "Sagittarius"
  684. def test_with_houses(self):
  685. planets = _make_planets()
  686. houses = calculate_houses(6.0, 45.0, "placidus")
  687. result = get_nodal_axis(planets, houses)
  688. assert result["north_node"]["house"] is not None
  689. assert result["south_node"]["house"] is not None
  690. def test_no_node(self):
  691. planets = [{"body": "sun", "sign": "Aries"}]
  692. result = get_nodal_axis(planets)
  693. assert result["north_node"] is None
  694. assert result["south_node"] is None
  695. # ── Saturn Info ──────────────────────────────────────────────────────
  696. class TestGetSaturnInfo:
  697. def test_saturn_found(self):
  698. planets = _make_planets()
  699. result = get_saturn_info(planets)
  700. assert result is not None
  701. assert result["body"] == "saturn"
  702. assert result["sign"] == "Capricornus"
  703. assert result["house"] == 10
  704. def test_saturn_retrograde(self):
  705. planets = _make_planets()
  706. result = get_saturn_info(planets)
  707. assert result["retrograde"] is False
  708. def test_no_saturn(self):
  709. planets = [{"body": "sun", "sign": "Aries"}]
  710. result = get_saturn_info(planets)
  711. assert result is None
  712. # ── Pluto Polarity Point ─────────────────────────────────────────────
  713. class TestGetPlutoPolarityPoint:
  714. def test_ppp_is_opposite(self):
  715. planets = _make_planets()
  716. result = get_pluto_polarity_point(planets)
  717. assert result is not None
  718. # Pluto at 225° (Scorpius 15°), PPP at 45° (Taurus 15°)
  719. assert result["sign"] == "Taurus"
  720. def test_ppp_with_houses(self):
  721. planets = _make_planets()
  722. houses = calculate_houses(6.0, 45.0, "placidus")
  723. result = get_pluto_polarity_point(planets, houses)
  724. assert result["house"] is not None
  725. def test_no_pluto(self):
  726. planets = [{"body": "sun", "sign": "Aries"}]
  727. result = get_pluto_polarity_point(planets)
  728. assert result is None
  729. # ── Part of Fortune ──────────────────────────────────────────────────
  730. class TestGetPartOfFortune:
  731. def test_basic_calculation(self):
  732. # ASC at 90°, Sun at 135°, Moon at 105°
  733. # PoF = 90 + 105 - 135 = 60° (Gemini)
  734. result = get_part_of_fortune(90.0, 135.0, 105.0)
  735. assert result["sign"] == "Gemini"
  736. def test_wraparound(self):
  737. # ASC at 10°, Sun at 350°, Moon at 350°
  738. # PoF = 10 + 350 - 350 = 10° (Aries)
  739. result = get_part_of_fortune(10.0, 350.0, 350.0)
  740. assert result["sign"] == "Aries"
  741. def test_with_houses(self):
  742. result = get_part_of_fortune(90.0, 135.0, 105.0, houses=calculate_houses(6.0, 45.0, "placidus"))
  743. assert result["house"] is not None
  744. # ── 12th House Analysis ──────────────────────────────────────────────
  745. class TestGetTwelfthHouseAnalysis:
  746. def test_cusp_sign(self):
  747. planets = _make_planets()
  748. houses = calculate_houses(6.0, 45.0, "placidus")
  749. result = get_twelfth_house_analysis(houses, planets)
  750. assert result["cusp_sign"] is not None
  751. def test_planets_in_12th(self):
  752. planets = _make_planets()
  753. houses = calculate_houses(6.0, 45.0, "placidus")
  754. result = get_twelfth_house_analysis(houses, planets)
  755. # neptune is in house 12 in our test data
  756. names = [p["body"] for p in result["planets"]]
  757. assert "neptune" in names
  758. def test_ruler(self):
  759. planets = _make_planets()
  760. houses = calculate_houses(6.0, 45.0, "placidus")
  761. result = get_twelfth_house_analysis(houses, planets)
  762. assert result["ruler"] is not None
  763. assert result["ruler"]["ruler"] is not None
  764. # ── Aspect Filtering ─────────────────────────────────────────────────
  765. class TestFilterAspectsByPlanets:
  766. def test_filter_to_planets(self):
  767. aspects = [
  768. {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0},
  769. {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 3.0},
  770. {"body1": "venus", "body2": "mars", "aspect": "trine", "orb": 1.0},
  771. ]
  772. result = filter_aspects_by_planets(aspects, {"sun"})
  773. assert len(result) == 2
  774. def test_filter_with_aspect_types(self):
  775. aspects = [
  776. {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0},
  777. {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 3.0},
  778. {"body1": "sun", "body2": "jupiter", "aspect": "trine", "orb": 1.0},
  779. ]
  780. result = filter_aspects_by_planets(aspects, {"sun"}, {"conjunction", "square"})
  781. assert len(result) == 2
  782. def test_strips_prefixes(self):
  783. aspects = [
  784. {"body1": "transit_saturn", "body2": "natal_true_node", "aspect": "conjunction", "orb": 1.0},
  785. {"body1": "p1_saturn", "body2": "p2_sun", "aspect": "opposition", "orb": 2.0},
  786. ]
  787. result = filter_aspects_by_planets(aspects, {"saturn"})
  788. assert len(result) == 2
  789. def test_no_match(self):
  790. aspects = [
  791. {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0},
  792. ]
  793. result = filter_aspects_by_planets(aspects, {"pluto"})
  794. assert len(result) == 0
  795. class TestGetNatalAspectsToPlanets:
  796. def test_sorted_by_orb(self):
  797. aspects = [
  798. {"body1": "sun", "body2": "true_node", "aspect": "conjunction", "orb": 3.0},
  799. {"body1": "moon", "body2": "true_node", "aspect": "square", "orb": 1.0},
  800. {"body1": "mars", "body2": "true_node", "aspect": "opposition", "orb": 2.0},
  801. ]
  802. result = get_natal_aspects_to_planets(aspects, {"true_node"})
  803. orbs = [a["orb"] for a in result]
  804. assert orbs == sorted(orbs)
  805. def test_filter_to_hard_aspects(self):
  806. aspects = [
  807. {"body1": "sun", "body2": "true_node", "aspect": "conjunction", "orb": 1.0},
  808. {"body1": "moon", "body2": "true_node", "aspect": "trine", "orb": 2.0},
  809. ]
  810. result = get_natal_aspects_to_planets(aspects, {"true_node"}, {"conjunction", "square", "opposition"})
  811. assert len(result) == 1
  812. assert result[0]["aspect"] == "conjunction"
  813. # ── Aspect Pattern Detection ─────────────────────────────────────────
  814. class TestDetectAspectPatterns:
  815. def test_no_patterns(self):
  816. """Random aspects with no patterns."""
  817. planets = _make_planets()
  818. aspects = [
  819. {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0},
  820. {"body1": "venus", "body2": "mars", "aspect": "trine", "orb": 3.0},
  821. ]
  822. result = detect_aspect_patterns(planets, aspects)
  823. assert len(result) == 0
  824. def test_t_square_detection(self):
  825. """Sun-Moon opposition, both square Mars = T-square with Mars apex."""
  826. planets = [
  827. {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 0.0},
  828. {"body": "moon", "sign": "Libra", "house": 7, "absolute_lon": 180.0},
  829. {"body": "mars", "sign": "Capricornus", "house": 10, "absolute_lon": 270.0},
  830. ]
  831. aspects = [
  832. {"body1": "sun", "body2": "moon", "aspect": "opposition", "orb": 0.0},
  833. {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 0.0},
  834. {"body1": "moon", "body2": "mars", "aspect": "square", "orb": 0.0},
  835. ]
  836. result = detect_aspect_patterns(planets, aspects)
  837. t_squares = [p for p in result if p["type"] == "T-square"]
  838. assert len(t_squares) == 1
  839. assert t_squares[0]["apex"] == "mars"
  840. assert set(t_squares[0]["planets"]) == {"sun", "moon", "mars"}
  841. assert t_squares[0]["modality"] == "cardinal"
  842. def test_grand_trine_detection(self):
  843. """Three planets in trine, same element."""
  844. planets = [
  845. {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 15.0},
  846. {"body": "jupiter", "sign": "Leo", "house": 5, "absolute_lon": 135.0},
  847. {"body": "saturn", "sign": "Sagittarius", "house": 9, "absolute_lon": 255.0},
  848. ]
  849. aspects = [
  850. {"body1": "sun", "body2": "jupiter", "aspect": "trine", "orb": 0.0},
  851. {"body1": "jupiter", "body2": "saturn", "aspect": "trine", "orb": 0.0},
  852. {"body1": "sun", "body2": "saturn", "aspect": "trine", "orb": 0.0},
  853. ]
  854. result = detect_aspect_patterns(planets, aspects)
  855. gt = [p for p in result if p["type"] == "Grand Trine"]
  856. assert len(gt) == 1
  857. assert set(gt[0]["planets"]) == {"sun", "jupiter", "saturn"}
  858. assert gt[0]["element"] == "fire"
  859. def test_yod_detection(self):
  860. """Two planets in sextile, both quincunx a third."""
  861. planets = [
  862. {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 15.0},
  863. {"body": "jupiter", "sign": "Gemini", "house": 3, "absolute_lon": 75.0},
  864. {"body": "saturn", "sign": "Scorpius", "house": 8, "absolute_lon": 225.0},
  865. ]
  866. aspects = [
  867. {"body1": "sun", "body2": "jupiter", "aspect": "sextile", "orb": 0.0},
  868. {"body1": "sun", "body2": "saturn", "aspect": "quincunx", "orb": 0.0},
  869. {"body1": "jupiter", "body2": "saturn", "aspect": "quincunx", "orb": 0.0},
  870. ]
  871. result = detect_aspect_patterns(planets, aspects)
  872. yods = [p for p in result if p["type"] == "Yod"]
  873. assert len(yods) == 1
  874. assert yods[0]["apex"] == "saturn"
  875. assert set(yods[0]["planets"]) == {"sun", "jupiter", "saturn"}
  876. def test_orb_limit_filters_patterns(self):
  877. """Patterns with orbs exceeding the limit should not be detected."""
  878. planets = [
  879. {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 0.0},
  880. {"body": "moon", "sign": "Libra", "house": 7, "absolute_lon": 180.0},
  881. {"body": "mars", "sign": "Capricornus", "house": 10, "absolute_lon": 270.0},
  882. ]
  883. aspects = [
  884. {"body1": "sun", "body2": "moon", "aspect": "opposition", "orb": 0.0},
  885. {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 10.0}, # too wide
  886. {"body1": "moon", "body2": "mars", "aspect": "square", "orb": 10.0}, # too wide
  887. ]
  888. result = detect_aspect_patterns(planets, aspects, orb_limit=8.0)
  889. t_squares = [p for p in result if p["type"] == "T-square"]
  890. assert len(t_squares) == 0
  891. # ── Chart Shape Detection ────────────────────────────────────────────
  892. class TestDetectChartShape:
  893. def test_bundle(self):
  894. """All planets within 120°."""
  895. planets = [
  896. {"body": "sun", "absolute_lon": 10.0},
  897. {"body": "moon", "absolute_lon": 30.0},
  898. {"body": "mars", "absolute_lon": 80.0},
  899. {"body": "venus", "absolute_lon": 100.0},
  900. ]
  901. result = detect_chart_shape(planets)
  902. assert result["shape"] == "bundle"
  903. def test_bowl(self):
  904. """All planets within 180°."""
  905. planets = [
  906. {"body": "sun", "absolute_lon": 0.0},
  907. {"body": "moon", "absolute_lon": 45.0},
  908. {"body": "mars", "absolute_lon": 90.0},
  909. {"body": "venus", "absolute_lon": 150.0},
  910. ]
  911. result = detect_chart_shape(planets)
  912. assert result["shape"] == "bowl"
  913. def test_splash(self):
  914. """Planets spread around the full chart."""
  915. planets = [
  916. {"body": "sun", "absolute_lon": 0.0},
  917. {"body": "moon", "absolute_lon": 120.0},
  918. {"body": "mars", "absolute_lon": 240.0},
  919. ]
  920. result = detect_chart_shape(planets)
  921. assert result["shape"] == "splash"
  922. def test_single_planet(self):
  923. result = detect_chart_shape([{"body": "sun", "absolute_lon": 0.0}])
  924. assert result["shape"] == "unknown"
  925. def test_empty(self):
  926. result = detect_chart_shape([])
  927. assert result["shape"] == "unknown"
  928. def test_largest_gap_reported(self):
  929. planets = [
  930. {"body": "sun", "absolute_lon": 0.0},
  931. {"body": "moon", "absolute_lon": 10.0},
  932. {"body": "mars", "absolute_lon": 20.0},
  933. ]
  934. result = detect_chart_shape(planets)
  935. assert result["largest_gap"] > 0
  936. assert result["occupied_arc"] > 0