Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
323 lines
19 KiB
Python
323 lines
19 KiB
Python
"""Opening supplement under 「重点」: reading guide, timeline, domain evidence.
|
||
|
||
The upstream export opens with a reading guide, the current Vimshottari and
|
||
Narayana periods, the next sub-periods, and the divisional placements behind
|
||
career, finances and relationships. This module prints those from the packet
|
||
the report already carries. It computes no new dasha, chart or yoga.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
try:
|
||
from scripts.pl9_interpretation_packs import (
|
||
_current_period_rows,
|
||
_dict,
|
||
_functional_classification,
|
||
_houses,
|
||
_house_row,
|
||
_iso_day,
|
||
_list,
|
||
_planets,
|
||
_role,
|
||
_timing,
|
||
_worksheets,
|
||
)
|
||
from scripts.report_render.appendix_language import planet_name, sign_name
|
||
except ModuleNotFoundError: # pragma: no cover - scripts/ on sys.path
|
||
from pl9_interpretation_packs import (
|
||
_current_period_rows,
|
||
_dict,
|
||
_functional_classification,
|
||
_houses,
|
||
_house_row,
|
||
_iso_day,
|
||
_list,
|
||
_planets,
|
||
_role,
|
||
_timing,
|
||
_worksheets,
|
||
)
|
||
from report_render.appendix_language import planet_name, sign_name
|
||
|
||
_SIGNS = (
|
||
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
||
)
|
||
|
||
# (domain key, zh title, en title, houses, divisional charts, karaka, arudha)
|
||
_DOMAINS = (
|
||
("career", "事业", "Career", (10,), ("D10_Dasamsa", "D9_Navamsa"), "Amatyakaraka", "A10"),
|
||
("wealth", "财富", "Finances", (2, 11), ("D2_Hora", "D11_Rudramsa"), None, None),
|
||
("relationship", "婚恋", "Relationships", (7,), ("D9_Navamsa",), "Darakaraka", "UL"),
|
||
)
|
||
_CHART_LABELS = {
|
||
"D10_Dasamsa": "D10", "D9_Navamsa": "D9", "D2_Hora": "D2", "D11_Rudramsa": "D11",
|
||
}
|
||
_KARAKA_LABELS = {
|
||
"Amatyakaraka": ("AmK(事业星)", "Amatyakaraka (AmK)"),
|
||
"Darakaraka": ("DK(配偶星)", "Darakaraka (DK)"),
|
||
}
|
||
_ARUDHA_LABELS = {
|
||
"A10": ("A10(事业宫 Arudha)", "A10 (Karma Pada)"),
|
||
"UL": ("UL(Upapada,第 12 宫 Arudha)", "UL (Upapada, A12)"),
|
||
}
|
||
|
||
|
||
def _p(name: Any, zh: bool) -> str:
|
||
text = str(name or "")
|
||
return str(planet_name(text, "zh")) if zh and text else text or "—"
|
||
|
||
|
||
def _s(name: Any, zh: bool) -> str:
|
||
text = str(name or "")
|
||
return str(sign_name(text, "zh")) if zh and text else text or "—"
|
||
|
||
|
||
def _house_from(ascendant: str, sign: str) -> int | None:
|
||
if ascendant not in _SIGNS or sign not in _SIGNS:
|
||
return None
|
||
return (_SIGNS.index(sign) - _SIGNS.index(ascendant)) % 12 + 1
|
||
|
||
|
||
def reading_guide(language: str) -> str:
|
||
if language == "zh":
|
||
return "\n".join([
|
||
"## 阅读说明",
|
||
"",
|
||
"这份文件自带全部盘面数据、大运表和解释文字,不需要另附图片。交给 AI 或自己阅读时:",
|
||
"",
|
||
"- 先看出生资料、岁差、交点口径、地点和时区。不同计算口径的数据不要混用;本命盘、分盘、年盘和月盘按标题区分。",
|
||
"- 问某个日期时,先在大运表和注明日期的行运表里找到这一天。超出行运或年盘覆盖范围的日期需要重新计算。",
|
||
"- 大运、小运、细运是同一套大运的三层,上下层要对应着看。不要把不同大运体系的日期拼在一起;星座大运的「主星」一栏可能是星座。",
|
||
"- 角度单位是度(°)。力量分数按各表自己的单位,Virupa 和 Rupa 不要混。传统分盘与研究型分盘分开看。",
|
||
"- 「—」「未判定」「规则未单列」表示缺数据或核对受限,不等于零,也不等于没有影响。计算结果、传统含义、条件推论和已核实事实要分开说;冲突照实保留,候选时间窗只是候选,不要当作已定的事。",
|
||
"- 回答应期问题时,引用具体章节、行星、数值和日期区间;交叉看相关分盘、功能吉凶、Vimshottari 与 Narayana 两套大运,再说明哪里一致、哪里不一致、哪些要用本人经历核对。",
|
||
])
|
||
return "\n".join([
|
||
"## AI Reading Guide",
|
||
"",
|
||
"This self-contained file includes chart data, timing tables and interpretive text. No image attachments are required.",
|
||
"",
|
||
"- Read the birth particulars, ayanamsa, node mode, location and time zone first. Do not mix calculation profiles. Distinguish natal, divisional, annual and monthly charts by their headings.",
|
||
"- Match the requested date to the Dasha intervals and dated transit tables. Dates beyond the supplied transit or annual coverage require updated calculations.",
|
||
"- MD / AD / PD mean Mahadasha / Antardasha / Pratyantardasha. Do not splice dates from different Dasha systems. A sign-based Dasha lord field can name a sign rather than a planet.",
|
||
"- Angles are in degrees (°). Strength scores use each table's units; do not confuse Virupas and Rupas. Keep traditional and research divisional charts distinct.",
|
||
"- An em dash (—), \"not determined\" or \"not listed by the rule\" means missing data or a verification limit, never zero or absence of influence. Distinguish computed data, traditional meanings, conditional inferences and verified facts. Preserve conflicts and do not turn candidate windows into certain events.",
|
||
"- Ground timing answers in named sections, bodies, values and date ranges. Cross-check relevant divisional charts, functional benefic/malefic roles, Vimshottari and Narayana, then state agreement, disagreement and what needs personal-history confirmation.",
|
||
])
|
||
|
||
|
||
def _upcoming_subperiods(packet: dict, count: int = 3) -> list[dict]:
|
||
rows = _current_period_rows(packet)
|
||
ad = rows.get("ad") or {}
|
||
pds = [row for row in _list(ad.get("pratyantar_dasha_timeline")) if isinstance(row, dict)]
|
||
for index, row in enumerate(pds):
|
||
if row.get("is_current"):
|
||
return pds[index + 1:index + 1 + count]
|
||
return []
|
||
|
||
|
||
def timeline(packet: dict, language: str) -> str:
|
||
zh = language == "zh"
|
||
lines = ["### 时间线" if zh else "### Timeline", ""]
|
||
upcoming = _upcoming_subperiods(packet)
|
||
if upcoming:
|
||
lines.append("当前小运里接下来的细运:" if zh else "Next pratyantar periods in the current antardasha:")
|
||
lines.append("")
|
||
for row in upcoming:
|
||
lines.append(f"- {_p(row.get('lord'), zh)}:{_iso_day(row.get('start'))} → {_iso_day(row.get('end'))}"
|
||
if zh else f"- {row.get('lord')}: {_iso_day(row.get('start'))} → {_iso_day(row.get('end'))}")
|
||
lines.append("")
|
||
narayana = _dict(_timing(packet).get("narayana_dasha"))
|
||
md = _dict(_dict(narayana.get("current_dasha")).get("md"))
|
||
ad = _dict(_dict(narayana.get("current_dasha")).get("ad"))
|
||
if md.get("sign"):
|
||
seed = narayana.get("seed_sign")
|
||
if zh:
|
||
text = (f"Narayana 当前大运(Sanjay Rath 版,见「Narayana 口径对照」):{_s(md.get('sign'), zh)},"
|
||
f"约 {md.get('start_age')} 至 {md.get('end_age')} 岁")
|
||
if ad.get("sign"):
|
||
text += f";小运 {_s(ad.get('sign'), zh)},约 {ad.get('start_age')} 至 {ad.get('end_age')} 岁"
|
||
if seed:
|
||
text += f"。起运星座 {_s(seed, zh)}"
|
||
lines.append(text + "。")
|
||
else:
|
||
text = (f"Current Narayana period (Sanjay Rath; see the Narayana comparison): {md.get('sign')}, "
|
||
f"age about {md.get('start_age')} to {md.get('end_age')}")
|
||
if ad.get("sign"):
|
||
text += f"; sub-period {ad.get('sign')}, age about {ad.get('start_age')} to {ad.get('end_age')}"
|
||
if seed:
|
||
text += f". Seed sign {seed}"
|
||
lines.append(text + ".")
|
||
lines.append("")
|
||
lines.append(
|
||
"大运交界是回看实际经历的节点,不是确定的事件日期。完整时间表在后面。" if zh
|
||
else "Period changes are checkpoints for reviewing actual developments, not fixed event dates. The complete timelines follow below."
|
||
)
|
||
if len(lines) <= 4 and not upcoming and not md.get("sign"):
|
||
return ""
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _karaka_planet(packet: dict, karaka: str) -> str | None:
|
||
jaimini = _dict(_dict(_worksheets(packet).get("advanced_systems")).get("jaimini"))
|
||
table = _dict(_dict(jaimini.get("chara_karaka_7")).get("karaka_table"))
|
||
planet = _dict(table.get(karaka)).get("planet")
|
||
return str(planet) if planet else None
|
||
|
||
|
||
def _arudha_sign(packet: dict, key: str) -> str | None:
|
||
jaimini = _dict(_dict(_worksheets(packet).get("advanced_systems")).get("jaimini"))
|
||
pada = _dict(_dict(_dict(jaimini.get("arudha_padas")).get("padas")).get(key))
|
||
sign = pada.get("sign")
|
||
return str(sign) if sign in _SIGNS else None
|
||
|
||
|
||
def _varga(packet: dict, chart: str) -> dict:
|
||
divisional = _dict(_worksheets(packet).get("divisional_and_special_charts"))
|
||
return _dict(_dict(divisional.get("varga_full")).get(chart))
|
||
|
||
|
||
def domain_evidence(packet: dict, language: str) -> str:
|
||
zh = language == "zh"
|
||
houses = _houses(packet)
|
||
planets = _planets(packet)
|
||
roles = _functional_classification(packet)
|
||
first = _dict(_house_row(houses, 1))
|
||
natal_asc = str(first.get("sign") or first.get("cusp_sign") or "")
|
||
current = {str(_dict(row).get("lord") or "") for row in _current_period_rows(packet).values()}
|
||
blocks = ["### 三个领域的盘面依据" if zh else "### Domain evidence", ""]
|
||
for _key, title_zh, title_en, numbers, charts, karaka, arudha in _DOMAINS:
|
||
blocks.append(f"#### {title_zh if zh else title_en}")
|
||
blocks.append("")
|
||
focus_planets: list[str] = []
|
||
for number in numbers:
|
||
lord = str(_dict(_house_row(houses, number)).get("lord") or "")
|
||
if not lord:
|
||
continue
|
||
focus_planets.append(lord)
|
||
row = _dict(planets.get(lord))
|
||
active = lord in current
|
||
if zh:
|
||
blocks.append(
|
||
f"- 第 {number} 宫主 {_p(lord, zh)}:落第 {row.get('house') or '—'} 宫{_s(row.get('sign'), zh)},"
|
||
f"功能角色:{_role(roles.get(lord), language) or '未判定'};"
|
||
+ ("现在是当前大运三级主星之一,这一宫的主题处在前台。" if active else "目前不是当前大运三级主星,这一宫的主题是本命背景。")
|
||
)
|
||
else:
|
||
blocks.append(
|
||
f"- Lord of house {number}, {lord}: in house {row.get('house') or '—'} ({row.get('sign') or '—'}), "
|
||
f"functional role: {_role(roles.get(lord), language) or 'not determined'}; "
|
||
+ ("currently an active Dasha lord, so this house theme is foregrounded." if active else "not a current Dasha lord, so this theme stays natal background.")
|
||
)
|
||
if karaka:
|
||
planet = _karaka_planet(packet, karaka)
|
||
if planet:
|
||
focus_planets.append(planet)
|
||
label = _KARAKA_LABELS[karaka][0 if zh else 1]
|
||
blocks.append(f"- {label}:{_p(planet, zh)}" if zh else f"- {label}: {planet}")
|
||
if arudha:
|
||
sign = _arudha_sign(packet, arudha)
|
||
if sign:
|
||
house = _house_from(natal_asc, sign)
|
||
label = _ARUDHA_LABELS[arudha][0 if zh else 1]
|
||
if zh:
|
||
blocks.append(f"- {label}:{_s(sign, zh)}" + (f",本命第 {house} 宫" if house else ""))
|
||
else:
|
||
blocks.append(f"- {label}: {sign}" + (f", natal house {house}" if house else ""))
|
||
rows = []
|
||
for chart in charts:
|
||
varga = _varga(packet, chart)
|
||
asc = str(_dict(varga.get("Ascendant")).get("sign") or "")
|
||
for planet in dict.fromkeys(focus_planets):
|
||
sign = str(_dict(varga.get(planet)).get("sign") or "")
|
||
if not sign:
|
||
continue
|
||
house = _house_from(asc, sign)
|
||
rows.append((_CHART_LABELS.get(chart, chart), asc, planet, sign, house))
|
||
if rows:
|
||
blocks.append("")
|
||
blocks.append("| 分盘 | 分盘上升 | 行星 | 星座 | 宫位 |" if zh else "| Chart | Ascendant | Planet | Sign | House |")
|
||
blocks.append("| --- | --- | --- | --- | --- |")
|
||
for chart, asc, planet, sign, house in rows:
|
||
blocks.append(f"| {chart} | {_s(asc, zh)} | {_p(planet, zh)} | {_s(sign, zh)} | {house or '—'} |")
|
||
blocks.append("")
|
||
blocks.append(
|
||
"这些是宫主与分盘的计算落点,不是单独的事件预言;要和对应分盘、两套大运及行运一起看。" if zh
|
||
else "These are calculated house-lord and divisional placements, not standalone event forecasts. Read them with the divisional charts, both Dasha systems and transits."
|
||
)
|
||
return "\n".join(blocks)
|
||
|
||
|
||
def render_opening_supplement(packet: dict, language: str) -> str:
|
||
if not isinstance(packet, dict):
|
||
return ""
|
||
parts = [reading_guide(language), timeline(packet, language), domain_evidence(packet, language)]
|
||
return "\n\n".join(part for part in parts if part.strip())
|
||
|
||
|
||
def insert_opening_supplement(markdown: str, packet: dict, language: str) -> str:
|
||
"""Put the supplement right after the 「重点」 / Focus block (before the next chapter)."""
|
||
import re
|
||
|
||
supplement = render_opening_supplement(packet, language).strip()
|
||
if not supplement or not markdown:
|
||
return markdown
|
||
heading = r"^## (?:重点|Focus|Key points)\s*$"
|
||
match = re.search(heading, markdown, flags=re.MULTILINE)
|
||
if not match:
|
||
title = re.search(r"^#\s+.+$", markdown, flags=re.MULTILINE)
|
||
if not title:
|
||
return supplement + "\n\n" + markdown
|
||
return markdown[: title.end()] + "\n\n" + supplement + "\n" + markdown[title.end():]
|
||
following = re.search(r"^## ", markdown[match.end():], flags=re.MULTILINE)
|
||
cut = match.end() + following.start() if following else len(markdown)
|
||
head = markdown[:cut].rstrip()
|
||
# BUG-1316: the focus card stays directly under the title. The timeline and
|
||
# domain evidence go under 「重点」; the reading guide follows as its own chapter.
|
||
guide, _, rest = supplement.partition("\n\n### ")
|
||
rest = "### " + rest if rest else ""
|
||
body = head + ("\n\n" + rest.strip() if rest else "") + "\n\n" + guide.strip()
|
||
return body + "\n\n" + markdown[cut:].lstrip()
|
||
|
||
|
||
def calculation_notes(language: str) -> str:
|
||
"""Conventions this engine actually uses (checked against the code on 2026-11-06)."""
|
||
if language == "zh":
|
||
return "\n".join([
|
||
"**计算说明**",
|
||
"",
|
||
"- D5、D6、D8、D11 按 PVR《Vedic Astrology: An Integrated Approach》6.2.5–6.2.11 的分盘规则。D8 动宫从白羊、固定宫从射手、双宫从狮子起算;选 Raman 岁差不会改用另一套 Raman D8 规则。",
|
||
"- Yogini 大运按本命月亮星宿与剩余弧度起算,一年取 365.25 天。表中第一段是出生余额,不是一段新的完整大运。",
|
||
"- Vimshottari 第一段小运和细运接着原来完整周期的钟点走,只是不列出生前那一部分,不在出生时刻重新起算。",
|
||
"- Shadbala 按本仓写明的各分量方法计算。最低门槛按 BPHS:太阳 6.5、月亮 6、火星 5、水星 7、木星 6.5、金星 5.5、土星 5 Rupa;门槛比值与总分是两回事。太阳的 Cheshta 等于他的 Ayana Bala,月亮的 Cheshta 等于她的 Paksha Bala(BPHS 27.18),所以在 Kala 细项和 Cheshta 两处各出现一次,这是原文规定,不是重复计算,也不再乘 2。各分量按占星师在 #166 选定的口径;外部软件用的口径不同,个别行星是否达标可能相反,所以达标与否对参数敏感。达标只说明力量,不等于吉;未达标也不等于凶,吉凶要看功能吉凶、宫主、落宫、大运与行运。",
|
||
"- 落陷取消(Neecha Bhanga)记录的是取消条件,行星仍是落陷;它本身不等于王者格,也不保证结果好。",
|
||
"- 计算用的是用户提供的出生资料和上面写明的计算口径。大运交界是算出的时间边界,不是到了那天就会发生的事。检出的瑜伽是规则筛出的候选,还要看成立与取消条件。",
|
||
"- 显示精度:时间到整秒,角度最多四位小数。",
|
||
])
|
||
return "\n".join([
|
||
"**Calculation notes**",
|
||
"",
|
||
"- D5, D6, D8 and D11 use the PVR divisional rules (Vedic Astrology: An Integrated Approach, 6.2.5-6.2.11). D8 starts from Aries for movable signs, Sagittarius for fixed signs and Leo for dual signs. Raman ayanamsa does not select the separate Raman D8 convention.",
|
||
"- Yogini uses the natal Moon nakshatra and its remaining arc, with a year of 365.25 days. Its first visible period is the birth balance, not a new full period.",
|
||
"- The first Vimshottari AD and PD continue the original full-period clock; only their pre-birth portions are omitted. They do not restart at birth.",
|
||
"- Shadbala uses the stated local component methods. Minimum strengths follow BPHS: Sun 6.5, Moon 6, Mars 5, Mercury 7, Jupiter 6.5, Venus 5.5, Saturn 5 Rupas; the threshold ratio is distinct from total strength. The Sun's Cheshta is his Ayana Bala and the Moon's Cheshta is her Paksha Bala (BPHS 27.18), so each value appears once in the Kala detail and once as Cheshta; this is the text's rule, not double counting, and nothing is doubled. Components follow the profile the astrologer chose in #166; external software uses other profiles, and a planet can meet the minimum in one and miss it in the other, so the verdict is parameter-sensitive. Meeting the minimum describes strength only and is not a favourable verdict; missing it is not an unfavourable one. That depends on functional roles, lordship, placement, dashas and transits.",
|
||
"- Neecha Bhanga records a cancellation condition while retaining the debilitated placement. It does not by itself establish a Raja Yoga or promise a favourable outcome.",
|
||
"- Calculations use the birth details supplied and the stated calculation profile. Dasha boundaries are calculated period boundaries, not promises of events on those dates. Detected yogas are rule-screened candidates requiring condition and cancellation review.",
|
||
"- Display precision: timestamps to whole seconds, angles to at most four decimals.",
|
||
])
|
||
|
||
|
||
def insert_calculation_notes(markdown: str, language: str) -> str:
|
||
"""Put the notes at the top of the 「计算口径」 / Calculation chapter."""
|
||
import re
|
||
|
||
notes = calculation_notes(language)
|
||
match = re.search(r"^## (?:计算口径|Calculation Conventions|Calculation conventions)\s*$", markdown or "", flags=re.MULTILINE)
|
||
if not match:
|
||
# No conventions chapter (e.g. a body without the parity chapters): add no new chapter.
|
||
return markdown
|
||
return markdown[: match.end()] + "\n\n" + notes + "\n" + markdown[match.end():]
|