Files
Jyotisha/tests/test_research_timing.py
T
jesse-uxandGrok (agent loop) a757af6b4e 标出研究章和 PyJHora 的已知差别(#92)。
10 张虚构盘逐体系对照。不一致的方法在标题下写明差在哪一类。本命 Tithi 顺行一致,逆行没有公开函数。Windows 上 1970 年以前的日出不再用 fromtimestamp。

自测:合同与研究章测试通过;200 张英文扫描约 1956 秒通过。未跑全量 pytest、快速门和前端。

Co-Authored-By: Grok (agent loop)
2026-10-23 22:29:52 +08:00

296 lines
13 KiB
Python

"""Research timing appendix for issue #92. Fictional charts only."""
from __future__ import annotations
import ast
import bisect
import hashlib
import json
import re
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, _utc_from_jd, find_returns, hindu_solar_events
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
# 新值:英文里的 PyJHora 只允许出现在「Differs from PyJHora:」开头的对照行
# 原因:#92 修复单要求不一致的体系写这句;其它位置仍然不能出现 PyJHora
stray = [line for line in english.splitlines() if "PyJHora" in line and not line.startswith("Differs from PyJHora:")]
assert stray == []
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 _sections(markdown: str) -> list[tuple[str, str]]:
parts = re.split(r"(?m)^## ", markdown)
rows = []
for part in parts[1:]:
title, _, body = part.partition("\n")
rows.append((title.strip(), body))
return rows
def _under(rows: list[tuple[str, str]], heading: str, match: str) -> list[str]:
found = []
for title, body in rows:
if title == heading or (match == "prefix" and title.startswith(heading + " ")):
found.append(body)
return found
def test_solar_events_before_1970_stay_available_on_windows() -> None:
"""BUG-1429. Windows fromtimestamp rejects instants before 1970."""
moment = datetime(1950, 6, 15, 12, 0, tzinfo=timezone.utc)
events = hindu_solar_events(moment, 0.0, 0.0)
assert events
assert all(item["event"] in {"sunrise", "sunset"} for item in events)
parsed = datetime.fromisoformat(events[0]["datetime_utc"])
assert parsed.year == 1950
assert parsed.tzinfo is not None
after = _utc_from_jd(2451545.0)
expect = datetime.fromtimestamp((2451545.0 - 2440587.5) * 86400.0, timezone.utc)
assert abs((after - expect).total_seconds()) < 1e-3
seen = 0
for index in range(200):
case = fictional_case(index)
if int(case["year"]) >= 1970:
continue
zone = timezone(timedelta(hours=float(case["tz"])))
birth = datetime(
int(case["year"]), int(case["month"]), int(case["day"]), 12, 0, tzinfo=zone,
)
rows = hindu_solar_events(birth, float(case["lat"]), float(case["lon"]))
assert rows
seen += 1
assert seen > 0
def test_pyjhora_mismatch_labels_follow_the_committed_json() -> None:
"""Read the offline JSON. This test does not import jhora."""
path = ROOT / "docs" / "tasks" / "research-timing-pyjhora-20261023.json"
payload = json.loads(path.read_text(encoding="utf-8"))
assert payload["charts"] >= 5
assert payload["status"] == "compared"
systems = payload["systems"]
assert isinstance(systems, dict) and systems
case, _chart_data = _chart(0)
base = {"birth_info": case, "worksheets": {}, "calculation_profile": {}}
from scripts.pl9_full_data_export import render_full_data_markdown
chinese = render_full_data_markdown(dict(base))
english_packet = dict(base)
english_packet["report_language"] = "en"
english = render_full_data_markdown(english_packet)
if any(item.get("status") == "不一致" for item in systems.values()):
assert "与 PyJHora 不一致:" in chinese
assert "Differs from PyJHora:" in english
zh_rows = _sections(chinese)
en_rows = _sections(english)
for name, item in systems.items():
status = item["status"]
assert status in {"一致", "不一致", "无此方法"}, name
same_lord = re.search(r"native (\w+) vs PyJHora (\w+)", str(item.get("example") or ""))
if same_lord:
assert same_lord.group(1) != same_lord.group(2), (name, item.get("example"))
for rows, heading, sentence in (
(zh_rows, item["heading_zh"], f"与 PyJHora 不一致:差在 {item.get('category') or ''}"),
(en_rows, item["heading_en"], f"Differs from PyJHora: {item.get('category_en') or ''}"),
):
bodies = _under(rows, heading, item.get("match") or "exact")
assert bodies, (name, heading)
if status == "不一致":
assert all(sentence in body for body in bodies), (name, heading)
else:
assert all("与 PyJHora 不一致:" not in body and "Differs from PyJHora:" not in body for body in bodies), name
tree = ast.parse((ROOT / "scripts" / "research" / "research_timing_pyjhora_compare.py").read_text(encoding="utf-8"))
allowed = {"pyjhora_fingerprints", "_py_tithi_return_days"}
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in allowed:
continue
for child in ast.walk(node):
module = ""
if isinstance(child, ast.Import):
module = " ".join(alias.name for alias in child.names)
elif isinstance(child, ast.ImportFrom):
module = child.module or ""
assert not module.startswith("jhora"), module
def test_rectification_golden_file_is_unchanged() -> None:
digest = hashlib.sha256((ROOT / _GOLDEN).read_bytes()).hexdigest()
assert digest == "92efd8fdbae43f553b0006cfe28e929d5255fe02f3c36e616475fa149a356212"