年数表字面量留在 calc_mudda_with_dasha_balance 里。调用矩阵测试改为确认这两个路径已删并由现行模块接替。pytest 三条文件 187 通过、1 跳过、0 失败。 Co-Authored-By: Grok (agent loop)
432 lines
16 KiB
Python
432 lines
16 KiB
Python
"""API dasha periods must match the full-reading modules (BUG-1341, BUG-1344).
|
|
|
|
fixtureProvenance:
|
|
fictional chart from docs/tasks/TASK-dasha-unify-20261013.md section 1
|
|
public charts already stored in tests/smoke_test_runner.py (Elvis Presley)
|
|
and tests/verify_jyotish_v6_1.py (Britney Spears, UT clock, tz 0)
|
|
PyJHora 4.8.7: drik.set_ayanamsa_mode("LAHIRI");
|
|
graha get_dhasa_bhukthi(dob, tob, place, dhasa_level_index=1);
|
|
ashtottari get_ashtottari_dhasa_bhukthi(julian_day_number(dob, tob), place, dhasa_level_index=1)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import os
|
|
import random
|
|
import re
|
|
import sys
|
|
from datetime import date, datetime
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SCRIPTS = ROOT / "scripts"
|
|
os.environ.setdefault("VEDASTRO_ENABLE_NETWORK", "0")
|
|
|
|
FICTION = {
|
|
"year": 1990,
|
|
"month": 1,
|
|
"day": 1,
|
|
"hour": 12,
|
|
"minute": 0,
|
|
"second": 0,
|
|
"lat": 28.6139,
|
|
"lon": 77.2090,
|
|
"tz": 5.5,
|
|
"ayanamsa": "lahiri",
|
|
"node_mode": "mean",
|
|
"today": "2026-01-01",
|
|
}
|
|
|
|
# Registry key -> full-reading module id. These are the systems T1 compares.
|
|
READING_KEYS = (
|
|
("panchottari", "panchottari_dasha"),
|
|
("satabdika", "satabdika_dasha"),
|
|
("chaturaaseeti", "chaturshitisama_dasha"),
|
|
("shasti_hayani", "shastihayani_dasha"),
|
|
("shodasottari", "shodashottari_dasha"),
|
|
("dwadashottari", "dwadashottari_dasha"),
|
|
("dwisaptati", "dwisaptatisama_dasha"),
|
|
("shattrimsa", "shattrimshatsama_dasha"),
|
|
("yogini", "yogini_dasha"),
|
|
("ashtottari", "ashtottari_dasha"),
|
|
("kalachakra", "kalachakra_dasha"),
|
|
("narayana", "narayana_dasha"),
|
|
)
|
|
|
|
# Anchors copied from the six modules before they shared one helper.
|
|
BALANCE_ANCHORS = (
|
|
("panchottari_dasha", "ANURADHA_INDEX"),
|
|
("satabdika_dasha", "REVATI_INDEX"),
|
|
("chaturshitisama_dasha", "SWATI_INDEX"),
|
|
("dwisaptatisama_dasha", "MULA_INDEX"),
|
|
("shattrimshatsama_dasha", "SRAVANA_INDEX"),
|
|
("shodashottari_dasha", "PUSHYA_INDEX"),
|
|
)
|
|
|
|
# 原值:白名单只有 dasha_analyzer.py、divisional_charts_extended.py
|
|
# 新值:加上 tajika.py
|
|
# 原因:calc_mudda_with_dasha_balance 被 test_ported_function_is_verbatim 逐字锁定,
|
|
# 函数里的年数字面量不能改成共享表引用。其余非白名单文件仍只能命中 dasha_common.py。
|
|
FROZEN_YEAR_TABLES = {
|
|
"dasha_analyzer.py",
|
|
"divisional_charts_extended.py",
|
|
"tajika.py",
|
|
}
|
|
YEAR_TABLE = re.compile(
|
|
r"""['"]Ketu['"]\s*:\s*7\b.*?['"]Venus['"]\s*:\s*20\b.*?['"]Mercury['"]\s*:\s*17\b""",
|
|
re.DOTALL,
|
|
)
|
|
PYJHORA_LORDS = {
|
|
0: "Sun",
|
|
1: "Moon",
|
|
2: "Mars",
|
|
3: "Mercury",
|
|
4: "Jupiter",
|
|
5: "Venus",
|
|
6: "Saturn",
|
|
7: "Rahu",
|
|
8: "Ketu",
|
|
}
|
|
|
|
|
|
def _handler():
|
|
path = ROOT / "tests" / "test_api_server_security.py"
|
|
spec = importlib.util.spec_from_file_location("dasha_parity_security_helpers", path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module._handler()
|
|
|
|
|
|
def _drop_injected_repo_paths() -> None:
|
|
"""PyJHora inserts the main checkout onto sys.path. Keep this worktree."""
|
|
banned = {
|
|
os.path.normcase(str(ROOT.parent)),
|
|
os.path.normcase(str(ROOT.parent.parent)),
|
|
}
|
|
sys.path[:] = [
|
|
item
|
|
for item in sys.path
|
|
if not item or os.path.normcase(os.path.abspath(item)) not in banned
|
|
]
|
|
scripts = str(SCRIPTS)
|
|
if scripts not in sys.path:
|
|
sys.path.insert(0, scripts)
|
|
|
|
|
|
def _major_rows(payload):
|
|
if not isinstance(payload, dict):
|
|
return []
|
|
if isinstance(payload.get("major"), list):
|
|
return payload["major"]
|
|
if isinstance(payload.get("mahadasha_sequence"), list):
|
|
return payload["mahadasha_sequence"]
|
|
if isinstance(payload.get("periods"), list):
|
|
return payload["periods"]
|
|
return []
|
|
|
|
|
|
def _chart_body(report: dict, dasha: str) -> dict:
|
|
chart = report["chart"]
|
|
planets = {
|
|
name: {"lon": row["lon"]}
|
|
for name, row in chart["planets"].items()
|
|
if isinstance(row, dict) and isinstance(row.get("lon"), (int, float))
|
|
}
|
|
return {
|
|
"dasha": dasha,
|
|
"year": FICTION["year"],
|
|
"month": FICTION["month"],
|
|
"day": FICTION["day"],
|
|
"hour": FICTION["hour"],
|
|
"minute": FICTION["minute"],
|
|
"lat": FICTION["lat"],
|
|
"lon": FICTION["lon"],
|
|
"tz": FICTION["tz"],
|
|
"today": FICTION["today"],
|
|
"planets": planets,
|
|
"ascendant": {"lon": chart["ascendant"]["lon"]},
|
|
}
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _fiction_reading() -> dict:
|
|
from scripts.jyotish_engine import cmd_full_reading
|
|
from scripts.professional_report_reference import _export_args
|
|
|
|
return cmd_full_reading(_export_args(dict(FICTION)))
|
|
|
|
|
|
def _legacy_birth_balance(moon_longitude, anchor_index, sequence):
|
|
"""Source copy of the six helpers before they called dasha_common.
|
|
|
|
Operation order matches the deleted bodies: modulo, nakshatra index,
|
|
offset, lord from the sequence, fraction, then years times (1 - fraction).
|
|
"""
|
|
moon_longitude = float(moon_longitude) % 360.0
|
|
span = 360.0 / 27.0
|
|
nakshatra_index = int(moon_longitude // span)
|
|
offset = (nakshatra_index - anchor_index) % 27
|
|
starting_lord = sequence[offset % len(sequence)][0]
|
|
years_by_lord = dict(sequence)
|
|
fraction_elapsed = (moon_longitude % span) / span
|
|
remaining_years = years_by_lord[starting_lord] * (1.0 - fraction_elapsed)
|
|
return starting_lord, remaining_years, nakshatra_index
|
|
|
|
|
|
def _as_date(value) -> date:
|
|
if isinstance(value, datetime):
|
|
return value.date()
|
|
if isinstance(value, date):
|
|
return value
|
|
text = str(value)
|
|
return datetime.fromisoformat(text[:19]).date()
|
|
|
|
|
|
def _period_covering(majors, moment: datetime):
|
|
first = datetime.fromisoformat(majors[0]["start_date"])
|
|
last = datetime.fromisoformat(majors[-1]["end_date"])
|
|
span = last - first
|
|
if span.total_seconds() <= 0 or moment < first:
|
|
return None
|
|
cycles = int((moment - first).total_seconds() // span.total_seconds())
|
|
for period in majors:
|
|
start = datetime.fromisoformat(period["start_date"]) + cycles * span
|
|
end = datetime.fromisoformat(period["end_date"]) + cycles * span
|
|
if start <= moment < end:
|
|
return period["planet"], start, end
|
|
return None
|
|
|
|
|
|
def test_api_major_periods_match_full_reading_modules() -> None:
|
|
report = _fiction_reading()
|
|
modules = report["modules"]
|
|
handler = _handler()
|
|
for key, module_id in READING_KEYS:
|
|
engine_rows = _major_rows(modules[module_id])
|
|
result = handler._compute_dasha_system(_chart_body(report, key))
|
|
assert result["precision"] == "engine_module", key
|
|
assert result["precision"] not in {"generic", "calculator"}
|
|
assert result["periods"] == engine_rows, key
|
|
|
|
|
|
def test_unimplemented_dasha_keys_are_rejected() -> None:
|
|
handler = _handler()
|
|
body = _chart_body(_fiction_reading(), "tara")
|
|
from scripts.jyotish_api_server import BadRequest
|
|
|
|
with pytest.raises(BadRequest, match="dasha system not implemented"):
|
|
handler._compute_dasha_system(body)
|
|
body["dasha"] = "chara"
|
|
with pytest.raises(BadRequest, match=r"/api/dasha/chara"):
|
|
handler._compute_dasha_system(body)
|
|
source = (SCRIPTS / "jyotish_api_server.py").read_text(encoding="utf-8")
|
|
assert "except BadRequest as e:" in source
|
|
assert "_error_json(str(e), 400, 'ERR_BAD_REQUEST')" in source
|
|
|
|
|
|
def test_narayana_keeps_the_legacy_label() -> None:
|
|
result = _handler()._compute_dasha_system(_chart_body(_fiction_reading(), "narayana"))
|
|
assert "旧算法" in result["algorithm_label"]
|
|
assert result["profile"] == "legacy_reference_only"
|
|
assert "Sanjay Rath" not in result["algorithm_label"]
|
|
|
|
|
|
def test_shoola_uses_the_native_rashi_section() -> None:
|
|
report = _fiction_reading()
|
|
from scripts.rashi_dashas_native import calculate_rashi_dashas
|
|
|
|
chart = report["chart"]
|
|
planet_lons = {name: row["lon"] for name, row in chart["planets"].items()}
|
|
expected = calculate_rashi_dashas(
|
|
date(1990, 1, 1),
|
|
chart["ascendant"]["lon"],
|
|
planet_lons,
|
|
)["shoola"]["periods"]
|
|
result = _handler()._compute_dasha_system(_chart_body(report, "shoola"))
|
|
assert result["precision"] == "engine_module"
|
|
assert result["periods"] == expected
|
|
|
|
|
|
def test_birth_balance_matches_the_old_formula() -> None:
|
|
from scripts.dasha_common import nakshatra_seeded_birth_balance
|
|
|
|
rng = random.Random(1341)
|
|
samples = [rng.uniform(-40.0, 400.0) for _ in range(2000)]
|
|
for module_name, anchor_name in BALANCE_ANCHORS:
|
|
module = importlib.import_module(f"scripts.{module_name}")
|
|
sequence = module.DASHA_SEQUENCE
|
|
anchor = getattr(module, anchor_name)
|
|
for moon in samples:
|
|
assert module._birth_lord_and_balance(moon) == nakshatra_seeded_birth_balance(
|
|
moon, anchor_index=anchor, sequence=sequence
|
|
)
|
|
assert nakshatra_seeded_birth_balance(
|
|
moon, anchor_index=anchor, sequence=sequence
|
|
) == _legacy_birth_balance(moon, anchor, sequence)
|
|
|
|
|
|
def test_vimshottari_year_literals_stay_at_the_shared_table() -> None:
|
|
matches = sorted(
|
|
path.name
|
|
for path in SCRIPTS.glob("*.py")
|
|
if path.name not in FROZEN_YEAR_TABLES and YEAR_TABLE.search(path.read_text(encoding="utf-8"))
|
|
)
|
|
assert matches == ["dasha_common.py"]
|
|
frozen = SCRIPTS / "dasha_analyzer.py"
|
|
assert YEAR_TABLE.search(frozen.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _pyjhora_rows(module_name: str, birth: dict):
|
|
pytest.importorskip("jhora")
|
|
_drop_injected_repo_paths()
|
|
from jhora import utils
|
|
from jhora.panchanga import drik
|
|
|
|
drik.set_ayanamsa_mode("LAHIRI")
|
|
place = drik.Place("f", birth["lat"], birth["lon"], birth["tz"])
|
|
dob = drik.Date(birth["year"], birth["month"], birth["day"])
|
|
tob = (birth["hour"], birth["minute"], int(birth.get("second", 0)))
|
|
if module_name == "ashtottari":
|
|
from jhora.horoscope.dhasa.graha.ashtottari import get_ashtottari_dhasa_bhukthi
|
|
|
|
jd = utils.julian_day_number((birth["year"], birth["month"], birth["day"]), tob)
|
|
return get_ashtottari_dhasa_bhukthi(jd, place, dhasa_level_index=1)
|
|
module = importlib.import_module(f"jhora.horoscope.dhasa.graha.{module_name}")
|
|
return module.get_dhasa_bhukthi(dob, tob, place, dhasa_level_index=1)
|
|
|
|
|
|
def _assert_same_boundaries(engine_rows, py_rows, birth: datetime, slack_days: int) -> None:
|
|
engine = [(_row_lord(row), _as_date(row.get("start_date"))) for row in engine_rows]
|
|
oracle = [(PYJHORA_LORDS[row[0][0]], date(*row[1][:3])) for row in py_rows]
|
|
assert engine[0][0] == oracle[0][0]
|
|
birth_day = birth.date()
|
|
if engine[0][1] == birth_day and oracle[0][1] < birth_day:
|
|
pairs = zip(engine[1:], oracle[1:])
|
|
limit = 0
|
|
else:
|
|
pairs = zip(engine, oracle)
|
|
limit = slack_days
|
|
compared = 0
|
|
for (engine_lord, engine_start), (oracle_lord, oracle_start) in pairs:
|
|
assert engine_lord == oracle_lord
|
|
assert abs((engine_start - oracle_start).days) <= limit
|
|
compared += 1
|
|
assert compared >= 3
|
|
|
|
|
|
def _row_lord(row: dict) -> str:
|
|
return row.get("lord") or row.get("planet")
|
|
|
|
|
|
def test_conditional_tables_match_pyjhora_start_days() -> None:
|
|
from scripts.chaturshitisama_dasha import calculate_chaturshitisama_dasha
|
|
from scripts.dasha_registry import build_reading_birth_info
|
|
from scripts.domain_calculation_service import compute_chart
|
|
from scripts.panchottari_dasha import calculate_panchottari_dasha
|
|
from scripts.satabdika_dasha import calculate_satabdika_dasha
|
|
from scripts.source_bounded_child_periods import PYJHORA_PARENT_SEEDED_EQUAL_SPLIT_PROFILE
|
|
|
|
chart = compute_chart({**FICTION, "ayanamsa": "lahiri"})
|
|
birth = datetime(1990, 1, 1, 12, 0)
|
|
_alt, conditional, _lons = build_reading_birth_info(
|
|
birth,
|
|
chart["planets"],
|
|
chart["ascendant"],
|
|
{"lat": FICTION["lat"], "lon": FICTION["lon"], "tz": FICTION["tz"], "year": 1990},
|
|
)
|
|
info = {
|
|
**conditional,
|
|
"child_period_profile": PYJHORA_PARENT_SEEDED_EQUAL_SPLIT_PROFILE,
|
|
"standard_table_profile": "pl9_reports_all_dasha_tables_v1",
|
|
}
|
|
calls = (
|
|
("panchottari", calculate_panchottari_dasha),
|
|
("sataatbika", calculate_satabdika_dasha),
|
|
("chathuraaseethi_sama", calculate_chaturshitisama_dasha),
|
|
)
|
|
for module_name, calculator in calls:
|
|
rows = calculator(info)["major"]
|
|
assert rows
|
|
_assert_same_boundaries(rows, _pyjhora_rows(module_name, FICTION), birth, 0)
|
|
|
|
|
|
def _public_case(name: str, birth: dict, family: str) -> None:
|
|
from scripts.ashtottari_dasha import calculate_ashtottari_dasha
|
|
from scripts.ashtottari_rule_profiles import (
|
|
RULE_FAMILY_MOON_NAKSHATRA_PLUS_PAKSHA,
|
|
RULE_FAMILY_RAHU_FROM_LAGNA_LORD,
|
|
)
|
|
from scripts.dasha_registry import build_reading_birth_info
|
|
from scripts.domain_calculation_service import compute_chart
|
|
|
|
families = {
|
|
"moon": RULE_FAMILY_MOON_NAKSHATRA_PLUS_PAKSHA,
|
|
"rahu": RULE_FAMILY_RAHU_FROM_LAGNA_LORD,
|
|
}
|
|
chart = compute_chart({**birth, "second": 0, "node_mode": "mean", "ayanamsa": "lahiri"})
|
|
born = datetime(birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"])
|
|
alt, conditional, _lons = build_reading_birth_info(
|
|
born,
|
|
chart["planets"],
|
|
chart["ascendant"],
|
|
{"lat": birth["lat"], "lon": birth["lon"], "tz": birth["tz"], "year": birth["year"]},
|
|
)
|
|
result = calculate_ashtottari_dasha({
|
|
**alt,
|
|
"moon_longitude": conditional["moon_longitude"],
|
|
"birth_is_daytime": conditional["birth_is_daytime"],
|
|
"rahu_sign_index": conditional["rahu_sign_index"],
|
|
"lagna_lord_sign_index": conditional["lagna_lord_sign_index"],
|
|
"applicability_rule_family": families[family],
|
|
})
|
|
assert result["applicable"] is True, name
|
|
majors = result["major"]
|
|
start = datetime.fromisoformat(majors[0]["start_date"])
|
|
end = datetime.fromisoformat(majors[0]["end_date"])
|
|
assert start <= born < end
|
|
now = datetime.now()
|
|
covered = _period_covering(majors, now)
|
|
assert covered is not None, name
|
|
current = result["current"]
|
|
assert current["planet"] == covered[0]
|
|
assert datetime.fromisoformat(current["start_date"]) == covered[1]
|
|
assert datetime.fromisoformat(current["end_date"]) == covered[2]
|
|
_assert_same_boundaries(majors, _pyjhora_rows("ashtottari", birth), born, 1)
|
|
|
|
|
|
def test_ashtottari_birth_balance_matches_pyjhora_within_one_day() -> None:
|
|
fiction = {key: FICTION[key] for key in ("year", "month", "day", "hour", "minute", "lat", "lon", "tz")}
|
|
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,
|
|
}
|
|
_public_case("fiction", fiction, "moon")
|
|
_public_case("presley", presley, "rahu")
|
|
_public_case("spears", spears, "moon")
|
|
|
|
|
|
def test_placeholder_modules_are_gone() -> None:
|
|
assert not (SCRIPTS / "extended_dashas.py").exists()
|
|
assert not (SCRIPTS / "conditional_dashas.py").exists()
|
|
assert not (ROOT / "skills" / "jyotish-vedic-astrology" / "scripts" / "extended_dashas.py").exists()
|