fix(ephemeris): include nutation in the sidereal frame

恒星黄道改为与 Swiss Ephemeris FLG_SIDEREAL 同一套含章动岁差。
产品默认路径不再用平岁差去减含章动的视位置。上升和宫头用同一个岁差。
校正身份升到 rectification-v5-matrix-scoring-12。
BUG-1275。未推送,未部署。v5 先验头名三格下降 1.3 个百分点,不调参。
This commit is contained in:
jesse-ux
2026-10-08 18:42:45 +08:00
parent 1a8c97f64a
commit e5f2a75183
37 changed files with 52297 additions and 113 deletions
+158
View File
@@ -0,0 +1,158 @@
{
"pyjhora_version": "4.8.7",
"generator": "python scripts/research/freeze_sidereal_nutation_pyjhora_fixture.py",
"generated_on": "2026-10-08",
"node_mode": "true",
"max_abs_difference_degrees": 0.014454176402182384,
"note": "Frozen from drik.sidereal_longitude. PyJHora 4.8.7 planetary_positions raises AttributeError because planet_list is a dict. Rahu body is const._RAHU (Swiss 11). Differences are recorded, not a second runtime.",
"cases": {
"einstein_1879_aa": {
"ayanamsa": {
"lahiri": {
"Sun": 331.33023005511546,
"Moon": 232.22620394496533,
"Mars": 274.74079089624144,
"Mercury": 340.96616653121913,
"Jupiter": 305.3154892687192,
"Venus": 354.8104387336731,
"Saturn": 342.02191209074596,
"Rahu": 280.5518346721791,
"Ketu": 100.5518346721791
},
"raman": {
"Sun": 332.77653156215774,
"Moon": 233.67250545200758,
"Mars": 276.1870924032838,
"Mercury": 342.4124680382614,
"Jupiter": 306.76179077576154,
"Venus": 356.2567402407154,
"Saturn": 343.4682135977883,
"Rahu": 281.99813617922143,
"Ketu": 101.99813617922143
},
"kp": {
"Sun": 331.42708256215775,
"Moon": 232.3230564520076,
"Mars": 274.83764340328383,
"Mercury": 341.0630190382614,
"Jupiter": 305.41234177576155,
"Venus": 354.9072912407154,
"Saturn": 342.1187645977883,
"Rahu": 280.64868717922144,
"Ketu": 100.64868717922144
}
}
},
"steve_jobs_1955_aa": {
"ayanamsa": {
"lahiri": {
"Sun": 312.51819523181405,
"Moon": 344.51168181221095,
"Mars": 5.862334338243065,
"Mercury": 291.12589362817465,
"Jupiter": 87.27055150216493,
"Venus": 267.94211928370015,
"Saturn": 207.92738390514677,
"Rahu": 250.17570529839801,
"Ketu": 70.17570529839804
},
"raman": {
"Sun": 313.96449660578025,
"Moon": 345.9579831861772,
"Mars": 7.308635712209287,
"Mercury": 292.57219500214086,
"Jupiter": 88.71685287613116,
"Venus": 269.3884206576664,
"Saturn": 209.373685279113,
"Rahu": 251.62200667236425,
"Ketu": 71.62200667236425
},
"kp": {
"Sun": 312.61504760578026,
"Moon": 344.6085341861772,
"Mars": 5.959186712209296,
"Mercury": 291.22274600214087,
"Jupiter": 87.36740387613116,
"Venus": 268.03897165766637,
"Saturn": 208.02423627911304,
"Rahu": 250.27255767236423,
"Ketu": 70.2725576723642
}
}
},
"barack_obama_1961_aa": {
"ayanamsa": {
"lahiri": {
"Sun": 109.23518945852025,
"Moon": 40.0394553657099,
"Mars": 149.26640544096526,
"Mercury": 99.02772319705292,
"Jupiter": 277.53729754967225,
"Venus": 68.4779756797172,
"Saturn": 272.0088308190876,
"Rahu": 123.98114859107521,
"Ketu": 303.9811485910752
},
"raman": {
"Sun": 110.68149082295898,
"Moon": 41.48575673014863,
"Mars": 150.712706805404,
"Mercury": 100.47402456149166,
"Jupiter": 278.983598914111,
"Venus": 69.92427704415594,
"Saturn": 273.4551321835263,
"Rahu": 125.42744995551394,
"Ketu": 305.4274499555139
},
"kp": {
"Sun": 109.332041822959,
"Moon": 40.136307730148644,
"Mars": 149.36325780540403,
"Mercury": 99.12457556149167,
"Jupiter": 277.634149914111,
"Venus": 68.57482804415595,
"Saturn": 272.1056831835263,
"Rahu": 124.07800095551394,
"Ketu": 304.07800095551397
}
}
},
"fictional_reader_main": {
"ayanamsa": {
"lahiri": {
"Sun": 352.9959273272877,
"Moon": 260.49801426025005,
"Mars": 68.40348564673326,
"Mercury": 4.987951885744678,
"Jupiter": 99.89939212201921,
"Venus": 29.231572538839952,
"Saturn": 281.8223083068704,
"Rahu": 270.37602435184266,
"Ketu": 90.37602435184266
},
"raman": {
"Sun": 354.44222865141984,
"Moon": 261.9443155843822,
"Mars": 69.84978697086541,
"Mercury": 6.434253209876828,
"Jupiter": 101.34569344615136,
"Venus": 30.677873862972103,
"Saturn": 283.2686096310025,
"Rahu": 271.8223256759748,
"Ketu": 91.82232567597481
},
"kp": {
"Sun": 353.0927796514199,
"Moon": 260.59486658438215,
"Mars": 68.5003379708654,
"Mercury": 5.084804209876837,
"Jupiter": 99.99624444615137,
"Venus": 29.328424862972106,
"Saturn": 281.9191606310025,
"Rahu": 270.47287667597476,
"Ketu": 90.47287667597476
}
}
}
}
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -240,7 +240,8 @@ def test_full_reading_accepts_second_and_preserves_birth_time_precision() -> Non
assert result["birth_info"]["second"] == 20
assert result["modules"]["chart"]["birth_info"]["time"] == "19:45:20"
assert result["modules"]["dasha"]["birth_time"] == "19:45:20"
assert result["modules"]["dasha"]["timeline"][0]["start_datetime"].startswith("1952-03-19T20:24:47")
# 原值: 1952-03-19T20:24:47;新值: 1952-03-22T11:42:07;原因: 恒星黄道补上章动后月亮位置移动约一个章动,第一段大运起点跟着移(BUG-1275)。
assert result["modules"]["dasha"]["timeline"][0]["start_datetime"].startswith("1952-03-22T11:42:07")
def test_full_reading_reports_ayanamsa_metadata_and_ai_prompt_pack() -> None:
@@ -31,9 +31,9 @@ def test_fixed_scoring_identity_is_exposed_without_changing_input_contract():
ALGORITHM_VERSION, INPUT_CONTRACT_VERSION, calculation_spec, sha256,
)
# 原值: "rectification-v5-matrix-scoring-10";新值: "...-scoring-11";原因: D5/D11 映射改变学业与财务分盘得分
# (BUG-1260),同输入分数变了,按 BUG-1181 升算法身份;输入合同 v5 与日期窗合同不变。
assert engine_scoring_versions()["algorithm_version"] == ALGORITHM_VERSION == "rectification-v5-matrix-scoring-11"
# 原值: "rectification-v5-matrix-scoring-11";新值: "...-scoring-12";原因: 恒星黄道补上章动后同输入分数变了
# (BUG-1275),按 BUG-1181 升算法身份;输入合同 v5 与日期窗合同不变。
assert engine_scoring_versions()["algorithm_version"] == ALGORITHM_VERSION == "rectification-v5-matrix-scoring-12"
assert INPUT_CONTRACT_VERSION == "rectification-calculation-spec-v5"
request = {"birth_date": "2000-03-01", "start_time": "00:00", "end_time": "00:02",
"lat": 0.0, "lon": 0.0, "tz": 0.0, "events": []}
+33 -10
View File
@@ -30,11 +30,17 @@ rectification-candidate-policy-v3 -> v4, event_quality / domain_diversity
minimum 3/2 -> 4/3, and the representative candidate UUID (its namespace
carries the policy version). v2 is frozen by sha256 below.
Current golden tests/golden/rectification_engine_memoization_v4.json
Golden tests/golden/rectification_engine_memoization_v4.json
(2026-10-07, BUG-1260) was written by ``write_golden`` after the D5/D11
mappings changed education and finance varga points for this same request.
The algorithm is rectification-v5-matrix-scoring-11. Policy stays v4.
v3 stays byte-for-byte and is pinned below.
v3 stays byte-for-byte and is pinned below. v4 is now frozen the same way.
Current golden tests/golden/rectification_engine_memoization_v5.json
(2026-10-08, BUG-1275) was written by ``write_golden`` after sidereal
longitudes included nutation. The same request moves (12:00 8.5289 -> 8.5336).
The algorithm is rectification-v5-matrix-scoring-12. Policy stays v4.
v4 stays byte-for-byte and is pinned below.
Do not compare that payload with a whole-structure ``==``. Cross-machine
libm / pyswisseph rounding already drifted ``margin_percent`` by 1.1e-3
@@ -74,7 +80,10 @@ import scripts.rectification.scoring_service as scoring_service
import shadbala
ROOT = Path(__file__).resolve().parents[1]
GOLDEN_PATH = ROOT / "tests" / "golden" / "rectification_engine_memoization_v4.json"
GOLDEN_PATH = ROOT / "tests" / "golden" / "rectification_engine_memoization_v5.json"
V4_GOLDEN_PATH = ROOT / "tests" / "golden" / "rectification_engine_memoization_v4.json"
V4_GOLDEN_SHA256 = "8491093281fe9767defea1b46d325237f1a4c6c0d4f076b9a77f07b985b526c3"
V4_SOURCE_COMMIT = "db68e90816d462b0c17b4f84a0d19df83010fbce"
V3_GOLDEN_PATH = ROOT / "tests" / "golden" / "rectification_engine_memoization_v3.json"
V3_GOLDEN_SHA256 = "7064f30bea3a7bcd8058fdcb6b52484a96a09767c0c289e0d71f3e2368fc8d45"
V3_SOURCE_COMMIT = "7fb165ecf66ba1726f2fc74ffa2353fadc65ba39"
@@ -83,15 +92,15 @@ PREVIOUS_GOLDEN_SHA256 = "abb15efae4d86387d930cd463b82a22fe95ccd1779e85ca6cc4cdf
PREVIOUS_SOURCE_COMMIT = "57782aea8ae28f5dd165a08d221ec27dbef391f8"
HISTORICAL_GOLDEN_PATH = ROOT / "tests" / "golden" / "rectification_engine_memoization_v1.json"
HISTORICAL_GOLDEN_SHA256 = "6266e448d7dad204b264cbe8f9bcf4c36e02286794764a94edb9e2ce4d0e3015"
# 原值: rectification-v5-matrix-scoring-10;新值: ...-scoring-11;原因: D5/D11 改变同输入分数(BUG-1260)。
CURRENT_ALGORITHM_VERSION = "rectification-v5-matrix-scoring-11"
# 原值: rectification-v5-matrix-scoring-11;新值: ...-scoring-12;原因: 章动修正改变同输入分数(BUG-1275)。
CURRENT_ALGORITHM_VERSION = "rectification-v5-matrix-scoring-12"
FROZEN_TODAY = date(2026, 9, 16)
TIMING_KEYS = frozenset({"column_compare_ms"})
HISTORICAL_SOURCE_COMMIT = "a8d29d1b6cc37ff865ddec6c8bccdf9aa889ee53"
CURRENT_POLICY_VERSION = "rectification-candidate-policy-v4"
# D5/D11 mapping commit that moves these scores (BUG-1260). The v4 file is
# written later on this branch; v3 keeps its own source commit below.
SOURCE_COMMIT = "db68e90816d462b0c17b4f84a0d19df83010fbce"
# Nutation fix commit that moves these scores (BUG-1275). Replaced after that
# commit exists; v4 keeps V4_SOURCE_COMMIT.
SOURCE_COMMIT = "PENDING_NUTATION_COMMIT"
CACHE_LAYER_KEYS = (
"ashtakavarga_result",
"shadbala_result",
@@ -275,7 +284,7 @@ def _contexts_with_layer_cache_cleared(contexts: list[dict[str, Any]]) -> list[d
def _assert_dated_payload_against_golden(actual: dict[str, Any], expected: dict[str, Any]) -> None:
"""Compare with the scoring-11 / policy-v4 golden; identity is spelled out, not trusted."""
"""Compare with the scoring-12 / policy-v4 golden; identity is spelled out, not trusted."""
metadata = {
"candidate_window_contract": "dated-v1",
"candidate_intervals": [{"start_at": "1990-01-01T12:00", "end_at": "1990-01-01T12:02"}],
@@ -348,9 +357,23 @@ def test_scoring_10_golden_is_frozen_and_scores_moved() -> None:
raw = V3_GOLDEN_PATH.read_bytes()
assert hashlib.sha256(raw).hexdigest() == V3_GOLDEN_SHA256
previous = json.loads(raw)
current = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
scoring11 = json.loads(V4_GOLDEN_PATH.read_text(encoding="utf-8"))
assert previous["source_commit"] == V3_SOURCE_COMMIT
assert previous["candidate_feature_snapshot"]["algorithm_version"] == "rectification-v5-matrix-scoring-10"
assert scoring11["candidate_feature_snapshot"]["algorithm_version"] == "rectification-v5-matrix-scoring-11"
assert scoring11["source_commit"] == V4_SOURCE_COMMIT
with pytest.raises(AssertionError):
_assert_memoization_payloads(scoring11, previous)
def test_scoring_11_golden_is_frozen_and_scores_moved() -> None:
"""BUG-1275: v4 stays byte-identical; nutation moved this request, hence scoring-12."""
raw = V4_GOLDEN_PATH.read_bytes()
assert hashlib.sha256(raw).hexdigest() == V4_GOLDEN_SHA256
previous = json.loads(raw)
current = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
assert previous["source_commit"] == V4_SOURCE_COMMIT
assert previous["candidate_feature_snapshot"]["algorithm_version"] == "rectification-v5-matrix-scoring-11"
assert current["candidate_feature_snapshot"]["algorithm_version"] == CURRENT_ALGORITHM_VERSION
assert current["source_commit"] == SOURCE_COMMIT
with pytest.raises(AssertionError):
+2 -2
View File
@@ -21,8 +21,8 @@ class RelativeSupportScaleTest(unittest.TestCase):
# 原值: policy-v3;新值: policy-v4;原因: R1 区间门槛 4 件 3 域(BUG-1193),相对支持口径不变。
self.assertEqual(POLICY_VERSION, "rectification-candidate-policy-v4")
# Explicit date windows: scoring identity -8 -> -9, not a prior/policy change.
# 原值: -10;新值: -11;原因: D5/D11 改变同输入分数(BUG-1260),策略 v4 与相对支持口径不变。
self.assertEqual(ALGORITHM_VERSION, "rectification-v5-matrix-scoring-11")
# 原值: -11;新值: -12;原因: 章动修正改变同输入分数(BUG-1275),策略 v4 与相对支持口径不变。
self.assertEqual(ALGORITHM_VERSION, "rectification-v5-matrix-scoring-12")
self.assertEqual(RELATIVE_SUPPORT_MODE, "proportional")
def test_offset_top_two_lead_is_at_least_proportional(self) -> None:
@@ -38,6 +38,11 @@ IDENTITY_FORBIDDEN_PREFIXES = (
# 原因: ERR-110,D5/D11 与 scoring-11 改了冻结文件,另存新记录,旧记录留在清单外
"docs/research/sealed_holdout_rerun_upstream_sync6_2026_10_07",
"docs/research/reported_offset_upstream_sync6_2026_10_07",
# 原值: 排除到 upstream_sync6_2026_10_07
# 新值: 再排除 ephemeris_nutation_2026_10_08 两份冻结路径
# 原因: ERR-110,章动修正与 scoring-12 改了冻结身份,另存新记录,旧记录留在清单外(BUG-1275)
"docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08",
"docs/research/reported_offset_ephemeris_nutation_2026_10_08",
"docs/research/history/",
"artifacts/",
)
+138
View File
@@ -0,0 +1,138 @@
"""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