"""BUG-1275: sidereal longitudes use the same nutation as FLG_SIDEREAL. PyJHora 4.8.7 answers are frozen in tests/fixtures/sidereal_nutation_pyjhora_487.json. This file does not import jhora. The offline generator is scripts/research/freeze_sidereal_nutation_pyjhora_fixture.py. """ from __future__ import annotations import json from pathlib import Path import pytest import swisseph as swe from scripts.ayanamsa_utils import apply_ayanamsa from scripts.jyotish_engine import ( POSITION_MODES, _calc_sidereal_planets_for_jd, _sidereal_calculation_profile, compute_chart_data, ) ROOT = Path(__file__).resolve().parents[1] PUBLIC_CASES = ROOT / "references" / "public_oracle_cases.json" SPECIAL_FIXTURE = ROOT / "tests" / "fixtures" / "special_lagnas_pyjhora_487.json" PYJHORA_FIXTURE = ROOT / "tests" / "fixtures" / "sidereal_nutation_pyjhora_487.json" CASE_IDS = ( "steve_jobs_1955_aa", "barack_obama_1961_aa", "einstein_1879_aa", "fictional_reader_main", ) AYANAMSAS = ("lahiri", "raman", "kp") PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu") LIMIT = 1e-6 def _births() -> dict[str, dict]: public = json.loads(PUBLIC_CASES.read_text(encoding="utf-8")) births = {case["id"]: case["birth"] for case in public["cases"]} special = json.loads(SPECIAL_FIXTURE.read_text(encoding="utf-8")) births["fictional_reader_main"] = special["cases"]["fictional_reader_main"]["birth"] return births def _jd(birth: dict) -> float: hour = birth["hour"] + birth["minute"] / 60.0 + birth.get("second", 0) / 3600.0 - birth["tz"] return swe.julday(birth["year"], birth["month"], birth["day"], hour) def _delta(left: float, right: float) -> float: return ((left - right + 180.0) % 360.0) - 180.0 def _reference_longitudes(jd: float, ayanamsa: str, node_mode: str) -> dict[str, float]: apply_ayanamsa(ayanamsa) node = swe.TRUE_NODE if node_mode == "true" else swe.MEAN_NODE flags = swe.FLG_SWIEPH | swe.FLG_SPEED | swe.FLG_SIDEREAL bodies = { "Sun": swe.SUN, "Moon": swe.MOON, "Mars": swe.MARS, "Mercury": swe.MERCURY, "Jupiter": swe.JUPITER, "Venus": swe.VENUS, "Saturn": swe.SATURN, "Rahu": node, } longitudes = {name: swe.calc_ut(jd, pid, flags)[0][0] % 360.0 for name, pid in bodies.items()} longitudes["Ketu"] = (longitudes["Rahu"] + 180.0) % 360.0 return longitudes @pytest.mark.parametrize("case_id", CASE_IDS) @pytest.mark.parametrize("ayanamsa", AYANAMSAS) @pytest.mark.parametrize("node_mode", ("mean", "true")) def test_nine_planets_match_swiss_sidereal_flag(case_id, ayanamsa, node_mode): birth = _births()[case_id] jd = _jd(birth) expected = _reference_longitudes(jd, ayanamsa, node_mode) chart, _asc_idx, returned_jd, ayanamsa_value = compute_chart_data( birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"], birth["lat"], birth["lon"], birth["tz"], node_mode=node_mode, second=birth.get("second", 0), ayanamsa_name=ayanamsa, ) assert returned_jd == pytest.approx(jd, abs=1e-6) for name in PLANETS: actual = chart["planets"][name]["degree_raw"] assert abs(_delta(actual, expected[name])) <= LIMIT, name transit, _ = _calc_sidereal_planets_for_jd(jd, node_mode=node_mode, ayanamsa_name=ayanamsa) for name in PLANETS: assert abs(_delta(transit[name]["degree_raw"], expected[name])) <= LIMIT, name cusps, _ascmc = swe.houses(jd, birth["lat"], birth["lon"], b"A") sidereal_cusps, sidereal_ascmc = swe.houses_ex( jd, birth["lat"], birth["lon"], b"A", swe.FLG_SWIEPH | swe.FLG_SIDEREAL, ) for index in range(12): assert abs(_delta((cusps[index] - ayanamsa_value) % 360.0, sidereal_cusps[index] % 360.0)) <= LIMIT assert abs(_delta(chart["ascendant"]["degree_raw"], sidereal_cusps[0] % 360.0)) <= 5.1e-5 _mode, flags, _aya = _sidereal_calculation_profile(jd, "legacy") _mc_cusps, mc_ascmc = swe.houses_ex(jd, birth["lat"], birth["lon"], b"R", flags | swe.FLG_SIDEREAL) assert abs(_delta(sidereal_ascmc[1] % 360.0, mc_ascmc[1] % 360.0)) <= LIMIT def test_legacy_matches_apparent_and_mean_still_drops_nutation(): assert POSITION_MODES == ("legacy", "mean", "apparent") birth = _births()["steve_jobs_1955_aa"] jd = _jd(birth) apply_ayanamsa("lahiri") legacy = _sidereal_calculation_profile(jd, "legacy") apparent = _sidereal_calculation_profile(jd, "apparent") mean = _sidereal_calculation_profile(jd, "mean") assert legacy[0] == "legacy" assert legacy[1] == apparent[1] assert legacy[2] == pytest.approx(apparent[2], abs=0.0) assert mean[1] & swe.FLG_NONUT nutation = swe.calc_ut(jd, swe.ECL_NUT)[0][2] assert abs((legacy[2] - mean[2]) - nutation) <= 1e-6 assert abs((legacy[2] - swe.get_ayanamsa(jd)) - nutation) <= 1e-4 def test_frozen_pyjhora_difference_stays_inside_the_recorded_bound(): payload = json.loads(PYJHORA_FIXTURE.read_text(encoding="utf-8")) assert payload["pyjhora_version"] == "4.8.7" assert payload["node_mode"] == "true" births = _births() worst = 0.0 for case_id in CASE_IDS: birth = births[case_id] for ayanamsa in AYANAMSAS: chart, *_ = compute_chart_data( birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"], birth["lat"], birth["lon"], birth["tz"], node_mode="true", second=birth.get("second", 0), ayanamsa_name=ayanamsa, ) expected = payload["cases"][case_id]["ayanamsa"][ayanamsa] for name in PLANETS: gap = abs(_delta(chart["planets"][name]["degree_raw"], expected[name])) worst = max(worst, gap) assert gap <= payload["max_abs_difference_degrees"] + 1e-9, (case_id, ayanamsa, name) assert worst <= payload["max_abs_difference_degrees"] + 1e-9 assert worst > 0.0