test_reference_charts.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. """
  2. Integration tests against well-known celebrity birth charts.
  3. Uses mocked call_sky_state to verify that astro-mcp produces correct
  4. planetary positions, houses, and angles for charts that can be independently
  5. verified against published sources.
  6. Reference data source: astro-charts.com (which uses Swiss Ephemeris)
  7. """
  8. from __future__ import annotations
  9. import asyncio
  10. from unittest.mock import AsyncMock, patch
  11. import pytest
  12. from src.astro_mcp import tools
  13. # ── Sign lookup ───────────────────────────────────────────────────────
  14. SIGN_START = {
  15. "Aries": 0, "Taurus": 30, "Gemini": 60, "Cancer": 90,
  16. "Leo": 120, "Virgo": 150, "Libra": 180, "Scorpius": 210,
  17. "Sagittarius": 240, "Capricornus": 270, "Aquarius": 300, "Pisces": 330,
  18. }
  19. SIGN_NAMES = list(SIGN_START.keys())
  20. def sign_deg_to_abs(sign: str, deg: float) -> float:
  21. return SIGN_START[sign] + deg
  22. # ── Reference data from astro-charts.com ──────────────────────────────
  23. # Albert Einstein: 1879-03-14 11:30 AM LMT, Ulm, Germany
  24. # Source: astro-charts.com, Rodden AA (birth certificate)
  25. EINSTEIN = {
  26. "name": "Albert Einstein",
  27. "datetime": "1879-03-14T11:30:00+00:53",
  28. "lat": 48.39841,
  29. "lon": 9.99155,
  30. "planets": {
  31. "sun": ("Pisces", 23.50, False),
  32. "moon": ("Sagittarius", 14.38, False),
  33. "mercury": ("Aries", 3.12, False),
  34. "venus": ("Aries", 16.97, False),
  35. "mars": ("Capricornus", 26.90, False),
  36. "jupiter": ("Aquarius", 27.48, False),
  37. "saturn": ("Aries", 4.18, False),
  38. "uranus": ("Virgo", 1.28, True),
  39. "neptune": ("Taurus", 7.87, False),
  40. "pluto": ("Taurus", 24.73, False),
  41. "true_node": ("Aquarius", 1.48, True),
  42. "chiron": ("Taurus", 5.55, False),
  43. },
  44. "angles": {
  45. "ascendant": ("Cancer", 8.72),
  46. "midheaven": ("Pisces", 9.07),
  47. },
  48. }
  49. # Chaka Khan: 1953-03-23 9:05 PM CST, Chicago, IL
  50. # Source: astro-charts.com, Rodden AA
  51. CHAKA_KHAN = {
  52. "name": "Chaka Khan",
  53. "datetime": "1953-03-23T21:05:00-06:00",
  54. "lat": 41.87811,
  55. "lon": -87.62980,
  56. "planets": {
  57. "sun": ("Aries", 3.18, False),
  58. "moon": ("Cancer", 23.42, False),
  59. "mercury": ("Pisces", 22.83, True),
  60. "venus": ("Taurus", 1.32, True),
  61. "mars": ("Taurus", 2.80, False),
  62. "jupiter": ("Taurus", 19.80, False),
  63. "saturn": ("Libra", 25.53, True),
  64. "uranus": ("Cancer", 14.43, False),
  65. "neptune": ("Libra", 23.05, True),
  66. "pluto": ("Leo", 21.15, True),
  67. "true_node": ("Aquarius", 9.73, True),
  68. "chiron": ("Capricornus", 20.22, False),
  69. },
  70. "angles": {
  71. "ascendant": ("Scorpius", 8.92),
  72. "midheaven": ("Leo", 17.45),
  73. },
  74. }
  75. # ── Helper: build mock sky_state from reference data ──────────────────
  76. def _make_mock_sky_state(ref: dict) -> dict:
  77. """Build a mock get_sky_state response matching the ephemeris server format."""
  78. bodies = []
  79. for body_name, (sign, deg, retro) in ref["planets"].items():
  80. abs_lon = sign_deg_to_abs(sign, deg)
  81. speed = -0.5 if retro else 0.5
  82. bodies.append({
  83. "body": body_name,
  84. "ecliptic_lon": abs_lon,
  85. "ecliptic_lat": 0.0,
  86. "distance_au": 1.0,
  87. "speed_lon": speed,
  88. "speed_lat": 0.0,
  89. "speed_dist": 0.0,
  90. "ra": 0.0,
  91. "dec": 0.0,
  92. })
  93. asc_sign, asc_deg = ref["angles"]["ascendant"]
  94. mc_sign, mc_deg = ref["angles"]["midheaven"]
  95. asc_lon = sign_deg_to_abs(asc_sign, asc_deg)
  96. mc_lon = sign_deg_to_abs(mc_sign, mc_deg)
  97. cusps = []
  98. for i in range(12):
  99. cusp_lon = (asc_lon + i * 30) % 360
  100. sign_idx = int(cusp_lon // 30)
  101. sign_name = SIGN_NAMES[sign_idx]
  102. cusps.append({
  103. "house": i + 1,
  104. "absolute_lon": cusp_lon,
  105. "sign": sign_name,
  106. "abbreviation": sign_name[:3],
  107. "degree": cusp_lon % 30.0,
  108. })
  109. return {
  110. "input": {
  111. "datetime": ref["datetime"],
  112. "lat": ref["lat"],
  113. "lon": ref["lon"],
  114. "elevation": 0.0,
  115. "geocentric": True,
  116. "house_system": "placidus",
  117. },
  118. "timestamp_utc": ref["datetime"],
  119. "julian_day": 2400000.0,
  120. "planetary_positions": {"bodies": bodies},
  121. "lunar_state": {"phase_name": "Waxing Crescent", "illumination_fraction": 0.5},
  122. "sidereal_time": {
  123. "greenwich_sidereal_time": 12.0,
  124. "local_sidereal_time": 12.0,
  125. "obliquity_of_ecliptic": 23.4367,
  126. },
  127. "houses": {
  128. "system": "PLACIDUS",
  129. "cusps": cusps,
  130. "ascendant": asc_lon,
  131. "midheaven": mc_lon,
  132. "descendant": (asc_lon + 180) % 360,
  133. "imum_coeli": (mc_lon + 180) % 360,
  134. },
  135. }
  136. # ── Tests: Albert Einstein ────────────────────────────────────────────
  137. class TestEinsteinChart:
  138. """Verify Einstein's chart against astro-charts.com reference data."""
  139. @pytest.fixture
  140. def einstein_sky(self):
  141. return _make_mock_sky_state(EINSTEIN)
  142. async def test_planetary_positions(self, einstein_sky):
  143. """Verify planet signs and approximate degrees match reference."""
  144. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  145. mock.return_value = einstein_sky
  146. result = await tools.calculate_natal_chart(
  147. birth_datetime=EINSTEIN["datetime"],
  148. latitude=EINSTEIN["lat"],
  149. longitude=EINSTEIN["lon"],
  150. house_system="placidus",
  151. )
  152. assert "error" not in result
  153. assert "planets" in result
  154. for planet in result["planets"]:
  155. name = planet["body"]
  156. if name not in EINSTEIN["planets"]:
  157. continue
  158. ref_sign, ref_deg, ref_retro = EINSTEIN["planets"][name]
  159. ref_abs = sign_deg_to_abs(ref_sign, ref_deg)
  160. assert planet["sign"] == ref_sign, \
  161. f"{name}: expected {ref_sign}, got {planet['sign']}"
  162. assert abs(planet["degree_within_sign"] - ref_deg) < 1.0, \
  163. f"{name}: expected {ref_deg}°, got {planet['degree_within_sign']:.2f}°"
  164. assert abs(planet["absolute_lon"] - ref_abs) < 1.0, \
  165. f"{name}: expected abs_lon {ref_abs:.2f}°, got {planet['absolute_lon']:.2f}°"
  166. assert planet["retrograde"] == ref_retro, \
  167. f"{name}: expected retrograde={ref_retro}, got {planet['retrograde']}"
  168. async def test_angles(self, einstein_sky):
  169. """Verify ASC and MC match reference."""
  170. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  171. mock.return_value = einstein_sky
  172. result = await tools.calculate_natal_chart(
  173. birth_datetime=EINSTEIN["datetime"],
  174. latitude=EINSTEIN["lat"],
  175. longitude=EINSTEIN["lon"],
  176. house_system="placidus",
  177. )
  178. angles = result["angles"]
  179. for angle_name, (ref_sign, ref_deg) in EINSTEIN["angles"].items():
  180. ref_abs = sign_deg_to_abs(ref_sign, ref_deg)
  181. actual = angles[angle_name]
  182. assert actual["sign"] == ref_sign, \
  183. f"{angle_name}: expected {ref_sign}, got {actual['sign']}"
  184. assert abs(actual["absolute_lon"] - ref_abs) < 1.0, \
  185. f"{angle_name}: expected {ref_abs:.2f}°, got {actual['absolute_lon']:.2f}°"
  186. async def test_houses_present(self, einstein_sky):
  187. """Verify all 12 houses are returned with correct structure."""
  188. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  189. mock.return_value = einstein_sky
  190. result = await tools.calculate_natal_chart(
  191. birth_datetime=EINSTEIN["datetime"],
  192. latitude=EINSTEIN["lat"],
  193. longitude=EINSTEIN["lon"],
  194. house_system="placidus",
  195. )
  196. assert "houses" in result
  197. assert len(result["houses"]) == 12
  198. for h in result["houses"]:
  199. assert "house" in h
  200. assert "sign" in h
  201. assert "degree" in h
  202. assert "absolute_lon" in h
  203. assert 1 <= h["house"] <= 12
  204. async def test_aspects_present(self, einstein_sky):
  205. """Verify aspects are computed."""
  206. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  207. mock.return_value = einstein_sky
  208. result = await tools.calculate_natal_chart(
  209. birth_datetime=EINSTEIN["datetime"],
  210. latitude=EINSTEIN["lat"],
  211. longitude=EINSTEIN["lon"],
  212. house_system="placidus",
  213. )
  214. assert "aspects" in result
  215. assert len(result["aspects"]) > 0
  216. for asp in result["aspects"]:
  217. assert "body1" in asp
  218. assert "body2" in asp
  219. assert "aspect" in asp
  220. assert "orb" in asp
  221. # ── Tests: Chaka Khan ─────────────────────────────────────────────────
  222. class TestChakaKhanChart:
  223. """Verify Chaka Khan's chart against astro-charts.com reference data."""
  224. @pytest.fixture
  225. def chaka_sky(self):
  226. return _make_mock_sky_state(CHAKA_KHAN)
  227. async def test_planetary_positions(self, chaka_sky):
  228. """Verify planet signs and approximate degrees match reference."""
  229. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  230. mock.return_value = chaka_sky
  231. result = await tools.calculate_natal_chart(
  232. birth_datetime=CHAKA_KHAN["datetime"],
  233. latitude=CHAKA_KHAN["lat"],
  234. longitude=CHAKA_KHAN["lon"],
  235. house_system="placidus",
  236. )
  237. assert "error" not in result
  238. for planet in result["planets"]:
  239. name = planet["body"]
  240. if name not in CHAKA_KHAN["planets"]:
  241. continue
  242. ref_sign, ref_deg, ref_retro = CHAKA_KHAN["planets"][name]
  243. ref_abs = sign_deg_to_abs(ref_sign, ref_deg)
  244. assert planet["sign"] == ref_sign, \
  245. f"{name}: expected {ref_sign}, got {planet['sign']}"
  246. assert abs(planet["degree_within_sign"] - ref_deg) < 1.0, \
  247. f"{name}: expected {ref_deg}°, got {planet['degree_within_sign']:.2f}°"
  248. assert abs(planet["absolute_lon"] - ref_abs) < 1.0, \
  249. f"{name}: expected abs_lon {ref_abs:.2f}°, got {planet['absolute_lon']:.2f}°"
  250. assert planet["retrograde"] == ref_retro, \
  251. f"{name}: expected retrograde={ref_retro}, got {planet['retrograde']}"
  252. async def test_angles(self, chaka_sky):
  253. """Verify ASC and MC match reference."""
  254. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  255. mock.return_value = chaka_sky
  256. result = await tools.calculate_natal_chart(
  257. birth_datetime=CHAKA_KHAN["datetime"],
  258. latitude=CHAKA_KHAN["lat"],
  259. longitude=CHAKA_KHAN["lon"],
  260. house_system="placidus",
  261. )
  262. angles = result["angles"]
  263. for angle_name, (ref_sign, ref_deg) in CHAKA_KHAN["angles"].items():
  264. ref_abs = sign_deg_to_abs(ref_sign, ref_deg)
  265. actual = angles[angle_name]
  266. assert actual["sign"] == ref_sign, \
  267. f"{angle_name}: expected {ref_sign}, got {actual['sign']}"
  268. assert abs(actual["absolute_lon"] - ref_abs) < 1.0, \
  269. f"{angle_name}: expected {ref_abs:.2f}°, got {actual['absolute_lon']:.2f}°"
  270. async def test_retrograde_planets(self, chaka_sky):
  271. """Chaka Khan has 6 retrograde planets — verify they're detected."""
  272. with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
  273. mock.return_value = chaka_sky
  274. result = await tools.calculate_natal_chart(
  275. birth_datetime=CHAKA_KHAN["datetime"],
  276. latitude=CHAKA_KHAN["lat"],
  277. longitude=CHAKA_KHAN["lon"],
  278. house_system="placidus",
  279. include_overview=True,
  280. )
  281. retro = result["overview"]["retrograde_planets"]
  282. retro_names = [p["body"] for p in retro]
  283. expected_retro = {"mercury", "venus", "saturn", "neptune", "pluto", "true_node"}
  284. assert set(retro_names) == expected_retro, \
  285. f"Expected retrograde: {expected_retro}, got: {set(retro_names)}"
  286. # ── Tests: extract_houses and extract_angles ──────────────────────────
  287. class TestExtractHouses:
  288. """Test the extract_houses helper with server-formatted data."""
  289. def test_extracts_12_cusps(self):
  290. from src.astro_mcp.ephemeris_client import extract_houses
  291. sky = {
  292. "houses": {
  293. "system": "PLACIDUS",
  294. "cusps": [
  295. {"house": i+1, "absolute_lon": float(i*30), "sign": "Aries",
  296. "abbreviation": "Ar", "degree": 0.0}
  297. for i in range(12)
  298. ],
  299. "ascendant": 0.0,
  300. "midheaven": 90.0,
  301. }
  302. }
  303. result = extract_houses(sky)
  304. assert result is not None
  305. assert len(result) == 12
  306. def test_returns_none_when_no_houses(self):
  307. from src.astro_mcp.ephemeris_client import extract_houses
  308. assert extract_houses({}) is None
  309. assert extract_houses({"houses": None}) is None
  310. def test_returns_none_on_error(self):
  311. from src.astro_mcp.ephemeris_client import extract_houses
  312. sky = {"houses": {"system": "X", "error": "invalid house system"}}
  313. assert extract_houses(sky) is None
  314. class TestExtractAngles:
  315. """Test the extract_angles helper with server-formatted data."""
  316. def test_extracts_all_four_angles(self):
  317. from src.astro_mcp.ephemeris_client import extract_angles
  318. sky = {
  319. "houses": {
  320. "cusps": [],
  321. "ascendant": 98.72,
  322. "midheaven": 339.07,
  323. "descendant": 278.72,
  324. "imum_coeli": 159.07,
  325. }
  326. }
  327. result = extract_angles(sky)
  328. assert result is not None
  329. assert "ascendant" in result
  330. assert "midheaven" in result
  331. assert "descendant" in result
  332. assert "imum_coeli" in result
  333. assert result["ascendant"]["sign"] == "Cancer"
  334. assert result["midheaven"]["sign"] == "Pisces"
  335. assert result["descendant"]["sign"] == "Capricornus"
  336. assert result["imum_coeli"]["sign"] == "Virgo"
  337. def test_returns_none_when_no_houses(self):
  338. from src.astro_mcp.ephemeris_client import extract_angles
  339. assert extract_angles({}) is None
  340. assert extract_angles({"houses": None}) is None
  341. def test_returns_none_on_error(self):
  342. from src.astro_mcp.ephemeris_client import extract_angles
  343. sky = {"houses": {"error": "failed"}}
  344. assert extract_angles(sky) is None
  345. def test_computes_dsc_ic_when_missing(self):
  346. from src.astro_mcp.ephemeris_client import extract_angles
  347. sky = {
  348. "houses": {
  349. "cusps": [],
  350. "ascendant": 90.0,
  351. "midheaven": 180.0,
  352. }
  353. }
  354. result = extract_angles(sky)
  355. assert result is not None
  356. assert result["descendant"]["sign"] == "Capricornus"
  357. assert result["imum_coeli"]["sign"] == "Aries"