Files
Jyotisha/tests/test_sidereal_nutation_frame.py
T
jesse-ux e5f2a75183 fix(ephemeris): include nutation in the sidereal frame
恒星黄道改为与 Swiss Ephemeris FLG_SIDEREAL 同一套含章动岁差。
产品默认路径不再用平岁差去减含章动的视位置。上升和宫头用同一个岁差。
校正身份升到 rectification-v5-matrix-scoring-12。
BUG-1275。未推送,未部署。v5 先验头名三格下降 1.3 个百分点,不调参。
2026-10-08 18:42:45 +08:00

139 lines
5.8 KiB
Python

"""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