Files
Jyotisha/scripts/research_timing/render.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

552 lines
25 KiB
Python

"""Snake-free bilingual chapters for the research appendix.
The chapters are computed while the report is rendered. They are not stored
on the packet, and they are not sent to chat cards or rectification.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
logger = logging.getLogger(__name__)
try:
from report_render.parity_chapters import _first, _major_list
from report_render.text import PLANET_LABEL_CN, SIGNS_CN
from research_timing.bundle import chart_from_parts, research_bundle
from research_timing.rashi_pd import attach_rashi_pratyantar
from rashi_dashas_native import calculate_rashi_dashas
except ImportError: # pragma: no cover - package import
from scripts.report_render.parity_chapters import _first, _major_list
from scripts.report_render.text import PLANET_LABEL_CN, SIGNS_CN
from scripts.research_timing.bundle import chart_from_parts, research_bundle
from scripts.research_timing.rashi_pd import attach_rashi_pratyantar
from scripts.rashi_dashas_native import calculate_rashi_dashas
try:
from annual_native.dasha_children import attach_kalachakra_simplified_children
except ImportError: # pragma: no cover - package import
from scripts.annual_native.dasha_children import attach_kalachakra_simplified_children
BANNER = {
"zh": "研究口径:依赖所选方法,未经外部软件核对。",
"en": "Research basis: method-dependent; not checked against external software.",
}
_CHILD_KEYS = ("antardashas", "pratyantardashas", "antardasha", "pratyantar", "pratyantardasha")
_LEVELS = {"MD": "MD", "AD": "AD", "PD": "PD", "antardasha": "AD", "pratyantardasha": "PD", "pratyantar": "PD"}
_FIXED = {
"tithi-natal-forward": ("Natal Tithi Ashtottari forward", "本命 Tithi Ashtottari 顺行"),
"tithi-natal-reverse": ("Natal Tithi Ashtottari reverse", "本命 Tithi Ashtottari 逆行"),
"yoga-natal": ("Natal Yoga Vimshottari", "本命 Yoga Vimshottari"),
"saptavarga": ("Saptavarga lords", "七分盘星座主"),
"buddhi-gati": ("Buddhi Gati", "Buddhi Gati"),
"kaala": ("Kaala", "Kaala"),
"sudasa-confirmed": ("Sudasa confirmed start", "Sudasa 已确认起点"),
"sudasa-birth": ("Sudasa birth cycle", "Sudasa 出生周期"),
"varnada": ("Varnada", "Varnada"),
"karana": ("Karana year", "Karana 年"),
}
_TECH = {"tithi": "Tithi Ashtottari", "yoga": "Yoga Pravesha", "nakshatra": "Nakshatra Pravesha"}
_KIND = {
"en": {
"arc": "arc balance", "time": "elapsed balance", "successor": "successor arc",
"tithi-year": "360 tithi year", "forward": "forward", "reverse": "reverse",
},
"zh": {
"arc": "角距", "time": "时间余额", "successor": "后继角距",
"tithi-year": "360 太阴月年", "forward": "顺行", "reverse": "逆行",
},
}
_KARANA = {
"julene": ("Julene lords", "Julene 主星"),
"pvr": ("PVR quadrant lords", "PVR 象限主星"),
"pvr-tithi-year": ("PVR 360 tithi year", "PVR 360 太阴月年"),
}
_RASHI = (
("sthira", "Sthira", "Sthira"),
("drig", "Drig", "Drig"),
("shoola", "Shoola", "Shoola"),
("navamsha", "Navamsha", "Navamsha"),
)
_DIVISION = {1: "D1", 2: "D2", 3: "D3", 7: "D7", 9: "D9", 12: "D12", 30: "D30", 81: "D81", 108: "D108", 144: "D144", 150: "D150"}
def render_research_chapters(packet: dict, language: str) -> str:
"""Three research chapters. One failed family does not remove the others."""
if language not in ("zh", "en"):
raise ValueError("language must be zh or en")
try:
chart = context_from_packet(packet if isinstance(packet, dict) else {})
bundle = research_bundle(chart)
except (ValueError, RuntimeError, KeyError, TypeError) as exc:
logger.warning("research timing chapter unavailable: %s: %s", type(exc).__name__, exc)
text = _chapter(
"Additional timing calculations" if language == "en" else "补充推运计算数据",
language,
_reason(language, "This chapter did not finish. The runtime log has the cause.", "这一章没有算成,原因记在运行日志里。"),
)
return text
timing = _timing_chapter(bundle, language)
auxiliary = _auxiliary_chapter(packet, chart, language)
vargas = _varga_chapter(bundle, language)
return "\n".join(part for part in (timing, auxiliary, vargas) if part)
def context_from_packet(packet: dict) -> dict:
birth = packet.get("birth_info") if isinstance(packet.get("birth_info"), dict) else {}
profile = packet.get("calculation_profile") if isinstance(packet.get("calculation_profile"), dict) else {}
settings = profile.get("effective_settings") if isinstance(profile.get("effective_settings"), dict) else {}
year, month, day = _birth_date(birth)
hour = int(birth.get("hour") if birth.get("hour") is not None else 0)
minute = int(birth.get("minute") if birth.get("minute") is not None else 0)
second = int(birth.get("second") if birth.get("second") is not None else 0)
if birth.get("lat") is None and birth.get("latitude") is None:
raise ValueError("birth place is missing")
if birth.get("lon") is None and birth.get("longitude") is None:
raise ValueError("birth place is missing")
lat = float(birth.get("lat") if birth.get("lat") is not None else birth.get("latitude"))
lon = float(birth.get("lon") if birth.get("lon") is not None else birth.get("longitude"))
tz = _tz(settings.get("timezone_offset") if settings.get("timezone_offset") not in (None, "") else birth.get("tz", birth.get("timezone")))
ayanamsa = str(birth.get("ayanamsa_name") or birth.get("ayanamsa") or settings.get("ayanamsa") or "lahiri")
node_mode = str(birth.get("node_mode") or settings.get("node_mode") or "mean")
year_days = float(settings.get("dasha_year_days") or 365.25)
target_year, query = _query(packet, year, tz)
return chart_from_parts(
year=year, month=month, day=day, hour=hour, minute=minute, second=second,
lat=lat, lon=lon, tz=tz, ayanamsa=ayanamsa, node_mode=node_mode,
target_year=target_year, query=query, year_days=year_days,
)
def _timing_chapter(bundle: dict, language: str) -> str:
parts = [_chapter("Additional timing calculations" if language == "en" else "补充推运计算数据", language, "")]
note = bundle["reference_note_en"] if language == "en" else bundle["reference_note_zh"]
counts = _return_counts(bundle)
count_line = (
f"Tithi returns across the target year and the next two years: {counts['tithi']}. "
f"Yoga returns in the target year: {counts['yoga']}. "
f"Nakshatra returns in the target year: {counts['nakshatra']}."
if language == "en"
else (
f"目标年及后两年的 Tithi 回归次数:{counts['tithi']}。"
f"目标年的 Yoga 回归次数:{counts['yoga']}。"
f"目标年的 Nakshatra 回归次数:{counts['nakshatra']}。"
)
)
parts.append(note + "\n\n" + count_line)
families = bundle.get("families") or {}
for key, family in families.items():
if key in ("annual-returns", "research-vargas"):
continue
if key == "karana":
groups = family.get("returns") if isinstance(family, dict) else None
if isinstance(groups, list):
for group in groups:
number = group.get("index") or ""
suffix = f"return {number}" if language == "en" else f"第 {number} 次"
parts.extend(_variant_sections(group, language, "Karana", suffix))
else:
parts.extend(_variant_sections(family, language, "Karana", ""))
continue
if key == "varnada" and isinstance(family.get("variants"), list):
parts.extend(_variant_sections(family, language, "Varnada"))
continue
title = _heading(key, language)
if not title:
continue
parts.append(_family_section(title, family, language, bundle.get("query")))
return "\n\n".join(parts)
def _auxiliary_chapter(packet: dict, chart: dict, language: str) -> str:
parts = [_chapter("Auxiliary dasha subperiods" if language == "en" else "辅助大运次小运数据", language, "")]
birth = datetime(chart["year"], chart["month"], chart["day"]).date()
longitudes = chart["longitudes"]
original = calculate_rashi_dashas(birth, chart["ascendant"], longitudes)
copied = attach_rashi_pratyantar(original)
rule = (
"The third level starts at the sub-period sign and runs forward, splitting that sub-period into twelve equal parts. Major and sub-period rows stay as they were."
if language == "en"
else "次小运从子运星座起顺行,把该子运的时间等分成十二段。主运和子运与原来的表相同。"
)
for key, en, zh in _RASHI:
title = en if language == "en" else zh
section = copied.get(key) if isinstance(copied.get(key), dict) else {}
periods = _third_only(section.get("periods") or [])
body = rule + "\n\n" + (_table(periods, language) if periods else _reason(
language,
"This system has no sub-period rows, so the third level is not listed.",
"这个体系没有子运,所以不列次小运。",
))
parts.append(_family_block(title, language, body))
niryaana = (
"Niryaana Shoola child periods and the third level are omitted. The upstream program states that the child-period rule is not independently specified. Rows from an unpublished frozen engine are not a written source."
if language == "en"
else "Niryaana Shoola 的子运和次小运这次不列。上游主程序写明子运规则没有单独的出处,冻结引擎里的几行不能当作书页依据。"
)
parts.append(_family_block("Niryaana Shoola", language, niryaana))
parts.append(_family_block("Kalachakra", language, _kalachakra(packet, language)))
return "\n\n".join(parts)
def _varga_chapter(bundle: dict, language: str) -> str:
family = (bundle.get("families") or {}).get("research-vargas") or {}
rows = family.get("rows") or []
intro = (
"D81, D108 and D144 are printed only in this chapter."
if language == "en"
else "D81、D108、D144 只出现在这一章。"
)
body = intro if family.get("status") == "calculated" else _text(family, language)
if rows:
body += "\n\n" + _varga_table(rows, language)
return _chapter("Research divisional charts" if language == "en" else "研究型分盘", language, body)
def _variant_sections(family: dict, language: str, fallback: str, suffix: str = "") -> list[str]:
variants = family.get("variants")
if not isinstance(variants, list) or not variants:
return [_family_block(fallback, language, _text(family, language))]
sections = []
for index, variant in enumerate(variants, start=1):
profile = variant.get("profile")
if profile in _KARANA:
title = _KARANA[profile][0 if language == "en" else 1]
else:
seed = variant.get("seed_sign")
title = f"{fallback} {_name(seed, language)}".strip() if seed else f"{fallback} {index}"
if suffix:
title = f"{title} {suffix}"
sections.append(_family_section(title, variant, language, None))
return sections
def _family_section(title: str, family: dict, language: str, query: str | None) -> str:
body = [_text(family, language)]
periods = family.get("periods") or []
if periods and family.get("status") != "omitted":
if query:
current = _current(periods, query)
if current:
label = "Reference row" if language == "en" else "参考时刻所在"
body.append(label + "\n\n" + _table(current, language))
body.append(_table(periods, language))
rows = family.get("rows")
if rows:
body.append(_varga_table(rows, language))
return _family_block(title, language, "\n\n".join(part for part in body if part))
def _third_only(periods: list[dict]) -> list[dict]:
rows = []
for major in periods:
if not isinstance(major, dict):
continue
for child in major.get("antardasha") or []:
third = child.get("pratyantar") if isinstance(child, dict) else None
if isinstance(third, list) and third:
rows.extend(third)
return rows
def _kalachakra(packet: dict, language: str) -> str:
note = (
"The third level uses the simplified model, split in proportion from the majors already on the chart. Major and sub-period rows stay as they were."
if language == "en"
else "次小运按简化模型,从现有主运按比例分出。主运和子运与原来的表相同。"
)
module = _first(packet, ("timing_and_predictive_systems", "kalachakra_dasha"))
rows = [item for item in _major_list(module) if isinstance(item, dict) and "antardasha" not in item and "subperiods" not in item]
if not rows:
rows = [item for item in _major_list(module) if isinstance(item, dict)]
if not rows:
missing = "The chart has no Kalachakra majors, so the third level is not listed." if language == "en" else "这张盘没有 Kalachakra 主运,所以不列次小运。"
return note + "\n\n" + missing
try:
_enriched, _children, grands = attach_kalachakra_simplified_children(rows)
except (KeyError, TypeError, ValueError) as exc:
logger.warning("research kalachakra third level failed: %s: %s", type(exc).__name__, exc)
failed = "The third level did not finish. The runtime log has the cause." if language == "en" else "次小运没有算成,原因记在运行日志里。"
return note + "\n\n" + failed
shaped = [{
"level": "PD",
"sign": item.get("rashi") or item.get("sign"),
"lord": item.get("lord"),
"start": item.get("start_date") or item.get("start"),
"end": item.get("end_date") or item.get("end"),
} for item in grands if isinstance(item, dict)]
if not shaped:
empty = "No third-level rows were produced." if language == "en" else "没有产生次小运行。"
return note + "\n\n" + empty
return note + "\n\n" + _table(shaped, language)
def _chapter(title: str, language: str, body: str) -> str:
text = f"## {title}\n\n{BANNER[language]}"
if body:
text += "\n\n" + body.strip()
return text
def _family_block(title: str, language: str, body: str) -> str:
note = _pyjhora_note(title, language)
text = f"## {title}\n\n{BANNER[language]}"
if note:
text += "\n\n" + note
if body.strip():
text += "\n\n" + body.strip()
return text
_PYJHORA_NOTES: dict | None = None
def _pyjhora_notes() -> dict:
"""Committed offline comparison. Missing or unread JSON adds no label."""
global _PYJHORA_NOTES
if _PYJHORA_NOTES is None:
path = Path(__file__).resolve().parents[2] / "docs" / "tasks" / "research-timing-pyjhora-20261023.json"
notes: dict[tuple[str, str], str] = {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
payload = {}
systems = payload.get("systems") if isinstance(payload, dict) else None
for item in (systems or {}).values():
if not isinstance(item, dict) or item.get("status") != "不一致":
continue
zh = str(item.get("heading_zh") or "")
en = str(item.get("heading_en") or "")
category = str(item.get("category") or "")
category_en = str(item.get("category_en") or "")
match = item.get("match") or "exact"
if zh and category:
notes[("zh", zh, match)] = f"与 PyJHora 不一致:差在 {category}"
if en and category_en:
notes[("en", en, match)] = f"Differs from PyJHora: {category_en}"
_PYJHORA_NOTES = notes
return _PYJHORA_NOTES
def _pyjhora_note(title: str, language: str) -> str:
best = ""
found = ""
for (lang, heading, match), sentence in _pyjhora_notes().items():
if lang != language or not heading:
continue
if title == heading or (match == "prefix" and title.startswith(heading + " ")):
if len(heading) >= len(best):
best = heading
found = sentence
return found
def _heading(key: str, language: str) -> str:
if key in _FIXED:
en, zh = _FIXED[key]
return en if language == "en" else zh
matched = re.fullmatch(r"tithi-(\d{4})-(\d+)-pair-(\d+)-(arc|time)-(forward|reverse)", key)
if matched:
year, number, pair, kind, direction = matched.groups()
if language == "zh":
return (
f"年度 Tithi Ashtottari {_KIND['zh'][kind]} {_KIND['zh'][direction]} "
f"{year} 年第 {number} 次,配对 {pair}"
)
return (
f"Annual Tithi Ashtottari {_KIND['en'][kind]} {_KIND['en'][direction]} "
f"{year} return {number} pair {pair}"
)
matched = re.fullmatch(r"tithi-(\d{4})-(\d+)", key)
if matched:
year, number = matched.groups()
if language == "zh":
return f"年度 Tithi Ashtottari {year} 年第 {number} 次"
return f"Annual Tithi Ashtottari {year} return {number}"
matched = re.fullmatch(r"(yoga|nakshatra)-(\d+)-successor-(\d+)", key)
if matched:
tech, number, pair = matched.groups()
if language == "zh":
return f"年度 {_TECH[tech]} 后继角距 第 {number} 次,配对 {pair}"
return f"Annual {_TECH[tech]} successor arc return {number} pair {pair}"
matched = re.fullmatch(r"(yoga|nakshatra)-(\d+)-tithi-year", key)
if matched:
tech, number = matched.groups()
if language == "zh":
return f"年度 {_TECH[tech]} 360 太阴月年 第 {number} 次"
return f"Annual {_TECH[tech]} 360 tithi year return {number}"
matched = re.fullmatch(r"(yoga|nakshatra)-(\d+)-successor", key)
if matched:
tech, number = matched.groups()
if language == "zh":
return f"年度 {_TECH[tech]} 后继角距 第 {number} 次"
return f"Annual {_TECH[tech]} successor arc return {number}"
return ""
def _text(family: dict, language: str) -> str:
if not isinstance(family, dict):
return ""
custom = family.get("reason_en") if language == "en" else family.get("reason_zh")
if custom:
return str(custom)
if family.get("pd_status") in ("omitted", "blocked"):
return _reason(
language,
"No third level is listed for this method.",
"这个方法这里不列次小运。",
)
if family.get("status") == "omitted":
return _reason(language, "This branch is not listed.", "这一支不列表。")
return ""
def _reason(language: str, en: str, zh: str) -> str:
return en if language == "en" else zh
def _table(periods: list[dict], language: str) -> str:
headers = ("Level", "Planet or name", "Start", "End") if language == "en" else ("层级", "主星/名称", "起始", "结束")
lines = ["| " + " | ".join(headers) + " |", "| --- | --- | --- | --- |"]
def walk(rows: list, fallback: str) -> None:
for row in rows:
if not isinstance(row, dict):
continue
level = _LEVELS.get(str(row.get("level") or fallback), fallback)
label = "MD" if language == "en" else "主运"
if level == "AD":
label = "AD" if language == "en" else "子运"
elif level == "PD":
label = "PD" if language == "en" else "次小运"
name = row.get("lord") or row.get("planet") or row.get("sign") or ""
lines.append("| " + " | ".join((
label,
_name(name, language),
_plain(row.get("start")),
_plain(row.get("end")),
)) + " |")
for key in _CHILD_KEYS:
children = row.get(key)
if isinstance(children, list) and children:
walk(children, "AD" if level == "MD" else "PD")
walk(periods, "MD")
if len(lines) <= 2:
return ""
return "\n".join(lines)
def _current(periods: list[dict], query: str) -> list[dict]:
moment = datetime.fromisoformat(query)
found: list[dict] = []
def walk(rows: list) -> bool:
for row in rows:
if not isinstance(row, dict) or "start" not in row or "end" not in row:
continue
try:
start = _align(datetime.fromisoformat(str(row["start"])), moment)
end = _align(datetime.fromisoformat(str(row["end"])), moment)
except (TypeError, ValueError):
continue
if start <= moment < end:
found.append({key: row.get(key) for key in ("level", "lord", "planet", "sign", "start", "end")})
for key in _CHILD_KEYS:
children = row.get(key)
if isinstance(children, list) and walk(children):
break
return True
return False
walk(periods)
return found
def _align(value: datetime, sample: datetime) -> datetime:
if value.utcoffset() is None and sample.utcoffset() is not None:
return value.replace(tzinfo=sample.tzinfo)
if value.utcoffset() is not None and sample.utcoffset() is None:
return value.replace(tzinfo=None)
return value
def _varga_table(rows: list[dict], language: str) -> str:
headers = ("Division", "Body", "Sign", "Degree", "Sign lord") if language == "en" else ("分盘", "星体", "星座", "度数", "星座主")
lines = ["| " + " | ".join(headers) + " |", "| --- | --- | --- | --- | --- |"]
for row in rows:
division = _DIVISION.get(int(row.get("division") or 0), str(row.get("division") or ""))
degree = row.get("degree")
degree_text = f"{float(degree):.4f}" if isinstance(degree, (int, float)) else ""
lines.append("| " + " | ".join((
division,
_name(row.get("body"), language),
_name(row.get("sign"), language),
degree_text,
_name(row.get("lord"), language),
)) + " |")
return "\n".join(lines)
def _name(value, language: str) -> str:
text = "" if value is None else str(value)
if language == "zh":
text = PLANET_LABEL_CN.get(text, SIGNS_CN.get(text, text))
if text == "Lagna":
text = "上升"
return _plain(text)
def _plain(value) -> str:
return str(value or "").replace("|", "/").replace("\n", " ")
def _return_counts(bundle: dict) -> dict[str, int]:
found = ((bundle.get("families") or {}).get("annual-returns") or {}).get("returns") or {}
return {key: len(found.get(key) or []) for key in ("tithi", "yoga", "nakshatra")}
def _birth_date(birth: dict) -> tuple[int, int, int]:
if all(birth.get(key) is not None for key in ("year", "month", "day")):
return int(birth["year"]), int(birth["month"]), int(birth["day"])
text = str(birth.get("date") or "")
year, month, day = text.split("-")[:3]
return int(year), int(month), int(day)
def _tz(value) -> float:
if isinstance(value, (int, float)) and not isinstance(value, bool):
return float(value)
text = str(value or "").strip().upper().replace("UTC", "")
matched = re.fullmatch(r"([+-])(\d{2}):(\d{2})", text)
if matched:
sign = 1 if matched.group(1) == "+" else -1
return sign * (int(matched.group(2)) + int(matched.group(3)) / 60.0)
if not text:
raise ValueError("timezone is missing")
return float(text)
def _query(packet: dict, birth_year: int, tz: float) -> tuple[int, datetime]:
pack = _first(packet, ("timing_and_predictive_systems", "annual_tajika_pack"))
profile = pack.get("profile") if isinstance(pack, dict) else {}
target = profile.get("target_year") if isinstance(profile, dict) else None
if target in (None, ""):
target = packet.get("birth_info", {}).get("target_year") if isinstance(packet.get("birth_info"), dict) else None
ephemeris = _first(packet, ("timing_and_predictive_systems", "report_month_ephemeris"))
rows = ephemeris.get("report_rows") if isinstance(ephemeris, dict) else None
month_text = rows[0].get("month") if isinstance(rows, list) and rows and isinstance(rows[0], dict) else None
zone = timezone(timedelta(hours=tz))
if isinstance(month_text, str) and len(month_text) >= 7 and month_text[4] == "-":
year = int(month_text[0:4])
month = int(month_text[5:7])
if target in (None, ""):
target = year
return int(target), datetime(year, month, 1, 12, 0, tzinfo=zone)
year = int(target if target not in (None, "") else birth_year)
return year, datetime(year, 1, 1, 12, 0, tzinfo=zone)