| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161 |
- """
- 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
- from typing import Any
- import math
- import pytest
- from src.astro_mcp.astrology import (
- ANGULAR_HOUSES,
- CADENT_HOUSES,
- DEFAULT_ORBS,
- ELEMENT_SIGNS,
- HARD_ASPECTS,
- MODALITY_SIGNS,
- PERSONAL_PLANETS,
- SIGN_ABBREVIATIONS,
- SIGN_ELEMENTS,
- SIGN_MODALITIES,
- SIGN_RULERS,
- SIGN_RULERS_TRADITIONAL,
- SUCCEDENT_HOUSES,
- SUPPORTED_HOUSE_SYSTEMS,
- ZODIAC_SIGNS,
- calculate_angles,
- calculate_houses,
- compute_aspects,
- compute_composite_chart,
- detect_aspect_patterns,
- detect_chart_shape,
- detect_stelliums,
- ecliptic_to_zodiac,
- filter_aspects_by_planets,
- get_chart_ruler,
- get_element_balance,
- get_empty_houses,
- get_hemisphere_emphasis,
- get_house_placement,
- get_house_rulers,
- get_house_type_counts,
- get_modality_balance,
- get_nodal_axis,
- get_natal_aspects_to_planets,
- get_part_of_fortune,
- get_pluto_polarity_point,
- get_retrograde_planets,
- get_saturn_info,
- get_twelfth_house_analysis,
- group_planets_by_house,
- group_planets_by_sign,
- 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
- # ── Sign Ruler Lookup ────────────────────────────────────────────────
- class TestSignRulers:
- def test_aries_ruler(self):
- assert SIGN_RULERS["Aries"] == "mars"
- def test_scorpius_ruler_modern(self):
- assert SIGN_RULERS["Scorpius"] == "pluto"
- def test_scorpius_ruler_traditional(self):
- assert SIGN_RULERS_TRADITIONAL["Scorpius"] == "mars"
- def test_aquarius_ruler_modern(self):
- assert SIGN_RULERS["Aquarius"] == "uranus"
- def test_aquarius_ruler_traditional(self):
- assert SIGN_RULERS_TRADITIONAL["Aquarius"] == "saturn"
- def test_all_signs_have_rulers(self):
- for sign in ZODIAC_SIGNS:
- assert sign in SIGN_RULERS
- assert sign in SIGN_RULERS_TRADITIONAL
- class TestSignElements:
- def test_fire_signs(self):
- assert set(ELEMENT_SIGNS["fire"]) == {"Aries", "Leo", "Sagittarius"}
- def test_earth_signs(self):
- assert set(ELEMENT_SIGNS["earth"]) == {"Taurus", "Virgo", "Capricornus"}
- def test_air_signs(self):
- assert set(ELEMENT_SIGNS["air"]) == {"Gemini", "Libra", "Aquarius"}
- def test_water_signs(self):
- assert set(ELEMENT_SIGNS["water"]) == {"Cancer", "Scorpius", "Pisces"}
- def test_all_signs_have_elements(self):
- for sign in ZODIAC_SIGNS:
- assert sign in SIGN_ELEMENTS
- class TestSignModalities:
- def test_cardinal_signs(self):
- assert set(MODALITY_SIGNS["cardinal"]) == {"Aries", "Cancer", "Libra", "Capricornus"}
- def test_fixed_signs(self):
- assert set(MODALITY_SIGNS["fixed"]) == {"Taurus", "Leo", "Scorpius", "Aquarius"}
- def test_mutable_signs(self):
- assert set(MODALITY_SIGNS["mutable"]) == {"Gemini", "Virgo", "Sagittarius", "Pisces"}
- def test_all_signs_have_modalities(self):
- for sign in ZODIAC_SIGNS:
- assert sign in SIGN_MODALITIES
- # ── Helper Constants ─────────────────────────────────────────────────
- class TestHelperConstants:
- def test_angular_houses(self):
- assert ANGULAR_HOUSES == {1, 4, 7, 10}
- def test_succedent_houses(self):
- assert SUCCEDENT_HOUSES == {2, 5, 8, 11}
- def test_cadent_houses(self):
- assert CADENT_HOUSES == {3, 6, 9, 12}
- def test_personal_planets(self):
- assert PERSONAL_PLANETS == {"sun", "moon", "mercury", "venus", "mars"}
- def test_hard_aspects(self):
- assert HARD_ASPECTS == {"conjunction", "square", "opposition"}
- # ── Sample Planet Fixtures ───────────────────────────────────────────
- def _make_planets() -> list[dict[str, Any]]:
- """Create a realistic 12-planet list for testing."""
- return [
- {"body": "sun", "sign": "Leo", "house": 5, "absolute_lon": 135.0, "degree_within_sign": 15.0, "retrograde": False},
- {"body": "moon", "sign": "Cancer", "house": 4, "absolute_lon": 105.0, "degree_within_sign": 15.0, "retrograde": False},
- {"body": "mercury", "sign": "Leo", "house": 5, "absolute_lon": 138.0, "degree_within_sign": 18.0, "retrograde": False},
- {"body": "venus", "sign": "Virgo", "house": 6, "absolute_lon": 155.0, "degree_within_sign": 5.0, "retrograde": False},
- {"body": "mars", "sign": "Aries", "house": 1, "absolute_lon": 15.0, "degree_within_sign": 15.0, "retrograde": False},
- {"body": "jupiter", "sign": "Sagittarius", "house": 9, "absolute_lon": 255.0, "degree_within_sign": 15.0, "retrograde": True},
- {"body": "saturn", "sign": "Capricornus", "house": 10, "absolute_lon": 285.0, "degree_within_sign": 15.0, "retrograde": False},
- {"body": "uranus", "sign": "Aquarius", "house": 11, "absolute_lon": 315.0, "degree_within_sign": 15.0, "retrograde": False},
- {"body": "neptune", "sign": "Pisces", "house": 12, "absolute_lon": 345.0, "degree_within_sign": 15.0, "retrograde": False},
- {"body": "pluto", "sign": "Scorpius", "house": 8, "absolute_lon": 225.0, "degree_within_sign": 15.0, "retrograde": True},
- {"body": "chiron", "sign": "Aries", "house": 1, "absolute_lon": 12.0, "degree_within_sign": 12.0, "retrograde": False},
- {"body": "true_node", "sign": "Gemini", "house": 3, "absolute_lon": 75.0, "degree_within_sign": 15.0, "retrograde": False},
- ]
- def _make_stellium_planets() -> list[dict[str, Any]]:
- """Create a planet list with a stellium in Leo (sign) and house 5."""
- return [
- {"body": "sun", "sign": "Leo", "house": 5, "absolute_lon": 130.0, "degree_within_sign": 10.0, "retrograde": False},
- {"body": "moon", "sign": "Leo", "house": 5, "absolute_lon": 135.0, "degree_within_sign": 15.0, "retrograde": False},
- {"body": "mercury", "sign": "Leo", "house": 5, "absolute_lon": 140.0, "degree_within_sign": 20.0, "retrograde": False},
- {"body": "venus", "sign": "Virgo", "house": 6, "absolute_lon": 155.0, "degree_within_sign": 5.0, "retrograde": False},
- {"body": "mars", "sign": "Aries", "house": 1, "absolute_lon": 15.0, "degree_within_sign": 15.0, "retrograde": False},
- {"body": "jupiter", "sign": "Sagittarius", "house": 9, "absolute_lon": 255.0, "degree_within_sign": 15.0, "retrograde": False},
- {"body": "saturn", "sign": "Capricornus", "house": 10, "absolute_lon": 285.0, "degree_within_sign": 15.0, "retrograde": False},
- {"body": "uranus", "sign": "Aquarius", "house": 11, "absolute_lon": 315.0, "degree_within_sign": 15.0, "retrograde": False},
- {"body": "neptune", "sign": "Pisces", "house": 12, "absolute_lon": 345.0, "degree_within_sign": 15.0, "retrograde": False},
- {"body": "pluto", "sign": "Scorpius", "house": 8, "absolute_lon": 225.0, "degree_within_sign": 15.0, "retrograde": False},
- {"body": "chiron", "sign": "Aries", "house": 1, "absolute_lon": 12.0, "degree_within_sign": 12.0, "retrograde": False},
- {"body": "true_node", "sign": "Gemini", "house": 3, "absolute_lon": 75.0, "degree_within_sign": 15.0, "retrograde": False},
- ]
- # ── Element Balance ──────────────────────────────────────────────────
- class TestGetElementBalance:
- def test_basic_counts(self):
- planets = _make_planets()
- result = get_element_balance(planets)
- assert result["total"] == 12
- assert "counts" in result
- assert "percentages" in result
- def test_all_elements_present(self):
- planets = _make_planets()
- result = get_element_balance(planets)
- # Leo(fire), Cancer(water), Leo(fire), Virgo(earth), Aries(fire),
- # Sagittarius(fire), Capricornus(earth), Aquarius(air), Pisces(water),
- # Scorpius(water), Aries(fire), Gemini(air)
- assert result["counts"]["fire"] == 5 # sun, mercury, mars, jupiter, chiron
- assert result["counts"]["earth"] == 2 # venus, saturn
- assert result["counts"]["air"] == 2 # uranus, true_node
- assert result["counts"]["water"] == 3 # moon, neptune, pluto
- def test_percentages_sum_to_100(self):
- planets = _make_planets()
- result = get_element_balance(planets)
- pct_sum = sum(result["percentages"].values())
- assert abs(pct_sum - 100.0) < 1.0
- def test_empty_planets(self):
- result = get_element_balance([])
- assert result["total"] == 0
- assert all(v == 0 for v in result["counts"].values())
- # ── Modality Balance ─────────────────────────────────────────────────
- class TestGetModalityBalance:
- def test_basic_counts(self):
- planets = _make_planets()
- result = get_modality_balance(planets)
- assert result["total"] == 12
- assert "counts" in result
- assert "percentages" in result
- def test_modalities_present(self):
- planets = _make_planets()
- result = get_modality_balance(planets)
- # Leo(fixed), Cancer(cardinal), Leo(fixed), Virgo(mutable), Aries(cardinal),
- # Sagittarius(mutable), Capricornus(cardinal), Aquarius(fixed), Pisces(mutable),
- # Scorpius(fixed), Aries(cardinal), Gemini(mutable)
- assert result["counts"]["cardinal"] == 4 # moon, mars, saturn, chiron
- assert result["counts"]["fixed"] == 4 # sun, mercury, uranus, pluto
- assert result["counts"]["mutable"] == 4 # venus, jupiter, neptune, true_node
- def test_empty_planets(self):
- result = get_modality_balance([])
- assert result["total"] == 0
- # ── Hemisphere Emphasis ──────────────────────────────────────────────
- class TestGetHemisphereEmphasis:
- def test_basic_counts(self):
- planets = _make_planets()
- result = get_hemisphere_emphasis(planets)
- assert sum(result.values()) == 24 # 12 planets * 2 (each counted in upper/lower AND east/west)
- def test_upper_lower_sum(self):
- planets = _make_planets()
- result = get_hemisphere_emphasis(planets)
- assert result["upper"] + result["lower"] == 12
- def test_east_west_sum(self):
- planets = _make_planets()
- result = get_hemisphere_emphasis(planets)
- assert result["east"] + result["west"] == 12
- # ── Stellium Detection ───────────────────────────────────────────────
- class TestDetectStelliums:
- def test_no_stellium(self):
- planets = _make_planets()
- result = detect_stelliums(planets)
- assert len(result) == 0
- def test_sign_stellium(self):
- planets = _make_stellium_planets()
- result = detect_stelliums(planets)
- sign_stelliums = [s for s in result if s["type"] == "sign"]
- assert len(sign_stelliums) >= 1
- leo_stellium = [s for s in sign_stelliums if s["key"] == "Leo"]
- assert len(leo_stellium) == 1
- assert set(leo_stellium[0]["planets"]) == {"sun", "moon", "mercury"}
- def test_house_stellium(self):
- planets = _make_stellium_planets()
- result = detect_stelliums(planets)
- house_stelliums = [s for s in result if s["type"] == "house"]
- assert len(house_stelliums) >= 1
- h5_stellium = [s for s in house_stelliums if s["key"] == "5"]
- assert len(h5_stellium) == 1
- assert set(h5_stellium[0]["planets"]) == {"sun", "moon", "mercury"}
- # ── Empty Houses ─────────────────────────────────────────────────────
- class TestGetEmptyHouses:
- def test_some_empty(self):
- planets = _make_planets()
- result = get_empty_houses(planets)
- occupied = {p["house"] for p in planets}
- expected = sorted(h for h in range(1, 13) if h not in occupied)
- assert result == expected
- def test_all_occupied(self):
- # Create planets in all 12 houses
- planets = [{"body": f"p{i}", "house": i} for i in range(1, 13)]
- result = get_empty_houses(planets)
- assert result == []
- # ── Chart Ruler ──────────────────────────────────────────────────────
- class TestGetChartRuler:
- def test_aries_asc_ruler_is_mars(self):
- planets = _make_planets()
- result = get_chart_ruler("Aries", planets)
- assert result is not None
- assert result["body"] == "mars"
- assert result["sign"] == "Aries"
- def test_leo_asc_ruler_is_sun(self):
- planets = _make_planets()
- result = get_chart_ruler("Leo", planets)
- assert result is not None
- assert result["body"] == "sun"
- def test_scorpius_asc_modern(self):
- planets = _make_planets()
- result = get_chart_ruler("Scorpius", planets)
- assert result is not None
- assert result["body"] == "pluto"
- def test_scorpius_asc_traditional(self):
- planets = _make_planets()
- result = get_chart_ruler("Scorpius", planets, traditional=True)
- assert result is not None
- assert result["body"] == "mars"
- def test_ruler_not_found(self):
- planets = [{"body": "sun", "sign": "Aries", "house": 1}]
- result = get_chart_ruler("Aries", planets)
- assert result is None
- def test_ruler_has_retrograde(self):
- planets = _make_planets()
- result = get_chart_ruler("Sagittarius", planets)
- assert result is not None
- assert result["body"] == "jupiter"
- assert result["retrograde"] is True
- # ── House Rulers ─────────────────────────────────────────────────────
- class TestGetHouseRulers:
- def test_returns_12_entries(self):
- planets = _make_planets()
- houses = calculate_houses(6.0, 45.0, "placidus")
- result = get_house_rulers(houses, planets)
- assert len(result) == 12
- def test_each_has_required_keys(self):
- planets = _make_planets()
- houses = calculate_houses(6.0, 45.0, "placidus")
- result = get_house_rulers(houses, planets)
- for entry in result:
- assert "house" in entry
- assert "cusp_sign" in entry
- assert "ruler" in entry
- assert "ruler_sign" in entry
- assert "ruler_house" in entry
- assert "ruler_retrograde" in entry
- # ── Group Planets by House ───────────────────────────────────────────
- class TestGroupPlanetsByHouse:
- def test_grouping(self):
- planets = _make_planets()
- result = group_planets_by_house(planets)
- assert 5 in result # sun, mercury in house 5
- assert set(result[5]) == {"sun", "mercury"}
- def test_all_planets_grouped(self):
- planets = _make_planets()
- result = group_planets_by_house(planets)
- total = sum(len(v) for v in result.values())
- assert total == 12
- # ── Group Planets by Sign ────────────────────────────────────────────
- class TestGroupPlanetsBySign:
- def test_grouping(self):
- planets = _make_planets()
- result = group_planets_by_sign(planets)
- assert "Leo" in result
- assert set(result["Leo"]) == {"sun", "mercury"}
- def test_all_planets_grouped(self):
- planets = _make_planets()
- result = group_planets_by_sign(planets)
- total = sum(len(v) for v in result.values())
- assert total == 12
- # ── House Type Counts ────────────────────────────────────────────────
- class TestGetHouseTypeCounts:
- def test_counts(self):
- planets = _make_planets()
- result = get_house_type_counts(planets)
- assert result["angular"] + result["succedent"] + result["cadent"] == 12
- def test_angular_count(self):
- planets = _make_planets()
- result = get_house_type_counts(planets)
- # mars in h1, chiron in h1, moon in h4, saturn in h10 = 4 angular
- assert result["angular"] == 4
- # ── Retrograde Planets ───────────────────────────────────────────────
- class TestGetRetrogradePlanets:
- def test_retrograde_list(self):
- planets = _make_planets()
- result = get_retrograde_planets(planets)
- names = [p["body"] for p in result]
- assert "jupiter" in names
- assert "pluto" in names
- def test_no_retrogrades(self):
- planets = [{"body": "sun", "retrograde": False}]
- result = get_retrograde_planets(planets)
- assert len(result) == 0
- def test_includes_sign_and_house(self):
- planets = _make_planets()
- result = get_retrograde_planets(planets)
- jupiter = [p for p in result if p["body"] == "jupiter"][0]
- assert jupiter["sign"] == "Sagittarius"
- assert jupiter["house"] == 9
- # ── Nodal Axis ───────────────────────────────────────────────────────
- class TestGetNodalAxis:
- def test_north_node_found(self):
- planets = _make_planets()
- result = get_nodal_axis(planets)
- assert result["north_node"] is not None
- assert result["north_node"]["sign"] == "Gemini"
- def test_south_node_is_opposite(self):
- planets = _make_planets()
- result = get_nodal_axis(planets)
- assert result["south_node"] is not None
- # Gemini opposite = Sagittarius
- assert result["south_node"]["sign"] == "Sagittarius"
- def test_with_houses(self):
- planets = _make_planets()
- houses = calculate_houses(6.0, 45.0, "placidus")
- result = get_nodal_axis(planets, houses)
- assert result["north_node"]["house"] is not None
- assert result["south_node"]["house"] is not None
- def test_no_node(self):
- planets = [{"body": "sun", "sign": "Aries"}]
- result = get_nodal_axis(planets)
- assert result["north_node"] is None
- assert result["south_node"] is None
- # ── Saturn Info ──────────────────────────────────────────────────────
- class TestGetSaturnInfo:
- def test_saturn_found(self):
- planets = _make_planets()
- result = get_saturn_info(planets)
- assert result is not None
- assert result["body"] == "saturn"
- assert result["sign"] == "Capricornus"
- assert result["house"] == 10
- def test_saturn_retrograde(self):
- planets = _make_planets()
- result = get_saturn_info(planets)
- assert result["retrograde"] is False
- def test_no_saturn(self):
- planets = [{"body": "sun", "sign": "Aries"}]
- result = get_saturn_info(planets)
- assert result is None
- # ── Pluto Polarity Point ─────────────────────────────────────────────
- class TestGetPlutoPolarityPoint:
- def test_ppp_is_opposite(self):
- planets = _make_planets()
- result = get_pluto_polarity_point(planets)
- assert result is not None
- # Pluto at 225° (Scorpius 15°), PPP at 45° (Taurus 15°)
- assert result["sign"] == "Taurus"
- def test_ppp_with_houses(self):
- planets = _make_planets()
- houses = calculate_houses(6.0, 45.0, "placidus")
- result = get_pluto_polarity_point(planets, houses)
- assert result["house"] is not None
- def test_no_pluto(self):
- planets = [{"body": "sun", "sign": "Aries"}]
- result = get_pluto_polarity_point(planets)
- assert result is None
- # ── Part of Fortune ──────────────────────────────────────────────────
- class TestGetPartOfFortune:
- def test_basic_calculation(self):
- # ASC at 90°, Sun at 135°, Moon at 105°
- # PoF = 90 + 105 - 135 = 60° (Gemini)
- result = get_part_of_fortune(90.0, 135.0, 105.0)
- assert result["sign"] == "Gemini"
- def test_wraparound(self):
- # ASC at 10°, Sun at 350°, Moon at 350°
- # PoF = 10 + 350 - 350 = 10° (Aries)
- result = get_part_of_fortune(10.0, 350.0, 350.0)
- assert result["sign"] == "Aries"
- def test_with_houses(self):
- result = get_part_of_fortune(90.0, 135.0, 105.0, houses=calculate_houses(6.0, 45.0, "placidus"))
- assert result["house"] is not None
- # ── 12th House Analysis ──────────────────────────────────────────────
- class TestGetTwelfthHouseAnalysis:
- def test_cusp_sign(self):
- planets = _make_planets()
- houses = calculate_houses(6.0, 45.0, "placidus")
- result = get_twelfth_house_analysis(houses, planets)
- assert result["cusp_sign"] is not None
- def test_planets_in_12th(self):
- planets = _make_planets()
- houses = calculate_houses(6.0, 45.0, "placidus")
- result = get_twelfth_house_analysis(houses, planets)
- # neptune is in house 12 in our test data
- names = [p["body"] for p in result["planets"]]
- assert "neptune" in names
- def test_ruler(self):
- planets = _make_planets()
- houses = calculate_houses(6.0, 45.0, "placidus")
- result = get_twelfth_house_analysis(houses, planets)
- assert result["ruler"] is not None
- assert result["ruler"]["ruler"] is not None
- # ── Aspect Filtering ─────────────────────────────────────────────────
- class TestFilterAspectsByPlanets:
- def test_filter_to_planets(self):
- aspects = [
- {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0},
- {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 3.0},
- {"body1": "venus", "body2": "mars", "aspect": "trine", "orb": 1.0},
- ]
- result = filter_aspects_by_planets(aspects, {"sun"})
- assert len(result) == 2
- def test_filter_with_aspect_types(self):
- aspects = [
- {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0},
- {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 3.0},
- {"body1": "sun", "body2": "jupiter", "aspect": "trine", "orb": 1.0},
- ]
- result = filter_aspects_by_planets(aspects, {"sun"}, {"conjunction", "square"})
- assert len(result) == 2
- def test_strips_prefixes(self):
- aspects = [
- {"body1": "transit_saturn", "body2": "natal_true_node", "aspect": "conjunction", "orb": 1.0},
- {"body1": "p1_saturn", "body2": "p2_sun", "aspect": "opposition", "orb": 2.0},
- ]
- result = filter_aspects_by_planets(aspects, {"saturn"})
- assert len(result) == 2
- def test_no_match(self):
- aspects = [
- {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0},
- ]
- result = filter_aspects_by_planets(aspects, {"pluto"})
- assert len(result) == 0
- class TestGetNatalAspectsToPlanets:
- def test_sorted_by_orb(self):
- aspects = [
- {"body1": "sun", "body2": "true_node", "aspect": "conjunction", "orb": 3.0},
- {"body1": "moon", "body2": "true_node", "aspect": "square", "orb": 1.0},
- {"body1": "mars", "body2": "true_node", "aspect": "opposition", "orb": 2.0},
- ]
- result = get_natal_aspects_to_planets(aspects, {"true_node"})
- orbs = [a["orb"] for a in result]
- assert orbs == sorted(orbs)
- def test_filter_to_hard_aspects(self):
- aspects = [
- {"body1": "sun", "body2": "true_node", "aspect": "conjunction", "orb": 1.0},
- {"body1": "moon", "body2": "true_node", "aspect": "trine", "orb": 2.0},
- ]
- result = get_natal_aspects_to_planets(aspects, {"true_node"}, {"conjunction", "square", "opposition"})
- assert len(result) == 1
- assert result[0]["aspect"] == "conjunction"
- # ── Aspect Pattern Detection ─────────────────────────────────────────
- class TestDetectAspectPatterns:
- def test_no_patterns(self):
- """Random aspects with no patterns."""
- planets = _make_planets()
- aspects = [
- {"body1": "sun", "body2": "moon", "aspect": "conjunction", "orb": 2.0},
- {"body1": "venus", "body2": "mars", "aspect": "trine", "orb": 3.0},
- ]
- result = detect_aspect_patterns(planets, aspects)
- assert len(result) == 0
- def test_t_square_detection(self):
- """Sun-Moon opposition, both square Mars = T-square with Mars apex."""
- planets = [
- {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 0.0},
- {"body": "moon", "sign": "Libra", "house": 7, "absolute_lon": 180.0},
- {"body": "mars", "sign": "Capricornus", "house": 10, "absolute_lon": 270.0},
- ]
- aspects = [
- {"body1": "sun", "body2": "moon", "aspect": "opposition", "orb": 0.0},
- {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 0.0},
- {"body1": "moon", "body2": "mars", "aspect": "square", "orb": 0.0},
- ]
- result = detect_aspect_patterns(planets, aspects)
- t_squares = [p for p in result if p["type"] == "T-square"]
- assert len(t_squares) == 1
- assert t_squares[0]["apex"] == "mars"
- assert set(t_squares[0]["planets"]) == {"sun", "moon", "mars"}
- assert t_squares[0]["modality"] == "cardinal"
- def test_grand_trine_detection(self):
- """Three planets in trine, same element."""
- planets = [
- {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 15.0},
- {"body": "jupiter", "sign": "Leo", "house": 5, "absolute_lon": 135.0},
- {"body": "saturn", "sign": "Sagittarius", "house": 9, "absolute_lon": 255.0},
- ]
- aspects = [
- {"body1": "sun", "body2": "jupiter", "aspect": "trine", "orb": 0.0},
- {"body1": "jupiter", "body2": "saturn", "aspect": "trine", "orb": 0.0},
- {"body1": "sun", "body2": "saturn", "aspect": "trine", "orb": 0.0},
- ]
- result = detect_aspect_patterns(planets, aspects)
- gt = [p for p in result if p["type"] == "Grand Trine"]
- assert len(gt) == 1
- assert set(gt[0]["planets"]) == {"sun", "jupiter", "saturn"}
- assert gt[0]["element"] == "fire"
- def test_yod_detection(self):
- """Two planets in sextile, both quincunx a third."""
- planets = [
- {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 15.0},
- {"body": "jupiter", "sign": "Gemini", "house": 3, "absolute_lon": 75.0},
- {"body": "saturn", "sign": "Scorpius", "house": 8, "absolute_lon": 225.0},
- ]
- aspects = [
- {"body1": "sun", "body2": "jupiter", "aspect": "sextile", "orb": 0.0},
- {"body1": "sun", "body2": "saturn", "aspect": "quincunx", "orb": 0.0},
- {"body1": "jupiter", "body2": "saturn", "aspect": "quincunx", "orb": 0.0},
- ]
- result = detect_aspect_patterns(planets, aspects)
- yods = [p for p in result if p["type"] == "Yod"]
- assert len(yods) == 1
- assert yods[0]["apex"] == "saturn"
- assert set(yods[0]["planets"]) == {"sun", "jupiter", "saturn"}
- def test_orb_limit_filters_patterns(self):
- """Patterns with orbs exceeding the limit should not be detected."""
- planets = [
- {"body": "sun", "sign": "Aries", "house": 1, "absolute_lon": 0.0},
- {"body": "moon", "sign": "Libra", "house": 7, "absolute_lon": 180.0},
- {"body": "mars", "sign": "Capricornus", "house": 10, "absolute_lon": 270.0},
- ]
- aspects = [
- {"body1": "sun", "body2": "moon", "aspect": "opposition", "orb": 0.0},
- {"body1": "sun", "body2": "mars", "aspect": "square", "orb": 10.0}, # too wide
- {"body1": "moon", "body2": "mars", "aspect": "square", "orb": 10.0}, # too wide
- ]
- result = detect_aspect_patterns(planets, aspects, orb_limit=8.0)
- t_squares = [p for p in result if p["type"] == "T-square"]
- assert len(t_squares) == 0
- # ── Chart Shape Detection ────────────────────────────────────────────
- class TestDetectChartShape:
- def test_bundle(self):
- """All planets within 120°."""
- planets = [
- {"body": "sun", "absolute_lon": 10.0},
- {"body": "moon", "absolute_lon": 30.0},
- {"body": "mars", "absolute_lon": 80.0},
- {"body": "venus", "absolute_lon": 100.0},
- ]
- result = detect_chart_shape(planets)
- assert result["shape"] == "bundle"
- def test_bowl(self):
- """All planets within 180°."""
- planets = [
- {"body": "sun", "absolute_lon": 0.0},
- {"body": "moon", "absolute_lon": 45.0},
- {"body": "mars", "absolute_lon": 90.0},
- {"body": "venus", "absolute_lon": 150.0},
- ]
- result = detect_chart_shape(planets)
- assert result["shape"] == "bowl"
- def test_splash(self):
- """Planets spread around the full chart."""
- planets = [
- {"body": "sun", "absolute_lon": 0.0},
- {"body": "moon", "absolute_lon": 120.0},
- {"body": "mars", "absolute_lon": 240.0},
- ]
- result = detect_chart_shape(planets)
- assert result["shape"] == "splash"
- def test_single_planet(self):
- result = detect_chart_shape([{"body": "sun", "absolute_lon": 0.0}])
- assert result["shape"] == "unknown"
- def test_empty(self):
- result = detect_chart_shape([])
- assert result["shape"] == "unknown"
- def test_largest_gap_reported(self):
- planets = [
- {"body": "sun", "absolute_lon": 0.0},
- {"body": "moon", "absolute_lon": 10.0},
- {"body": "mars", "absolute_lon": 20.0},
- ]
- result = detect_chart_shape(planets)
- assert result["largest_gap"] > 0
- assert result["occupied_arc"] > 0
|