test_astrology.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  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. import math
  14. import pytest
  15. from src.astro_mcp.astrology import (
  16. DEFAULT_ORBS,
  17. SUPPORTED_HOUSE_SYSTEMS,
  18. ZODIAC_SIGNS,
  19. calculate_angles,
  20. calculate_houses,
  21. compute_aspects,
  22. compute_composite_chart,
  23. ecliptic_to_zodiac,
  24. get_house_placement,
  25. is_retrograde,
  26. normalize_degrees,
  27. zodiac_to_ecliptic,
  28. )
  29. # ── normalize_degrees ────────────────────────────────────────────────
  30. class TestNormalizeDegrees:
  31. def test_zero(self):
  32. assert normalize_degrees(0.0) == 0.0
  33. def test_positive(self):
  34. assert normalize_degrees(45.0) == 45.0
  35. def test_360_wraps_to_zero(self):
  36. assert normalize_degrees(360.0) == 0.0
  37. def test_over_360(self):
  38. assert normalize_degrees(370.0) == 10.0
  39. def test_negative(self):
  40. assert normalize_degrees(-10.0) == 350.0
  41. def test_large_negative(self):
  42. assert normalize_degrees(-370.0) == 350.0
  43. def test_720(self):
  44. assert normalize_degrees(720.0) == 0.0
  45. # ── ecliptic_to_zodiac ──────────────────────────────────────────────
  46. class TestEclipticToZodiac:
  47. def test_aries_start(self):
  48. result = ecliptic_to_zodiac(0.0)
  49. assert result["sign"] == "Aries"
  50. assert result["degree"] == 0.0
  51. def test_aries_mid(self):
  52. result = ecliptic_to_zodiac(15.0)
  53. assert result["sign"] == "Aries"
  54. assert result["degree"] == 15.0
  55. def test_taurus_start(self):
  56. result = ecliptic_to_zodiac(30.0)
  57. assert result["sign"] == "Taurus"
  58. assert result["degree"] == 0.0
  59. def test_pisces_end(self):
  60. result = ecliptic_to_zodiac(359.0)
  61. assert result["sign"] == "Pisces"
  62. assert abs(result["degree"] - 29.0) < 0.001
  63. def test_all_signs(self):
  64. for i, sign in enumerate(ZODIAC_SIGNS):
  65. lon = i * 30 + 15 # middle of each sign
  66. result = ecliptic_to_zodiac(lon)
  67. assert result["sign"] == sign
  68. assert abs(result["degree"] - 15.0) < 0.001
  69. def test_abbreviation(self):
  70. result = ecliptic_to_zodiac(0.0)
  71. assert result["abbreviation"] == "Ar"
  72. def test_absolute_lon_preserved(self):
  73. result = ecliptic_to_zodiac(45.5)
  74. assert result["absolute_lon"] == 45.5
  75. # ── zodiac_to_ecliptic (roundtrip) ──────────────────────────────────
  76. class TestZodiacToEcliptic:
  77. def test_roundtrip_all_signs(self):
  78. for i, sign in enumerate(ZODIAC_SIGNS):
  79. for deg in [0.0, 15.0, 29.9]:
  80. lon = zodiac_to_ecliptic(sign, deg)
  81. result = ecliptic_to_zodiac(lon)
  82. assert result["sign"] == sign
  83. assert abs(result["degree"] - deg) < 0.01
  84. # ── Aspect Detection ────────────────────────────────────────────────
  85. class TestComputeAspects:
  86. def test_conjunction(self):
  87. bodies = [
  88. {"name": "sun", "lon": 10.0},
  89. {"name": "moon", "lon": 12.0},
  90. ]
  91. aspects = compute_aspects(bodies)
  92. assert len(aspects) == 1
  93. assert aspects[0]["aspect"] == "conjunction"
  94. assert aspects[0]["orb"] == 2.0
  95. assert aspects[0]["body1"] == "sun"
  96. assert aspects[0]["body2"] == "moon"
  97. def test_opposition(self):
  98. bodies = [
  99. {"name": "sun", "lon": 0.0},
  100. {"name": "moon", "lon": 180.0},
  101. ]
  102. aspects = compute_aspects(bodies)
  103. assert any(a["aspect"] == "opposition" and a["orb"] == 0.0 for a in aspects)
  104. def test_trine(self):
  105. bodies = [
  106. {"name": "sun", "lon": 0.0},
  107. {"name": "jupiter", "lon": 120.0},
  108. ]
  109. aspects = compute_aspects(bodies)
  110. assert any(a["aspect"] == "trine" for a in aspects)
  111. def test_square(self):
  112. bodies = [
  113. {"name": "sun", "lon": 0.0},
  114. {"name": "mars", "lon": 90.0},
  115. ]
  116. aspects = compute_aspects(bodies)
  117. assert any(a["aspect"] == "square" for a in aspects)
  118. def test_sextile(self):
  119. bodies = [
  120. {"name": "sun", "lon": 0.0},
  121. {"name": "venus", "lon": 60.0},
  122. ]
  123. aspects = compute_aspects(bodies)
  124. assert any(a["aspect"] == "sextile" for a in aspects)
  125. def test_no_aspects_beyond_orb(self):
  126. bodies = [
  127. {"name": "sun", "lon": 0.0},
  128. {"name": "moon", "lon": 45.0},
  129. ]
  130. aspects = compute_aspects(bodies)
  131. assert len(aspects) == 0
  132. def test_multiple_bodies(self):
  133. bodies = [
  134. {"name": "sun", "lon": 0.0},
  135. {"name": "moon", "lon": 2.0},
  136. {"name": "mars", "lon": 5.0},
  137. ]
  138. aspects = compute_aspects(bodies)
  139. # sun-moon conjunction, sun-mars conjunction, moon-mars conjunction
  140. assert len(aspects) >= 2
  141. def test_sorted_by_orb(self):
  142. bodies = [
  143. {"name": "sun", "lon": 0.0},
  144. {"name": "moon", "lon": 1.0},
  145. {"name": "mars", "lon": 5.0},
  146. ]
  147. aspects = compute_aspects(bodies)
  148. orbs = [a["orb"] for a in aspects]
  149. assert orbs == sorted(orbs)
  150. def test_custom_orbs(self):
  151. bodies = [
  152. {"name": "sun", "lon": 0.0},
  153. {"name": "moon", "lon": 10.0},
  154. ]
  155. # Default conjunction orb is 8, so 10° should not aspect
  156. aspects_default = compute_aspects(bodies)
  157. assert len(aspects_default) == 0
  158. # With wider orb, it should
  159. aspects_wide = compute_aspects(bodies, orb_limits={"conjunction": 12.0})
  160. assert len(aspects_wide) == 1
  161. def test_exactness(self):
  162. bodies = [
  163. {"name": "sun", "lon": 0.0},
  164. {"name": "moon", "lon": 0.0},
  165. ]
  166. aspects = compute_aspects(bodies)
  167. assert aspects[0]["exactness"] == 1.0
  168. def test_applying_with_speeds(self):
  169. # Moon is behind sun but faster -- catching up = applying
  170. bodies = [
  171. {"name": "sun", "lon": 10.0, "speed_lon": 1.0},
  172. {"name": "moon", "lon": 5.0, "speed_lon": 13.0},
  173. ]
  174. aspects = compute_aspects(bodies)
  175. conj = [a for a in aspects if a["aspect"] == "conjunction"]
  176. assert len(conj) == 1
  177. assert conj[0]["applying"] is True
  178. def test_separating_with_speeds(self):
  179. # Sun is ahead of moon and faster -- pulling away = separating
  180. bodies = [
  181. {"name": "sun", "lon": 10.0, "speed_lon": 13.0},
  182. {"name": "moon", "lon": 5.0, "speed_lon": 1.0},
  183. ]
  184. aspects = compute_aspects(bodies)
  185. conj = [a for a in aspects if a["aspect"] == "conjunction"]
  186. assert len(conj) == 1
  187. assert conj[0]["applying"] is False
  188. def test_applying_none_without_speeds(self):
  189. bodies = [
  190. {"name": "sun", "lon": 0.0},
  191. {"name": "moon", "lon": 5.0},
  192. ]
  193. aspects = compute_aspects(bodies)
  194. conj = [a for a in aspects if a["aspect"] == "conjunction"]
  195. assert conj[0]["applying"] is None
  196. def test_wraparound_360(self):
  197. bodies = [
  198. {"name": "sun", "lon": 359.0},
  199. {"name": "moon", "lon": 1.0},
  200. ]
  201. aspects = compute_aspects(bodies)
  202. conj = [a for a in aspects if a["aspect"] == "conjunction"]
  203. assert len(conj) == 1
  204. assert abs(conj[0]["orb"] - 2.0) < 0.01
  205. # ── House Systems ───────────────────────────────────────────────────
  206. class TestCalculateHouses:
  207. def test_all_systems_return_12_cusps(self):
  208. for system in SUPPORTED_HOUSE_SYSTEMS:
  209. houses = calculate_houses(6.0, 45.0, house_system=system)
  210. assert len(houses) == 12
  211. def test_all_systems_have_required_keys(self):
  212. for system in SUPPORTED_HOUSE_SYSTEMS:
  213. houses = calculate_houses(6.0, 45.0, house_system=system)
  214. for h in houses:
  215. assert "house" in h
  216. assert "sign" in h
  217. assert "abbreviation" in h
  218. assert "degree" in h
  219. assert "absolute_lon" in h
  220. def test_house_numbers_1_to_12(self):
  221. for system in SUPPORTED_HOUSE_SYSTEMS:
  222. houses = calculate_houses(6.0, 45.0, house_system=system)
  223. numbers = [h["house"] for h in houses]
  224. assert sorted(numbers) == list(range(1, 13))
  225. def test_equal_house_30_degree_spacing(self):
  226. houses = calculate_houses(6.0, 45.0, house_system="equal")
  227. for i in range(11):
  228. lon1 = houses[i]["absolute_lon"]
  229. lon2 = houses[i + 1]["absolute_lon"]
  230. diff = normalize_degrees(lon2 - lon1)
  231. assert abs(diff - 30.0) < 0.01
  232. def test_whole_sign_each_sign_is_one_house(self):
  233. houses = calculate_houses(6.0, 45.0, house_system="whole_sign")
  234. for i, h in enumerate(houses):
  235. sign_index = int(h["absolute_lon"] // 30)
  236. expected_sign = ZODIAC_SIGNS[sign_index]
  237. assert h["sign"] == expected_sign
  238. assert h["degree"] == 0.0
  239. def test_unsupported_system_raises(self):
  240. with pytest.raises(ValueError, match="Unsupported house system"):
  241. calculate_houses(6.0, 45.0, house_system="campanus")
  242. def test_placidus_asc_is_house_1(self):
  243. houses = calculate_houses(6.0, 45.0, house_system="placidus")
  244. h1 = [h for h in houses if h["house"] == 1][0]
  245. assert h1["absolute_lon"] is not None
  246. def test_placidus_mc_is_house_10(self):
  247. houses = calculate_houses(6.0, 45.0, house_system="placidus")
  248. h10 = [h for h in houses if h["house"] == 10][0]
  249. assert h10["absolute_lon"] is not None
  250. # ── Angles ──────────────────────────────────────────────────────────
  251. class TestCalculateAngles:
  252. def test_all_angles_present(self):
  253. angles = calculate_angles(6.0, 45.0)
  254. assert "ascendant" in angles
  255. assert "midheaven" in angles
  256. assert "descendant" in angles
  257. assert "imum_coeli" in angles
  258. def test_asc_opposite_dsc(self):
  259. angles = calculate_angles(6.0, 45.0)
  260. asc_lon = angles["ascendant"]["absolute_lon"]
  261. dsc_lon = angles["descendant"]["absolute_lon"]
  262. diff = abs(asc_lon - dsc_lon)
  263. assert abs(diff - 180.0) < 1.0 # within 1 degree
  264. def test_mc_opposite_ic(self):
  265. angles = calculate_angles(6.0, 45.0)
  266. mc_lon = angles["midheaven"]["absolute_lon"]
  267. ic_lon = angles["imum_coeli"]["absolute_lon"]
  268. diff = abs(mc_lon - ic_lon)
  269. assert abs(diff - 180.0) < 1.0
  270. def test_angles_have_sign_and_degree(self):
  271. angles = calculate_angles(6.0, 45.0)
  272. for key in ("ascendant", "midheaven", "descendant", "imum_coeli"):
  273. assert "sign" in angles[key]
  274. assert "degree" in angles[key]
  275. assert "absolute_lon" in angles[key]
  276. # ── House Placement ─────────────────────────────────────────────────
  277. class TestGetHousePlacement:
  278. def test_simple_equal_houses(self):
  279. # At LST=6h, lat=0, ASC is near 90° (Cancer). House 1 = 90-120.
  280. houses = calculate_houses(6.0, 0.0, house_system="equal")
  281. asc_lon = houses[0]["absolute_lon"]
  282. # Place a point 15° after the ASC -- should be in house 1
  283. test_lon = normalize_degrees(asc_lon + 15.0)
  284. assert get_house_placement(test_lon, houses) == 1
  285. def test_350_in_last_house(self):
  286. # At LST=0, lat=0, whole sign ASC = 90° (Cancer).
  287. # Houses: 1=Cn(90), 2=Le(120), 3=Vi(150), 4=Li(180), 5=Sc(210),
  288. # 6=Sg(240), 7=Cap(270), 8=Aq(300), 9=Pi(330), 10=Ar(0/360)...
  289. # House 9 = Pisces (330-360). 350° is in house 9.
  290. houses = calculate_houses(0.0, 0.0, house_system="whole_sign")
  291. assert get_house_placement(350.0, houses) == 9
  292. def test_0_degrees(self):
  293. houses = calculate_houses(0.0, 0.0, house_system="equal")
  294. result = get_house_placement(0.0, houses)
  295. assert 1 <= result <= 12
  296. def test_180_degrees(self):
  297. houses = calculate_houses(0.0, 0.0, house_system="equal")
  298. result = get_house_placement(180.0, houses)
  299. assert 1 <= result <= 12
  300. # ── Retrograde ──────────────────────────────────────────────────────
  301. class TestIsRetrograde:
  302. def test_positive_speed_direct(self):
  303. assert is_retrograde(1.0) is False
  304. def test_negative_speed_retrograde(self):
  305. assert is_retrograde(-0.5) is True
  306. def test_zero_speed_not_retrograde(self):
  307. assert is_retrograde(0.0) is False
  308. def test_none_speed_not_retrograde(self):
  309. assert is_retrograde(None) is False
  310. # ── Composite Chart ─────────────────────────────────────────────────
  311. class TestComputeCompositeChart:
  312. def test_basic_midpoint(self):
  313. b1 = [{"name": "sun", "lon": 10.0}]
  314. b2 = [{"name": "sun", "lon": 20.0}]
  315. composite = compute_composite_chart(b1, b2)
  316. assert len(composite) == 1
  317. assert abs(composite[0]["lon"] - 15.0) < 0.01
  318. def test_wraparound_midpoint(self):
  319. b1 = [{"name": "sun", "lon": 350.0}]
  320. b2 = [{"name": "sun", "lon": 10.0}]
  321. composite = compute_composite_chart(b1, b2)
  322. # Midpoint of 350 and 10 should be 0 (or 360)
  323. assert abs(composite[0]["lon"] - 0.0) < 1.0 or abs(composite[0]["lon"] - 360.0) < 1.0
  324. def test_only_common_bodies(self):
  325. b1 = [{"name": "sun", "lon": 10.0}, {"name": "mars", "lon": 20.0}]
  326. b2 = [{"name": "sun", "lon": 30.0}, {"name": "venus", "lon": 40.0}]
  327. composite = compute_composite_chart(b1, b2)
  328. assert len(composite) == 1
  329. assert composite[0]["name"] == "sun"
  330. def test_empty_when_no_common(self):
  331. b1 = [{"name": "sun", "lon": 10.0}]
  332. b2 = [{"name": "moon", "lon": 20.0}]
  333. composite = compute_composite_chart(b1, b2)
  334. assert len(composite) == 0
  335. # ── Default Orbs ────────────────────────────────────────────────────
  336. class TestDefaultOrbs:
  337. def test_conjunction_orb(self):
  338. assert DEFAULT_ORBS["conjunction"] == 8.0
  339. def test_sextile_orb(self):
  340. assert DEFAULT_ORBS["sextile"] == 6.0
  341. def test_square_orb(self):
  342. assert DEFAULT_ORBS["square"] == 8.0
  343. def test_trine_orb(self):
  344. assert DEFAULT_ORBS["trine"] == 8.0
  345. def test_opposition_orb(self):
  346. assert DEFAULT_ORBS["opposition"] == 8.0
  347. # ── Supported House Systems ─────────────────────────────────────────
  348. class TestSupportedHouseSystems:
  349. def test_placidus_supported(self):
  350. assert "placidus" in SUPPORTED_HOUSE_SYSTEMS
  351. def test_equal_supported(self):
  352. assert "equal" in SUPPORTED_HOUSE_SYSTEMS
  353. def test_whole_sign_supported(self):
  354. assert "whole_sign" in SUPPORTED_HOUSE_SYSTEMS