Files
Jyotisha/tests/test_research_timing.py
T
jesse-uxandGrok (agent loop) d45e65a9ec 完整数据版补上研究口径的推运、次小运和细分盘(#92)。
研究章在渲染时计算,不写入资料包。四种星座大运次小运和 Kalachakra 第三层不改原来的主运、子运。D81、D108、D144 只留在研究章。

自测:test_research_timing、星座大运、域读取、卫生预算、真恒星、jhora 扫描、API 增长、六章标题、英文版。三张虚构盘中英出稿都低于 180 秒。PyJHora 已导入但未做逐层对照。

Co-Authored-By: Grok (agent loop)
2026-10-23 18:20:44 +08:00

196 lines
8.6 KiB
Python

"""Research timing appendix for issue #92. Fictional charts only."""
from __future__ import annotations
import ast
import bisect
import hashlib
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
from scripts.pl9_full_data_export import read_full_data_domain
from scripts.pl9_reader_english import HAN
from scripts.report_render.appendix_language import chinese_prose_gates
from scripts.research_timing.bundle import chart_from_parts, research_bundle
from scripts.research_timing.frame import SiderealFrame, birth_jd
from scripts.research_timing.rashi_pd import attach_rashi_pratyantar
from scripts.research_timing.render import _heading, render_research_chapters
from scripts.research_timing.search import ElongationClock, find_returns
from scripts.rashi_dashas_native import calculate_rashi_dashas
from tests.report_english_fictional import fictional_case
ROOT = Path(__file__).resolve().parents[1]
_SNAKE = __import__("re").compile(r"[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+")
_GOLDEN = "tests/golden/rectification_engine_memoization_v5.json"
def _chart(index: int = 0):
case = fictional_case(index)
zone = timezone(timedelta(hours=float(case["tz"])))
query = datetime(int(case["target_year"]), 1, 1, 12, 0, tzinfo=zone)
chart = chart_from_parts(
year=case["year"], month=case["month"], day=case["day"],
hour=case["hour"], minute=case["minute"], second=case["second"],
lat=case["lat"], lon=case["lon"], tz=case["tz"],
ayanamsa=case["ayanamsa"], node_mode=case["node_mode"],
target_year=case["target_year"], query=query,
)
return case, chart
def test_birth_sun_and_moon_match_the_chart_function() -> None:
_case, chart = _chart(0)
frame = SiderealFrame(chart["ayanamsa"], chart["node_mode"])
jd = birth_jd(chart["year"], chart["month"], chart["day"], chart["hour_decimal"], chart["tz"])
sun, moon = frame.sun_moon(jd)
assert abs(sun - chart["chart_sun"]) <= 1e-6
assert abs(moon - chart["chart_moon"]) <= 1e-6
def test_one_tithi_return_matches_the_target_angle() -> None:
_case, chart = _chart(0)
frame = SiderealFrame(chart["ayanamsa"], chart["node_mode"])
jd = birth_jd(chart["year"], chart["month"], chart["day"], chart["hour_decimal"], chart["tz"])
found = find_returns(frame, "tithi_pravesha", jd, jd, jd + 400)
assert found["returns"]
row = found["returns"][0]
sun, moon = frame.sun_moon(row["jd_ut"])
target = found["target_degrees"]
error = ((moon - sun) - target + 180.0) % 360.0 - 180.0
assert abs(error) <= 1e-6
assert abs(row["residual_degrees"]) <= 1e-6
def test_rashi_third_level_does_not_change_the_producer() -> None:
_case, chart = _chart(1)
birth = datetime(chart["year"], chart["month"], chart["day"]).date()
original = calculate_rashi_dashas(birth, chart["ascendant"], chart["longitudes"])
before = json.dumps(original, ensure_ascii=False, sort_keys=True, default=str)
copied = attach_rashi_pratyantar(original)
assert json.dumps(original, ensure_ascii=False, sort_keys=True, default=str) == before
assert "pratyantar" not in before
produced = 0
for name in ("sthira", "drig", "shoola", "navamsha"):
for major in copied[name]["periods"]:
for child in major.get("antardasha") or []:
third = child.get("pratyantar") or []
if not third:
continue
produced += len(third)
assert len(third) == 12
assert third[0]["sign"] == child["sign"]
assert third[-1]["end"] == child["end"]
assert "pratyantar" not in major
assert produced > 0
def test_clock_solve_stays_within_a_microday_of_bisection() -> None:
_case, chart = _chart(0)
frame = SiderealFrame(chart["ayanamsa"], chart["node_mode"])
jd = birth_jd(chart["year"], chart["month"], chart["day"], chart["hour_decimal"], chart["tz"])
clock = ElongationClock(frame, jd, 360.0, None)
def slow(offset: float) -> float:
target = clock._origin + offset / 360.0 * clock.total_angle
index = bisect.bisect_right(clock._angles, target) - 1
low, high = clock._days[index], clock._days[index + 1]
left_angle = clock._angles[index]
origin_phase = clock._phases[index]
for _ in range(45):
mid = (low + high) / 2.0
motion = left_angle + (clock._phase(mid) - origin_phase + 180.0) % 360.0 - 180.0
if motion < target:
low = mid
else:
high = mid
return (low + high) / 2.0
for offset in (0.0, 30.0, 180.0, 360.0):
assert abs(clock.solve(offset) - slow(offset)) <= 1e-6
def test_rendered_chapters_keep_the_research_banner_and_drop_snake_case() -> None:
case, chart = _chart(0)
packet = {"birth_info": case, "worksheets": {}, "calculation_profile": {}}
chinese = render_research_chapters(packet, "zh")
english = render_research_chapters(packet, "en")
assert "研究口径:依赖所选方法,未经外部软件核对。" in chinese
assert "Research basis: method-dependent; not checked against external software." in english
assert "Niryaana Shoola" in chinese and "不列" in chinese
assert "D81" in chinese and "D108" in chinese and "D144" in chinese
assert "独立年度方法参考" not in chinese and "独立年度方法参考" not in english
assert chinese_prose_gates(chinese) == {"english_signs": 0, "han_function_glue": 0}
assert HAN.findall(english) == []
assert _SNAKE.search(chinese) is None
assert _SNAKE.search(english) is None
assert "blocked" not in english.lower()
assert "PyJHora" not in english
assert "主星/名称" in chinese
assert "次小运按简化模型,从现有主运按比例分出。主运和子运与原来的表相同。" in chinese
assert "收录的教程没有写明第三层。" in chinese
bundle = research_bundle(chart)
skipped = {"annual-returns", "research-vargas", "karana", "varnada"}
missing = [key for key in bundle["families"] if key not in skipped and not _heading(key, "en")]
assert missing == []
def test_domain_and_chat_card_ignore_the_research_chapter() -> None:
from scripts.consult_card_domain_facts import build_consult_card_facts
for index in (0, 1, 9):
case, _chart_data = _chart(index)
packet = {
"report_version": "pl9_personal_long_report.v4",
"birth_info": case,
"core_chart": {"planets": {}},
"calculation_profile": {},
"worksheets": {"timing_and_predictive_systems": {}},
}
before_domain = json.dumps(read_full_data_domain(packet, "career"), ensure_ascii=False, sort_keys=True, default=str)
before_card = json.dumps(
build_consult_card_facts(packet, ["career"], minute_confirmed=True),
ensure_ascii=False, sort_keys=True, default=str,
)
render_research_chapters(packet, "zh")
after_domain = json.dumps(read_full_data_domain(packet, "career"), ensure_ascii=False, sort_keys=True, default=str)
after_card = json.dumps(
build_consult_card_facts(packet, ["career"], minute_confirmed=True),
ensure_ascii=False, sort_keys=True, default=str,
)
assert before_domain == after_domain
assert before_card == after_card
for token in ("research_timing", "tithi_ashtottari", "pratyantar", "D81"):
assert token not in before_domain
def test_rectification_modules_do_not_import_the_research_package() -> None:
paths = list((ROOT / "scripts").glob("rectification*.py"))
paths.extend((ROOT / "scripts").glob("birth_time*.py"))
for name in (
"active_rectification_scoring.py",
"dynamic_rectification.py",
"birth_time_rectifier.py",
"active_rectification_events.py",
):
paths.append(ROOT / "scripts" / name)
seen = set()
for path in paths:
if not path.exists() or path in seen:
continue
seen.add(path)
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
modules = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom):
modules = [node.module or ""]
else:
continue
assert all("research_timing" not in module for module in modules), path.name
def test_rectification_golden_file_is_unchanged() -> None:
digest = hashlib.sha256((ROOT / _GOLDEN).read_bytes()).hexdigest()
assert digest == "92efd8fdbae43f553b0006cfe28e929d5255fe02f3c36e616475fa149a356212"