| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443 |
- """
- Unit tests for the pure astrological calculation module.
- Tests cover:
- - Zodiac sign calculations
- - House system calculations (Placidus, Equal, Whole Sign)
- - Aspect detection with orbs
- - Angle calculations (ASC, MC, DSC, IC)
- - House placement
- - Retrograde detection
- - Composite / Davison charts
- """
- from __future__ import annotations
- import math
- import pytest
- from src.astro_mcp.astrology import (
- DEFAULT_ORBS,
- SUPPORTED_HOUSE_SYSTEMS,
- ZODIAC_SIGNS,
- calculate_angles,
- calculate_houses,
- compute_aspects,
- compute_composite_chart,
- ecliptic_to_zodiac,
- get_house_placement,
- is_retrograde,
- normalize_degrees,
- zodiac_to_ecliptic,
- )
- # ── normalize_degrees ────────────────────────────────────────────────
- class TestNormalizeDegrees:
- def test_zero(self):
- assert normalize_degrees(0.0) == 0.0
- def test_positive(self):
- assert normalize_degrees(45.0) == 45.0
- def test_360_wraps_to_zero(self):
- assert normalize_degrees(360.0) == 0.0
- def test_over_360(self):
- assert normalize_degrees(370.0) == 10.0
- def test_negative(self):
- assert normalize_degrees(-10.0) == 350.0
- def test_large_negative(self):
- assert normalize_degrees(-370.0) == 350.0
- def test_720(self):
- assert normalize_degrees(720.0) == 0.0
- # ── ecliptic_to_zodiac ──────────────────────────────────────────────
- class TestEclipticToZodiac:
- def test_aries_start(self):
- result = ecliptic_to_zodiac(0.0)
- assert result["sign"] == "Aries"
- assert result["degree"] == 0.0
- def test_aries_mid(self):
- result = ecliptic_to_zodiac(15.0)
- assert result["sign"] == "Aries"
- assert result["degree"] == 15.0
- def test_taurus_start(self):
- result = ecliptic_to_zodiac(30.0)
- assert result["sign"] == "Taurus"
- assert result["degree"] == 0.0
- def test_pisces_end(self):
- result = ecliptic_to_zodiac(359.0)
- assert result["sign"] == "Pisces"
- assert abs(result["degree"] - 29.0) < 0.001
- def test_all_signs(self):
- for i, sign in enumerate(ZODIAC_SIGNS):
- lon = i * 30 + 15 # middle of each sign
- result = ecliptic_to_zodiac(lon)
- assert result["sign"] == sign
- assert abs(result["degree"] - 15.0) < 0.001
- def test_abbreviation(self):
- result = ecliptic_to_zodiac(0.0)
- assert result["abbreviation"] == "Ar"
- def test_absolute_lon_preserved(self):
- result = ecliptic_to_zodiac(45.5)
- assert result["absolute_lon"] == 45.5
- # ── zodiac_to_ecliptic (roundtrip) ──────────────────────────────────
- class TestZodiacToEcliptic:
- def test_roundtrip_all_signs(self):
- for i, sign in enumerate(ZODIAC_SIGNS):
- for deg in [0.0, 15.0, 29.9]:
- lon = zodiac_to_ecliptic(sign, deg)
- result = ecliptic_to_zodiac(lon)
- assert result["sign"] == sign
- assert abs(result["degree"] - deg) < 0.01
- # ── Aspect Detection ────────────────────────────────────────────────
- class TestComputeAspects:
- def test_conjunction(self):
- bodies = [
- {"name": "sun", "lon": 10.0},
- {"name": "moon", "lon": 12.0},
- ]
- aspects = compute_aspects(bodies)
- assert len(aspects) == 1
- assert aspects[0]["aspect"] == "conjunction"
- assert aspects[0]["orb"] == 2.0
- assert aspects[0]["body1"] == "sun"
- assert aspects[0]["body2"] == "moon"
- def test_opposition(self):
- bodies = [
- {"name": "sun", "lon": 0.0},
- {"name": "moon", "lon": 180.0},
- ]
- aspects = compute_aspects(bodies)
- assert any(a["aspect"] == "opposition" and a["orb"] == 0.0 for a in aspects)
- def test_trine(self):
- bodies = [
- {"name": "sun", "lon": 0.0},
- {"name": "jupiter", "lon": 120.0},
- ]
- aspects = compute_aspects(bodies)
- assert any(a["aspect"] == "trine" for a in aspects)
- def test_square(self):
- bodies = [
- {"name": "sun", "lon": 0.0},
- {"name": "mars", "lon": 90.0},
- ]
- aspects = compute_aspects(bodies)
- assert any(a["aspect"] == "square" for a in aspects)
- def test_sextile(self):
- bodies = [
- {"name": "sun", "lon": 0.0},
- {"name": "venus", "lon": 60.0},
- ]
- aspects = compute_aspects(bodies)
- assert any(a["aspect"] == "sextile" for a in aspects)
- def test_no_aspects_beyond_orb(self):
- bodies = [
- {"name": "sun", "lon": 0.0},
- {"name": "moon", "lon": 45.0},
- ]
- aspects = compute_aspects(bodies)
- assert len(aspects) == 0
- def test_multiple_bodies(self):
- bodies = [
- {"name": "sun", "lon": 0.0},
- {"name": "moon", "lon": 2.0},
- {"name": "mars", "lon": 5.0},
- ]
- aspects = compute_aspects(bodies)
- # sun-moon conjunction, sun-mars conjunction, moon-mars conjunction
- assert len(aspects) >= 2
- def test_sorted_by_orb(self):
- bodies = [
- {"name": "sun", "lon": 0.0},
- {"name": "moon", "lon": 1.0},
- {"name": "mars", "lon": 5.0},
- ]
- aspects = compute_aspects(bodies)
- orbs = [a["orb"] for a in aspects]
- assert orbs == sorted(orbs)
- def test_custom_orbs(self):
- bodies = [
- {"name": "sun", "lon": 0.0},
- {"name": "moon", "lon": 10.0},
- ]
- # Default conjunction orb is 8, so 10° should not aspect
- aspects_default = compute_aspects(bodies)
- assert len(aspects_default) == 0
- # With wider orb, it should
- aspects_wide = compute_aspects(bodies, orb_limits={"conjunction": 12.0})
- assert len(aspects_wide) == 1
- def test_exactness(self):
- bodies = [
- {"name": "sun", "lon": 0.0},
- {"name": "moon", "lon": 0.0},
- ]
- aspects = compute_aspects(bodies)
- assert aspects[0]["exactness"] == 1.0
- def test_applying_with_speeds(self):
- # Moon is behind sun but faster -- catching up = applying
- bodies = [
- {"name": "sun", "lon": 10.0, "speed_lon": 1.0},
- {"name": "moon", "lon": 5.0, "speed_lon": 13.0},
- ]
- aspects = compute_aspects(bodies)
- conj = [a for a in aspects if a["aspect"] == "conjunction"]
- assert len(conj) == 1
- assert conj[0]["applying"] is True
- def test_separating_with_speeds(self):
- # Sun is ahead of moon and faster -- pulling away = separating
- bodies = [
- {"name": "sun", "lon": 10.0, "speed_lon": 13.0},
- {"name": "moon", "lon": 5.0, "speed_lon": 1.0},
- ]
- aspects = compute_aspects(bodies)
- conj = [a for a in aspects if a["aspect"] == "conjunction"]
- assert len(conj) == 1
- assert conj[0]["applying"] is False
- def test_applying_none_without_speeds(self):
- bodies = [
- {"name": "sun", "lon": 0.0},
- {"name": "moon", "lon": 5.0},
- ]
- aspects = compute_aspects(bodies)
- conj = [a for a in aspects if a["aspect"] == "conjunction"]
- assert conj[0]["applying"] is None
- def test_wraparound_360(self):
- bodies = [
- {"name": "sun", "lon": 359.0},
- {"name": "moon", "lon": 1.0},
- ]
- aspects = compute_aspects(bodies)
- conj = [a for a in aspects if a["aspect"] == "conjunction"]
- assert len(conj) == 1
- assert abs(conj[0]["orb"] - 2.0) < 0.01
- # ── House Systems ───────────────────────────────────────────────────
- class TestCalculateHouses:
- def test_all_systems_return_12_cusps(self):
- for system in SUPPORTED_HOUSE_SYSTEMS:
- houses = calculate_houses(6.0, 45.0, house_system=system)
- assert len(houses) == 12
- def test_all_systems_have_required_keys(self):
- for system in SUPPORTED_HOUSE_SYSTEMS:
- houses = calculate_houses(6.0, 45.0, house_system=system)
- for h in houses:
- assert "house" in h
- assert "sign" in h
- assert "abbreviation" in h
- assert "degree" in h
- assert "absolute_lon" in h
- def test_house_numbers_1_to_12(self):
- for system in SUPPORTED_HOUSE_SYSTEMS:
- houses = calculate_houses(6.0, 45.0, house_system=system)
- numbers = [h["house"] for h in houses]
- assert sorted(numbers) == list(range(1, 13))
- def test_equal_house_30_degree_spacing(self):
- houses = calculate_houses(6.0, 45.0, house_system="equal")
- for i in range(11):
- lon1 = houses[i]["absolute_lon"]
- lon2 = houses[i + 1]["absolute_lon"]
- diff = normalize_degrees(lon2 - lon1)
- assert abs(diff - 30.0) < 0.01
- def test_whole_sign_each_sign_is_one_house(self):
- houses = calculate_houses(6.0, 45.0, house_system="whole_sign")
- for i, h in enumerate(houses):
- sign_index = int(h["absolute_lon"] // 30)
- expected_sign = ZODIAC_SIGNS[sign_index]
- assert h["sign"] == expected_sign
- assert h["degree"] == 0.0
- def test_unsupported_system_raises(self):
- with pytest.raises(ValueError, match="Unsupported house system"):
- calculate_houses(6.0, 45.0, house_system="campanus")
- def test_placidus_asc_is_house_1(self):
- houses = calculate_houses(6.0, 45.0, house_system="placidus")
- h1 = [h for h in houses if h["house"] == 1][0]
- assert h1["absolute_lon"] is not None
- def test_placidus_mc_is_house_10(self):
- houses = calculate_houses(6.0, 45.0, house_system="placidus")
- h10 = [h for h in houses if h["house"] == 10][0]
- assert h10["absolute_lon"] is not None
- # ── Angles ──────────────────────────────────────────────────────────
- class TestCalculateAngles:
- def test_all_angles_present(self):
- angles = calculate_angles(6.0, 45.0)
- assert "ascendant" in angles
- assert "midheaven" in angles
- assert "descendant" in angles
- assert "imum_coeli" in angles
- def test_asc_opposite_dsc(self):
- angles = calculate_angles(6.0, 45.0)
- asc_lon = angles["ascendant"]["absolute_lon"]
- dsc_lon = angles["descendant"]["absolute_lon"]
- diff = abs(asc_lon - dsc_lon)
- assert abs(diff - 180.0) < 1.0 # within 1 degree
- def test_mc_opposite_ic(self):
- angles = calculate_angles(6.0, 45.0)
- mc_lon = angles["midheaven"]["absolute_lon"]
- ic_lon = angles["imum_coeli"]["absolute_lon"]
- diff = abs(mc_lon - ic_lon)
- assert abs(diff - 180.0) < 1.0
- def test_angles_have_sign_and_degree(self):
- angles = calculate_angles(6.0, 45.0)
- for key in ("ascendant", "midheaven", "descendant", "imum_coeli"):
- assert "sign" in angles[key]
- assert "degree" in angles[key]
- assert "absolute_lon" in angles[key]
- # ── House Placement ─────────────────────────────────────────────────
- class TestGetHousePlacement:
- def test_simple_equal_houses(self):
- # At LST=6h, lat=0, ASC is near 90° (Cancer). House 1 = 90-120.
- houses = calculate_houses(6.0, 0.0, house_system="equal")
- asc_lon = houses[0]["absolute_lon"]
- # Place a point 15° after the ASC -- should be in house 1
- test_lon = normalize_degrees(asc_lon + 15.0)
- assert get_house_placement(test_lon, houses) == 1
- def test_350_in_last_house(self):
- # At LST=0, lat=0, whole sign ASC = 90° (Cancer).
- # Houses: 1=Cn(90), 2=Le(120), 3=Vi(150), 4=Li(180), 5=Sc(210),
- # 6=Sg(240), 7=Cap(270), 8=Aq(300), 9=Pi(330), 10=Ar(0/360)...
- # House 9 = Pisces (330-360). 350° is in house 9.
- houses = calculate_houses(0.0, 0.0, house_system="whole_sign")
- assert get_house_placement(350.0, houses) == 9
- def test_0_degrees(self):
- houses = calculate_houses(0.0, 0.0, house_system="equal")
- result = get_house_placement(0.0, houses)
- assert 1 <= result <= 12
- def test_180_degrees(self):
- houses = calculate_houses(0.0, 0.0, house_system="equal")
- result = get_house_placement(180.0, houses)
- assert 1 <= result <= 12
- # ── Retrograde ──────────────────────────────────────────────────────
- class TestIsRetrograde:
- def test_positive_speed_direct(self):
- assert is_retrograde(1.0) is False
- def test_negative_speed_retrograde(self):
- assert is_retrograde(-0.5) is True
- def test_zero_speed_not_retrograde(self):
- assert is_retrograde(0.0) is False
- def test_none_speed_not_retrograde(self):
- assert is_retrograde(None) is False
- # ── Composite Chart ─────────────────────────────────────────────────
- class TestComputeCompositeChart:
- def test_basic_midpoint(self):
- b1 = [{"name": "sun", "lon": 10.0}]
- b2 = [{"name": "sun", "lon": 20.0}]
- composite = compute_composite_chart(b1, b2)
- assert len(composite) == 1
- assert abs(composite[0]["lon"] - 15.0) < 0.01
- def test_wraparound_midpoint(self):
- b1 = [{"name": "sun", "lon": 350.0}]
- b2 = [{"name": "sun", "lon": 10.0}]
- composite = compute_composite_chart(b1, b2)
- # Midpoint of 350 and 10 should be 0 (or 360)
- assert abs(composite[0]["lon"] - 0.0) < 1.0 or abs(composite[0]["lon"] - 360.0) < 1.0
- def test_only_common_bodies(self):
- b1 = [{"name": "sun", "lon": 10.0}, {"name": "mars", "lon": 20.0}]
- b2 = [{"name": "sun", "lon": 30.0}, {"name": "venus", "lon": 40.0}]
- composite = compute_composite_chart(b1, b2)
- assert len(composite) == 1
- assert composite[0]["name"] == "sun"
- def test_empty_when_no_common(self):
- b1 = [{"name": "sun", "lon": 10.0}]
- b2 = [{"name": "moon", "lon": 20.0}]
- composite = compute_composite_chart(b1, b2)
- assert len(composite) == 0
- # ── Default Orbs ────────────────────────────────────────────────────
- class TestDefaultOrbs:
- def test_conjunction_orb(self):
- assert DEFAULT_ORBS["conjunction"] == 8.0
- def test_sextile_orb(self):
- assert DEFAULT_ORBS["sextile"] == 6.0
- def test_square_orb(self):
- assert DEFAULT_ORBS["square"] == 8.0
- def test_trine_orb(self):
- assert DEFAULT_ORBS["trine"] == 8.0
- def test_opposition_orb(self):
- assert DEFAULT_ORBS["opposition"] == 8.0
- # ── Supported House Systems ─────────────────────────────────────────
- class TestSupportedHouseSystems:
- def test_placidus_supported(self):
- assert "placidus" in SUPPORTED_HOUSE_SYSTEMS
- def test_equal_supported(self):
- assert "equal" in SUPPORTED_HOUSE_SYSTEMS
- def test_whole_sign_supported(self):
- assert "whole_sign" in SUPPORTED_HOUSE_SYSTEMS
|