研究章在渲染时计算,不写入资料包。四种星座大运次小运和 Kalachakra 第三层不改原来的主运、子运。D81、D108、D144 只留在研究章。 自测:test_research_timing、星座大运、域读取、卫生预算、真恒星、jhora 扫描、API 增长、六章标题、英文版。三张虚构盘中英出稿都低于 180 秒。PyJHora 已导入但未做逐层对照。 Co-Authored-By: Grok (agent loop)
196 lines
8.6 KiB
Python
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"
|