From 85567071c74f6460ec917aebc1011f429b346269 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Thu, 22 Oct 2026 03:37:17 +0800 Subject: [PATCH] =?UTF-8?q?test(dasha):=20Yogini=20=E5=A4=A7=E8=BF=90?= =?UTF-8?q?=E5=AF=B9=E7=85=A7=20PyJHora=EF=BC=8C=E5=B0=8F=E8=BF=90?= =?UTF-8?q?=E5=81=9A=E5=86=85=E9=83=A8=E6=A0=B8=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presley、Spears、Einstein 走接口。大运主星顺序一致,起止日误差在一天内。小运按比例自洽,不跟 PyJHora 等分比。 Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor --- tests/test_dasha_api_engine_parity.py | 168 +++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/tests/test_dasha_api_engine_parity.py b/tests/test_dasha_api_engine_parity.py index dad5a391..82ba876b 100644 --- a/tests/test_dasha_api_engine_parity.py +++ b/tests/test_dasha_api_engine_parity.py @@ -16,7 +16,7 @@ import os import random import re import sys -from datetime import date, datetime +from datetime import date, datetime, timedelta from functools import lru_cache from pathlib import Path @@ -446,6 +446,172 @@ def test_ashtottari_birth_balance_matches_pyjhora_within_one_day() -> None: _public_case("spears", spears, "moon") +YOGINI_PUBLIC_CHARTS = ( + ("presley", { + "year": 1935, + "month": 1, + "day": 8, + "hour": 4, + "minute": 35, + "lat": 34.3, + "lon": -88.4, + "tz": -6.0, + }), + ("spears", { + "year": 1981, + "month": 12, + "day": 2, + "hour": 19, + "minute": 55, + "lat": 31.17, + "lon": -89.18, + "tz": 0.0, + }), + ("einstein", { + "year": 1879, + "month": 3, + "day": 14, + "hour": 11, + "minute": 30, + "lat": 48.4, + "lon": 9.98, + "tz": 0.89, + }), +) + + +def _api_yogini_periods(birth: dict) -> list: + from scripts.domain_calculation_service import compute_chart + + chart = compute_chart({**birth, "second": 0, "node_mode": "mean", "ayanamsa": "lahiri"}) + planets = { + name: {"lon": row["lon"]} + for name, row in chart["planets"].items() + if isinstance(row, dict) and isinstance(row.get("lon"), (int, float)) + } + body = { + "dasha": "yogini", + "year": birth["year"], + "month": birth["month"], + "day": birth["day"], + "hour": birth["hour"], + "minute": birth["minute"], + "second": 0, + "lat": birth["lat"], + "lon": birth["lon"], + "tz": birth["tz"], + "today": "2026-10-22", + "ayanamsa": "lahiri", + "node_mode": "mean", + "planets": planets, + "ascendant": {"lon": chart["ascendant"]["lon"]}, + } + result = _handler()._compute_dasha_system(body) + assert result["precision"] == "engine_module" + return result["periods"] + + +def _pyjhora_yogini_end(row, following) -> date: + if following is not None: + return date(*following[1][:3]) + from jhora import const + + parts = row[1] + hours = float(parts[3]) if len(parts) > 3 else 0.0 + start = datetime(int(parts[0]), int(parts[1]), int(parts[2])) + timedelta(hours=hours) + return (start + timedelta(days=float(row[2]) * float(const.sidereal_year))).date() + + +def _assert_yogini_majors_against_pyjhora(name: str, periods: list, py_rows: list) -> None: + assert len(py_rows) >= 24, name + assert len(periods) >= 24, name + mismatches = [] + for index, (engine, oracle) in enumerate(zip(periods[:24], py_rows[:24])): + engine_lord = engine.get("planet") + oracle_lord = PYJHORA_LORDS[oracle[0][0]] + engine_start = _as_date(engine["unclipped_start_date"]) + oracle_start = date(*oracle[1][:3]) + following = py_rows[index + 1] if index + 1 < 24 else None + engine_end = _as_date(engine["end_date"]) + oracle_end = _pyjhora_yogini_end(oracle, following) + start_delta = abs((engine_start - oracle_start).days) + end_delta = abs((engine_end - oracle_end).days) + if engine_lord != oracle_lord or start_delta > 1 or end_delta > 1: + mismatches.append( + f"{name} major[{index}] engine={engine_lord} {engine_start}..{engine_end} " + f"pyjhora={oracle_lord} {oracle_start}..{oracle_end} " + f"start_delta_days={start_delta} end_delta_days={end_delta}" + ) + assert not mismatches, "\n".join(mismatches) + + +def _assert_yogini_antardasha_consistency(name: str, periods: list, birth: datetime) -> None: + from scripts.yogini_dasha import TOTAL_CYCLE, YOGINI_SEQUENCE + + year_days = 365.25 + problems = [] + for index, major in enumerate(periods): + subs = major.get("antardasha") or [] + if not subs: + problems.append(f"{name} major[{index}] {major.get('yogini')} has no antardasha") + continue + start_idx = next(item_index for item_index, item in enumerate(YOGINI_SEQUENCE) if item["name"] == major["yogini"]) + expected = [YOGINI_SEQUENCE[(start_idx + offset) % 8] for offset in range(8)] + got = [row["yogini"] for row in subs] + suffixes = [[item["name"] for item in expected[cut:]] for cut in range(8)] + if got not in suffixes: + problems.append(f"{name} major[{index}] order {got} is not a Goel suffix of {major['yogini']}") + continue + matched = expected[8 - len(got):] + for row, item in zip(subs, matched): + if row.get("level") != "antardasha" or row.get("planet") != item["planet"] or row.get("lord") != item["planet"]: + problems.append(f"{name} major[{index}] {row.get('yogini')} fields {row.get('level')} {row.get('planet')} {row.get('lord')}") + raw_start = datetime.fromisoformat(row.get("unclipped_start_date") or row["start_date"]) + raw_end = datetime.fromisoformat(row["end_date"]) + expected_seconds = float(major["full_years"]) * item["years"] / TOTAL_CYCLE * year_days * 86400 + delta_seconds = abs((raw_end - raw_start).total_seconds() - expected_seconds) + if delta_seconds > 2: + problems.append( + f"{name} major[{index}] {item['name']} span_delta_seconds={delta_seconds:.3f}" + ) + for left, right in zip(subs, subs[1:]): + if left["end_date"] != right["start_date"]: + problems.append(f"{name} major[{index}] gap {left['end_date']} -> {right['start_date']}") + if subs[0]["start_date"] != major["start_date"]: + problems.append(f"{name} major[{index}] first sub {subs[0]['start_date']} != major {major['start_date']}") + if index == 0 and datetime.fromisoformat(subs[0]["start_date"]) != birth: + problems.append(f"{name} first sub {subs[0]['start_date']} != birth {birth.isoformat(timespec='seconds')}") + if index > 0 and any("unclipped_start_date" in row for row in subs): + problems.append(f"{name} major[{index}] later mahadasha was clipped") + visible = sum( + (datetime.fromisoformat(row["end_date"]) - datetime.fromisoformat(row["start_date"])).total_seconds() + for row in subs + ) + major_span = (datetime.fromisoformat(major["end_date"]) - datetime.fromisoformat(major["start_date"])).total_seconds() + if abs(visible - major_span) > 86400: + problems.append(f"{name} major[{index}] sub_sum_delta_seconds={visible - major_span:.3f}") + if index == 0 and major["unclipped_start_date"] == major["start_date"]: + if len(subs) != 8: + problems.append(f"{name} unclipped first major has {len(subs)} subs") + for row, item in zip(subs, expected): + formula = float(major["full_years"]) * item["years"] / TOTAL_CYCLE + if abs(float(row["years"]) - formula) > 1e-6: + problems.append(f"{name} unclipped {item['name']} years {row['years']} != {formula}") + assert not problems, "\n".join(problems) + + +def test_yogini_api_majors_match_pyjhora_and_antardashas_are_proportional() -> None: + import importlib.metadata as metadata + + version = metadata.version("PyJHora") + assert version == "4.8.7", version + for name, birth in YOGINI_PUBLIC_CHARTS: + periods = _api_yogini_periods(birth) + born = datetime(birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"]) + _assert_yogini_majors_against_pyjhora(name, periods, _pyjhora_rows("yogini", birth)) + _assert_yogini_antardasha_consistency(name, periods, born) + + def test_placeholder_modules_are_gone() -> None: assert not (SCRIPTS / "extended_dashas.py").exists() assert not (SCRIPTS / "conditional_dashas.py").exists()