10 张虚构盘逐体系对照。不一致的方法在标题下写明差在哪一类。本命 Tithi 顺行一致,逆行没有公开函数。Windows 上 1970 年以前的日出不再用 fromtimestamp。 自测:合同与研究章测试通过;200 张英文扫描约 1956 秒通过。未跑全量 pytest、快速门和前端。 Co-Authored-By: Grok (agent loop)
296 lines
13 KiB
Python
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"
|