diff --git a/scripts/pl9_full_data_export.py b/scripts/pl9_full_data_export.py index c5731852..afe10e81 100644 --- a/scripts/pl9_full_data_export.py +++ b/scripts/pl9_full_data_export.py @@ -2352,6 +2352,17 @@ def _interpretation_tools(): return attach_full_data_interpretation, opening_focus_block +def _append_parity_chapters(markdown: str, packet: dict, language: str) -> str: + try: + from scripts.report_render.parity_chapters import render_parity_chapters + except ModuleNotFoundError: # pragma: no cover + from report_render.parity_chapters import render_parity_chapters + extra = render_parity_chapters(packet, language).strip() + if not extra: + return markdown + return markdown.rstrip() + "\n\n" + extra + "\n" + + def render_full_data_markdown(packet: dict) -> str: """Upstream `pl9_ai_density` branch at 23be1807, on this site's parity body.""" attach_full_data_interpretation, opening_focus_block = _interpretation_tools() @@ -2386,7 +2397,7 @@ def render_full_data_markdown(packet: dict) -> str: _pl9_english_report_title(packet), ) rendered = _apply_local_report_conventions(rendered, language="en") - return rendered + return _append_parity_chapters(rendered, packet, "en") zh_structured_appendix = re.sub( r"^#\s+.*?(?:\n{2,}|\n)", "", @@ -2408,7 +2419,7 @@ def render_full_data_markdown(packet: dict) -> str: ) rendered = _sanitize_pl9_ai_density_markdown(zh_markdown, _pl9_public_report_title(packet)) rendered = _apply_local_report_conventions(rendered, language="zh") - return rendered + return _append_parity_chapters(rendered, packet, "zh") def read_full_data_domain(packet: Mapping, domain: str) -> dict: diff --git a/scripts/report_render/parity_chapters.py b/scripts/report_render/parity_chapters.py new file mode 100644 index 00000000..de8da7b5 --- /dev/null +++ b/scripts/report_render/parity_chapters.py @@ -0,0 +1,1673 @@ +"""Six parity chapters for issue #91 section A. + +Rows are read from the public packet. Missing labels raise. Raw keys and +C-class sections are not printed. Chinese sentences are written here. +""" + +from __future__ import annotations + +import re +from typing import Any + +try: + from scripts.report_render.appendix_language import ( + friendship_table, + planet_list, + planet_name, + sign_name, + undetermined, + yes_no, + ) + from scripts.report_render.text import SIGNS +except ModuleNotFoundError: # pragma: no cover + from report_render.appendix_language import ( + friendship_table, + planet_list, + planet_name, + sign_name, + undetermined, + yes_no, + ) + from report_render.text import SIGNS + +LABELS: dict[str, tuple[str, str]] = { + "age": ("Age", "岁数"), + "anchor": ("Anchor", "锚点"), + "applicable": ("Applicable", "是否适用"), + "aspect": ("Aspect", "相位"), + "atmakaraka": ("Atmakaraka", "灵魂指示星"), + "bhava_house": ("Bhava house", "Bhava 宫"), + "boundary": ("Boundary", "交界"), + "candidates": ("Candidates", "候选"), + "category": ("Category", "类别"), + "component": ("Component", "分量"), + "compound": ("Compound relation", "复合关系"), + "counted": ("Counted", "计入"), + "date": ("Date", "日期"), + "days": ("Days from reference", "距参考日天数"), + "declination": ("Declination", "赤纬"), + "degree": ("Degree in sign", "落座度数"), + "detected": ("Detected", "是否成立"), + "dignity": ("Dignity", "尊贵"), + "domain": ("Domain", "领域"), + "end": ("End", "结束"), + "example_total": ("Example total", "例题合计"), + "friends": ("Natural friends", "天然友"), + "enemies": ("Natural enemies", "天然敌"), + "neutral": ("Natural neutral", "天然中性"), + "temporary_friends": ("Temporary friends", "临时友"), + "temporary_enemies": ("Temporary enemies", "临时敌"), + "house": ("House", "宫位"), + "item": ("Item", "项目"), + "karaka": ("Karaka", "卡拉卡"), + "level": ("Level", "层级"), + "lord": ("Lord", "主星"), + "longitude": ("Longitude", "黄经"), + "method": ("Method", "口径"), + "months": ("Months", "月数"), + "name": ("Name", "名称"), + "nakshatra": ("Nakshatra", "星宿"), + "occupant": ("Occupants", "落入行星"), + "orb": ("Orb days", "容许天数"), + "order": ("Order", "顺序"), + "pada": ("Pada", "星宿四分"), + "permanent": ("Permanent", "固定分"), + "phase": ("Phase", "阶段"), + "planet": ("Planet", "行星"), + "point": ("Point", "点"), + "policy": ("Node policy", "交点口径"), + "rashi_house": ("Rashi house", "星座宫"), + "reference": ("Reference date", "参考日"), + "role": ("Role", "角色"), + "rule": ("Rule", "规则"), + "score": ("Score", "分数"), + "sign": ("Sign", "星座"), + "speed": ("Speed", "速度"), + "start": ("Start", "开始"), + "status": ("Status", "状态"), + "system": ("System", "系统"), + "topic": ("Topic", "项目"), + "total": ("Total", "合计"), + "value": ("Value", "内容"), + "years": ("Years", "年数"), + "full_years": ("Full years", "完整年数"), + "balance_years": ("Balance years", "余额年数"), + "elapsed_at_birth": ("Elapsed at birth", "出生时已过年数"), + "rashi": ("Rashi", "星座"), + "start_date": ("Start", "开始"), + "end_date": ("End", "结束"), + "yogini": ("Yogini", "Yogini"), + "progressed_longitude": ("Progressed longitude", "推进黄经"), + "progressed_sign": ("Progressed sign", "推进星座"), + "progressed_nakshatra": ("Progressed nakshatra", "推进星宿"), + "progressed_nakshatra_lord": ("Nakshatra lord", "星宿主星"), + "progressed_nakshatra_pada": ("Pada", "星宿四分"), + "progression_rate": ("Progression rate", "推进速率"), + "years_elapsed": ("Years elapsed", "已过年数"), + "degree_in_sign": ("Degree in sign", "落座度数"), + "adhipathi": ("Adhipathi", "宫主力量"), + "dig": ("Dig", "方位力量"), + "drik": ("Drik", "相位力量"), +} + +CHAPTERS: tuple[tuple[str, str], ...] = ( + ("Dasha Appendix", "大运附表"), + ("Annual Appendix", "年运附表"), + ("Strength and Ashtakavarga", "力量与八分法"), + ("Relationships, Aspects and Yogas", "关系、相位与瑜伽"), + ("Nakshatras, Special Points and Transits", "星宿、特殊点与行运"), + ("Calculation Conventions", "计算口径"), +) + +SECTIONS: tuple[tuple[str, str], ...] = ( + ("Vimshottari Birth-Balance", "Vimshottari 出生余额"), + ("Kalachakra Dasha", "Kalachakra 大运"), + ("Yogini Periods", "Yogini 周期"), + ("Tribhagi Profile", "Tribhagi 适用情况"), + ("Tribhagi Periods", "Tribhagi 周期"), + ("Navamsha AD Duration", "Navamsha 小运时长"), + ("Nakshatra Dasha Context", "星宿大运上下文"), + ("Bhrigu Cycle", "Bhrigu 周期"), + ("Bhrigu Approximate Progression", "Bhrigu 近似推进序列"), + ("Conditional Dasha Applicability", "条件大运是否适用"), + ("Dasha Boundary Scan", "大运与小运交界检查"), + ("Current Dasha Convergence", "当前多系统收敛"), + ("Chara Dasha Period Durations", "Chara 大运时长"), + ("Narayana Method Comparison", "Narayana 口径对照"), + ("Annual Return Calculation Contract", "年运返照计算约定"), + ("Tajika Aspect and Yoga Candidates", "Tajika 相位与瑜伽候选"), + ("Annual Tajika Strength", "年度 Tajika 力量"), + ("Shadbala Subcomponent", "六维力量分项"), + ("Shadbala Result", "六维力量结果"), + ("House Components and Physical Coordinates", "宫位分量与坐标"), + ("Bhava Bala Three-Component", "Bhava Bala 三分量"), + ("Vimsopaka Contribution", "Vimsopaka 分盘贡献"), + ("AV Contribution and Reduction", "八分法加减"), + ("AV Yoga Pinda", "八分法 Yoga Pinda"), + ("AV Sign Assessments", "八分法星座评估"), + ("Prastara Totals", "Prastara 合计"), + ("Avastha Inputs", "行星状态输入"), + ("D30 States", "D30 状态"), + ("D30 Source Input", "D30 输入"), + ("Pushkara Basis", "Pushkara 判断依据"), + ("Lagna Vargottamamsa", "上升 Vargottamamsa"), + ("Goel Jaimini Strength and Special Points", "Goel Jaimini 强度与特殊点"), + ("Neutral and Compound Relationships", "行星中性与复合友敌关系"), + ("Relationship Conventions", "关系口径"), + ("Dispositor Terminals", "定位星终点与循环"), + ("Dispositor Chain", "定位星链"), + ("Aspect Geometry", "相位几何"), + ("Argala", "Argala 支持与阻挡"), + ("Congregation", "行星聚集"), + ("Bhrigu Relationship Geometry", "Bhrigu 关系几何"), + ("Yoga Participants", "瑜伽参与行星"), + ("Canonical Yoga Rows", "瑜伽条目"), + ("Whole-Sign Occupants", "整宫落入"), + ("Nakshatra Reference", "星宿参照"), + ("Tara-Chandra Matrix", "Tara 与 Chandra 矩阵"), + ("Natal Tithi Lord", "本命 Tithi 主星"), + ("Birth Calendar", "出生历法"), + ("Pancha Pakshi", "五鸟活动"), + ("Natal Vivah Saham", "本命 Vivah Saham"), + ("Natal Saham Formula Basis", "本命特殊点公式依据"), + ("Arudha / UL / A10", "Arudha、Upapada 与 A10"), + ("Jaimini Calculation Details", "Jaimini 计算细节"), + ("Graha Pada", "Graha Pada"), + ("Karakamsha", "Karakamsha"), + ("Darakaraka", "Darakaraka"), + ("Rashi Tulya Navamsa", "Rashi Tulya Navamsa"), + ("Moon Chart Reference", "月亮参考盘"), + ("Sudarshana Points", "Sudarshana 参考点"), + ("Dated Transit", "注明日期的行运"), + ("Multi-Reference Transit", "多参考点行运"), + ("Double Transit PAC", "木土双重行运 PAC"), + ("Lagna / 7th Lord Transit", "上升主与七宫主行运"), + ("Natal Calculation Conventions", "本命计算口径"), + ("KP Calculation Conventions", "KP 计算口径"), + ("D9 Dignity", "D9 尊贵"), + ("Divisional Mapping", "分盘映射"), + ("Bhava Chalit Planet Placements", "Bhava Chalit 行星落宫"), + ("Bhava Chalit Centres", "Bhava Chalit 宫心"), + ("Bhava Chalit Convention", "Bhava Chalit 口径"), +) + +FORBIDDEN_TITLES = ( + "Annual Return House Framework", + "Tripataki Chakra Detail", + "Annual Saham Formula Trace", + "Natal Saham Formula Trace", + "Jaimini Yogi and Ava Yogi", + "Annual Chart Numeric Data", + "Bhava Bala Relative Score Components", + "Score Extremes", + "独立年度方法参考", + "三年逐月星历", + "D81", + "D108", + "D144", +) + +_SNAKE = re.compile(r"^[a-z0-9_]+$") +_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}") + +ENUMS: dict[str, tuple[str, str]] = { + "blocked": ("Not determined", "未判定"), + "executed": ("Computed", "已算出"), + "computed": ("Computed", "已算出"), + "partial_verified": ("Partly computed", "部分已算出"), + "not_applicable": ("Not applicable", "不适用"), + "selected": ("Selected", "已选定"), + "conjunction": ("Conjunction", "合相"), + "opposition": ("Opposition", "对冲"), + "trine": ("Trine", "拱相"), + "square": ("Square", "刑相"), + "sextile": ("Sextile", "六合"), + "exalted": ("Exalted", "擢升"), + "debilitated": ("Debilitated", "落陷"), + "mooltrikona": ("Moolatrikona", "根本三分"), + "own": ("Own sign", "入庙"), + "include_nodes_text": ("Include nodes (text profile)", "计入交点(正文口径)"), + "exclude_nodes_worked_example": ("Worked example excludes nodes", "例题不计交点"), + "sripati": ("Sripati", "Sripati"), + "sandhi": ("Sandhi", "交界"), + "full": ("Full", "完全"), + "partial": ("Partial", "部分"), + "none": ("None", "无"), + "start": ("Start", "开始"), + "end": ("End", "结束"), + "mahadasha": ("Mahadasha", "大运"), + "antardasha": ("Antardasha", "小运"), + "applicable": ("Applicable", "适用"), + "mean": ("Mean node", "平交点"), + "true": ("True node", "真交点"), +} + +PROFILES: dict[str, tuple[str, str]] = { + "legacy_forward_v0": ("Legacy duration", "既有时长"), + "legacy_zodiacal_v0": ("Legacy sequence", "既有顺序"), + "jaimini_odd_footed_dignity_v2": ("Odd-footed dignity duration", "奇足尊贵时长"), + "rath_general_table10_v1": ("Rath table 10", "Rath 表10"), + "rath_saturn_table11_v1": ("Rath table 11", "Rath 表11"), + "jaimini_lagna_seventh_strength_v1": ("Lagna and seventh strength", "上升与七宫力量"), + "parashara_equal_v1": ("Equal sub-periods", "等分小运"), + "legacy_primary_lord_v0": ("Legacy primary lord", "既有主星"), + "legacy_lagna_v0": ("From the ascendant", "从上升起"), + "legacy_comparison_only": ("Legacy comparison only", "只保留既有对照"), +} + +REASONS: dict[str, tuple[str, str]] = { + "rath_ketu_table12_v1_not_in_local_sequence_profiles": ( + "No local table-12 sequence when Ketu is in the seed sign", + "计都在起算星座时,本地没有表12顺序", + ), + "eighth_lord_from_AK_occupies_eighth_from_AK": ( + "The eighth lord from the atmakaraka occupies that eighth sign", + "灵魂指示星起第八宫的宫主落在该宫", + ), + "strongest_6_8_12_lord_from_stronger_Lagna_seventh_excluding_Saturn": ( + "Strongest of the 6th, 8th and 12th lords from the stronger of lagna and the seventh, excluding Saturn", + "上升与第七宫中较强的一方,取其 6、8、12 宫主里最强且不是土星的一颗", + ), + "eighth_lord_from_AK_compare_twelfth_if_exalted": ( + "Eighth lord from the atmakaraka, compared with the twelfth lord when that eighth lord is exalted", + "灵魂指示星起第八宫主;若该星擢升,再与第十二宫主比较", + ), + "stronger_8th_2nd_lord_unless_only_weaker_has_malefic_contact": ( + "The stronger of the 8th and 2nd lords, unless only the weaker one has malefic contact", + "第八宫主与第二宫主取较强者;仅较弱者受凶星接触时改取较弱者", + ), + "equal_Goel_strength_no_unique_selector": ( + "Equal strength, so there is no single selected planet", + "力量相同,没有唯一入选星", + ), + "equal_Lagna_seventh_Goel_strength": ( + "Lagna and the seventh have equal strength", + "上升与第七宫力量相同", + ), + "equal_degree_seven_karaka_rank_requires_finer_positions": ( + "Equal degrees among the seven planets; the present precision cannot rank them", + "七颗星度数相同,现有精度排不出顺序", + ), +} + +DOMAINS: dict[str, tuple[str, str]] = { + "self_health": ("Self and health", "自身与健康"), + "wealth_family": ("Resources and family", "资源与家庭"), + "communication_skill": ("Communication and skill", "沟通与技能"), + "home_mother": ("Home and mother", "家宅与母亲"), + "creativity_children": ("Creativity and children", "创造与子女"), + "health_service": ("Health and service", "健康与事务"), + "marriage_partnership": ("Marriage and partnership", "婚姻与合作"), + "transformation": ("Transformation", "转变"), + "fortune_dharma": ("Fortune and dharma", "福运与法"), + "career_status": ("Career and status", "事业与地位"), + "gains_wishes": ("Gains and wishes", "收获与愿望"), + "loss_spirituality": ("Loss and inward life", "损耗与内在"), +} + +LEVELS: dict[str, tuple[str, str]] = { + "L1": ("One system is active; transits still have to be read with it.", "单系统激活,还要看行运。"), + "L3": ("Two systems are active together.", "两个系统同时激活。"), + "L4": ("Three systems are active together.", "三个系统同时激活。"), + "L5": ("Four or more systems are active together.", "四个及以上系统同时激活。"), +} + +FAMILIES: dict[str, tuple[str, str]] = { + "ashtottari": ("Ashtottari", "Ashtottari"), + "tribhagi": ("Tribhagi", "Tribhagi"), + "tribhagi_40": ("Tribhagi 40", "Tribhagi 40"), + "shodashottari": ("Shodashottari", "Shodashottari"), + "dwadashottari": ("Dwadashottari", "Dwadashottari"), + "dwisaptatisama": ("Dwisaptati Sama", "Dwisaptati Sama"), + "shattrimshatsama": ("Shattrimsha Sama", "Shattrimsha Sama"), + "panchottari": ("Panchottari", "Panchottari"), + "satabdika": ("Satabdika", "Satabdika"), + "chaturshitisama": ("Chaturshiti Sama", "Chaturshiti Sama"), + "shastihayani": ("Shastihayani", "六十年运"), + "kala_chakra": ("Kalachakra", "Kalachakra"), + "yogini": ("Yogini", "Yogini"), + "narayana": ("Narayana", "Narayana"), +} + +ASPECT_TYPES = { + "conjunction": ("Conjunction", "合相"), + "opposition": ("Opposition", "对冲"), + "square": ("Square", "刑相"), + "trine": ("Trine", "拱相"), + "sextile": ("Sextile", "六合"), +} + + +def lab(key: str, language: str) -> str: + pair = LABELS.get(key) + if not pair or not pair[0] or not pair[1]: + raise KeyError(key) + return pair[1] if language == "zh" else pair[0] + + +def _enum(value: Any, language: str) -> str | None: + text = str(value or "") + for table in (ENUMS, PROFILES, REASONS, DOMAINS, FAMILIES): + pair = table.get(text) + if pair: + return pair[1] if language == "zh" else pair[0] + level = LEVELS.get(text) + if level: + return level[1] if language == "zh" else level[0] + return None + + +def show(value: Any, language: str, kind: str = "text") -> str: + if isinstance(value, dict): + return undetermined(language) + if value is None or value == "": + return undetermined(language) + if isinstance(value, bool): + return yes_no(value, language) + if isinstance(value, float): + return f"{value:.4f}".rstrip("0").rstrip(".") + if isinstance(value, int) and kind == "sign": + named = sign_name(value, language) + return _english_safe(named, language) + if isinstance(value, int): + return str(value) + if kind == "planet": + named = planet_name(value, language) + if "_" in str(named): + return undetermined(language) + return _english_safe(named, language) + if kind == "sign": + return _english_safe(sign_name(value, language), language) + if kind == "planets": + return _english_safe(planet_list(value, language) or undetermined(language), language) + if isinstance(value, (list, tuple)): + parts = [show(item, language, "planet" if kind == "planet_list" else "text") for item in value[:12]] + parts = [part for part in parts if part and part != undetermined(language)] + if not parts: + return undetermined(language) + joined = "、".join(parts) if language == "zh" else ", ".join(parts) + return _english_safe(joined, language) + text = str(value).strip() + mapped = _enum(text, language) + if mapped: + return mapped + if _SNAKE.fullmatch(text) and "_" in text: + return undetermined(language) + if re.search(r"\b(?:source_locator|profile_id|niryaana_shoola_strength|source_discrepancy|parameter_sensitive|sign_idx)\b", text): + return undetermined(language) + if re.search(r"[A-Za-z0-9]+_[A-Za-z0-9_]+", text): + return undetermined(language) + if language == "en" and re.search(r"[\u4e00-\u9fff]", text): + return undetermined(language) + if language == "zh" and re.search(r"\b(in|and|or|from|planet|lord)\b", text, re.IGNORECASE): + return sign_name(text, language) if text in SIGNS else undetermined(language) + if kind == "sign" or text in SIGNS: + return _english_safe(sign_name(text, language), language) + return text + + +def _english_safe(text: str, language: str) -> str: + if language == "en" and re.search(r"[\u4e00-\u9fff]", text or ""): + return undetermined(language) + return text or undetermined(language) + + +def _dig(root: Any, path: tuple[str, ...]) -> Any: + current = root + for key in path: + if not isinstance(current, dict) or key not in current: + return None + current = current[key] + return current + + +def _first(packet: dict, tail: tuple[str, ...]) -> Any: + for prefix in ((), ("worksheets",)): + found = _dig(packet, prefix + tail) + if found not in (None, {}, ""): + return found + return None + + +def _md_table(headers: list[str], rows: list[list[Any]]) -> str: + if not rows: + return "" + lines = ["| " + " | ".join(headers) + " |", "|" + "|".join(["---"] * len(headers)) + "|"] + for row in rows: + cells = [str(cell).replace("|", "/").replace("\n", " ") for cell in row] + lines.append("| " + " | ".join(cells) + " |") + return "\n".join(lines) + + +def _section(title_en: str, language: str, body: str) -> str: + titles = dict(SECTIONS) + zh = titles[title_en] + heading = zh if language == "zh" else title_en + text = body.strip() or undetermined(language) + return f"## {heading}\n\n{text}" + + +def _blank(language: str, columns: list[str]) -> str: + headers = [lab(key, language) for key in columns] + row = [undetermined(language) for _ in columns] + return _md_table(headers, [row]) + + +def _rows_from(items: list[Any], language: str, columns: list[tuple[str, str]]) -> str: + headers = [lab(key, language) for key, _kind in columns] + rows: list[list[str]] = [] + for item in items: + if not isinstance(item, dict): + continue + rows.append([show(item.get(key), language, kind) for key, kind in columns]) + if not rows: + return _blank(language, [key for key, _kind in columns]) + keep = [index for index, _column in enumerate(columns) if any(row[index] != undetermined(language) for row in rows)] + if not keep: + return _blank(language, [key for key, _kind in columns]) + headers = [headers[index] for index in keep] + rows = [[row[index] for index in keep] for row in rows] + return _md_table(headers, rows) + + +def _months(start: Any, end: Any) -> str | None: + if not isinstance(start, str) or not isinstance(end, str): + return None + if not _DATE.match(start) or not _DATE.match(end): + return None + from datetime import datetime + try: + days = (datetime.strptime(end[:10], "%Y-%m-%d") - datetime.strptime(start[:10], "%Y-%m-%d")).days + except ValueError: + return None + if days <= 0: + return None + return f"{days / 30.436875:.2f}".rstrip("0").rstrip(".") + + +def _vimshottari(packet: dict, language: str) -> str: + dasha = _first(packet, ("timing_and_predictive_systems", "dasha")) + timeline = dasha.get("timeline") if isinstance(dasha, dict) else None + row = next((item for item in timeline or [] if isinstance(item, dict) and item.get("is_balance")), None) + if not isinstance(row, dict): + return _blank(language, ["lord", "years", "start", "end"]) + return _rows_from([row], language, [ + ("lord", "planet"), ("years", "text"), ("full_years", "text"), + ("balance_years", "text"), ("elapsed_at_birth", "text"), + ("start", "text"), ("end", "text"), + ]) + + +def _major_list(module: Any) -> list[Any]: + if not isinstance(module, dict): + return [] + for key in ("major", "mahadasha", "periods", "dasha_sequence"): + value = module.get(key) + if isinstance(value, list) and value: + return value + return [] + + +def _kalachakra(packet: dict, language: str) -> str: + 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)] + return _rows_from(rows, language, [ + ("lord", "planet"), ("rashi", "sign"), ("sign", "sign"), + ("years", "text"), ("start_date", "text"), ("end_date", "text"), + ("start", "text"), ("end", "text"), + ]) + + +def _yogini(packet: dict, language: str) -> str: + module = _first(packet, ("timing_and_predictive_systems", "yogini_dasha")) + return _rows_from(_major_list(module), language, [ + ("yogini", "text"), ("lord", "planet"), ("planet", "planet"), + ("years", "text"), ("start", "text"), ("end", "text"), + ("start_date", "text"), ("end_date", "text"), + ]) + + +def _family(packet: dict, key: str) -> dict: + families = _first(packet, ("timing_and_predictive_systems", "dasha_master_pack", "families")) + item = families.get(key) if isinstance(families, dict) else None + return item if isinstance(item, dict) else {} + + +def _tribhagi_profile(packet: dict, language: str) -> str: + rows = [] + for key in ("tribhagi", "tribhagi_40"): + item = _family(packet, key) + status = str(item.get("execution_status") or item.get("status") or "") + applicable = "applicable" if status == "executed" else "not_applicable" if status == "not_applicable" else "" + rows.append({ + "name": FAMILIES[key][1 if language == "zh" else 0], + "applicable": applicable or None, + }) + return _rows_from(rows, language, [("name", "text"), ("applicable", "text")]) + + +def _tribhagi_periods(packet: dict, language: str) -> str: + periods = _family(packet, "tribhagi").get("periods") + if not isinstance(periods, list): + periods = [] + return _rows_from(periods, language, [ + ("lord", "planet"), ("planet", "planet"), ("sign", "sign"), + ("years", "text"), ("start_date", "text"), ("end_date", "text"), + ("start", "text"), ("end", "text"), + ]) + + +def _navamsha(packet: dict, language: str) -> str: + module = _first(packet, ("timing_and_predictive_systems", "navamsha_dasha")) + periods = [] + if isinstance(module, dict): + majors = module.get("periods") or module.get("native_result", {}).get("periods") if isinstance(module.get("native_result"), dict) else module.get("periods") + if isinstance(module.get("native_result"), dict): + majors = module["native_result"].get("periods") or majors + for major in majors or []: + if not isinstance(major, dict): + continue + for child in major.get("antardasha") or []: + if not isinstance(child, dict): + continue + copied = dict(child) + copied["months"] = child.get("duration_months") or _months(child.get("start"), child.get("end")) + periods.append(copied) + if not periods: + note = "公开报告包没有带上 Navamsha 小运行。" if language == "zh" else "The public packet does not carry the Navamsha sub-period rows." + return note + "\n\n" + _blank(language, ["sign", "start", "end", "months"]) + return _rows_from(periods, language, [("sign", "sign"), ("start", "text"), ("end", "text"), ("months", "text")]) + + +def _nakshatra_context(packet: dict, language: str) -> str: + dasha = _first(packet, ("timing_and_predictive_systems", "dasha")) or {} + ashtottari = _first(packet, ("timing_and_predictive_systems", "ashtottari_dasha")) or {} + row = { + "nakshatra": dasha.get("moon_nakshatra") or dasha.get("nakshatra") if isinstance(dasha, dict) else None, + "lord": dasha.get("start_lord") if isinstance(dasha, dict) else None, + "applicable": ashtottari.get("applicable") if isinstance(ashtottari, dict) else None, + } + return _rows_from([row], language, [("nakshatra", "text"), ("lord", "planet"), ("applicable", "text")]) + + +def _bhrigu_cycle(packet: dict, language: str) -> str: + module = _first(packet, ("timing_and_predictive_systems", "bhrigu_pada_dasha")) or {} + moon = module.get("birth_moon") if isinstance(module, dict) else {} + if not isinstance(moon, dict): + moon = {} + sign = moon.get("sign_cn") if language == "zh" and moon.get("sign_cn") else moon.get("sign") + note = ( + "下面的年龄是推进坐标的取样,不是事件日期。" + if language == "zh" + else "The ages below are samples of the progressed coordinate, not event dates." + ) + row = {"sign": sign, "longitude": moon.get("longitude"), "method": "Bhrigu Pada"} + return note + "\n\n" + _rows_from([row], language, [("sign", "sign"), ("longitude", "text"), ("method", "text")]) + + +def _bhrigu_sequence(packet: dict, language: str) -> str: + module = _first(packet, ("timing_and_predictive_systems", "bhrigu_pada_dasha")) or {} + rows = [] + for item in module.get("dasha_sequence") or []: + if not isinstance(item, dict): + continue + copied = dict(item) + if language == "zh" and item.get("progressed_sign_cn"): + copied["progressed_sign"] = item.get("progressed_sign_cn") + rows.append(copied) + return _rows_from(rows, language, [ + ("age", "text"), ("years_elapsed", "text"), ("progressed_longitude", "text"), + ("progressed_sign", "sign"), ("progressed_nakshatra", "text"), + ("progressed_nakshatra_lord", "planet"), ("progressed_nakshatra_pada", "text"), + ("progression_rate", "text"), + ]) + + +def _applicability(packet: dict, language: str) -> str: + rows = [] + for key, pair in FAMILIES.items(): + if key in {"yogini", "narayana", "kala_chakra"}: + continue + item = _family(packet, key) + if not item and key != "ashtottari": + module = _first(packet, ("timing_and_predictive_systems", f"{key}_dasha")) + item = module if isinstance(module, dict) else {} + if key == "ashtottari" and not item: + item = _first(packet, ("timing_and_predictive_systems", "ashtottari_dasha")) or {} + status = str(item.get("execution_status") or item.get("status") or "") + if item.get("applicable") is True: + applicable = "applicable" + elif item.get("applicable") is False or status == "not_applicable": + applicable = "not_applicable" + elif status == "executed": + applicable = "applicable" + else: + applicable = "" + rows.append({"name": pair[1 if language == "zh" else 0], "applicable": applicable or None}) + return _rows_from(rows, language, [("name", "text"), ("applicable", "text")]) + + +def _sandhi(packet: dict, language: str) -> str: + module = _first(packet, ("timing_and_predictive_systems", "dasha_sandhi")) + if not isinstance(module, dict): + return _blank(language, ["reference", "orb", "status"]) + windows = module.get("sandhi_windows") if isinstance(module.get("sandhi_windows"), list) else [] + if not windows: + sentence = ( + f"参考日 {module.get('reference_date') or undetermined(language)} 起 {module.get('orb_days') if module.get('orb_days') is not None else undetermined(language)} 天内没有大运或小运交界。" + if language == "zh" + else f"No mahadasha or antardasha boundary falls within {module.get('orb_days')} days of {module.get('reference_date')}." + ) + return sentence + rows = [] + for item in windows: + if isinstance(item, dict): + rows.append({ + "level": item.get("level"), + "lord": item.get("lord"), + "boundary": item.get("boundary"), + "date": item.get("date"), + "days": item.get("days_from_reference"), + }) + return _rows_from(rows, language, [("level", "text"), ("lord", "planet"), ("boundary", "text"), ("date", "text"), ("days", "text")]) + + +def _convergence(packet: dict, language: str) -> str: + timing = _first(packet, ("ai_and_audit", "ai_prompt_pack", "evidence_snapshot", "timing")) or {} + pairs = timing.get("convergence_top_domains") if isinstance(timing, dict) else None + module = _first(packet, ("timing_and_predictive_systems", "dasa_convergence")) + activations = module.get("domain_activations") if isinstance(module, dict) else None + rows = [] + if isinstance(activations, dict) and activations: + for domain, info in activations.items(): + if not isinstance(info, dict): + continue + rows.append({ + "domain": domain, + "house": info.get("house"), + "level": info.get("convergence_level"), + "counted": info.get("system_count"), + }) + elif isinstance(pairs, list): + for item in pairs: + if isinstance(item, (list, tuple)) and len(item) >= 2: + rows.append({"domain": item[0], "level": item[1]}) + elif isinstance(item, dict): + rows.append({"domain": item.get("domain") or item.get("name"), "level": item.get("level") or item.get("convergence_level")}) + if not rows: + note = "公开报告包没有带上逐领域激活行。" if language == "zh" else "The public packet does not carry the per-domain activation rows." + return note + "\n\n" + _blank(language, ["domain", "level"]) + return _rows_from(rows, language, [("domain", "text"), ("house", "text"), ("level", "text"), ("counted", "text")]) + + +def _chara(packet: dict, language: str) -> str: + module = _first(packet, ("advanced_systems", "jaimini", "chara_dasha")) or {} + sequence = module.get("dasha_sequence") if isinstance(module, dict) else None + rows = [] + for major in sequence or []: + if not isinstance(major, dict): + continue + rows.append({"level": "mahadasha", "sign": major.get("sign"), "lord": major.get("lord"), "years": major.get("duration_years"), "order": major.get("order")}) + for antar in major.get("antardashas") or []: + if not isinstance(antar, dict): + continue + rows.append({"level": "antardasha", "sign": antar.get("sign"), "lord": antar.get("lord"), "years": antar.get("duration_years"), "order": antar.get("order")}) + for sub in antar.get("pratyantar_dashas") or []: + if isinstance(sub, dict): + rows.append({"level": "PD", "sign": sub.get("sign"), "lord": sub.get("lord"), "years": sub.get("duration_years"), "order": sub.get("order")}) + if not rows: + return _blank(language, ["level", "sign", "lord", "years"]) + rendered = [] + for row in rows: + level = row["level"] + if level == "PD": + level_text = "再小运" if language == "zh" else "Pratyantardasha" + else: + level_text = show(level, language) + rendered.append([level_text, show(row["sign"], language, "sign"), show(row["lord"], language, "planet"), show(row["years"], language), show(row["order"], language)]) + return _md_table([lab(key, language) for key in ("level", "sign", "lord", "years", "order")], rendered) + + +def _narayana_block(report: Any, language: str, fallback: str) -> str: + if not isinstance(report, dict): + return fallback + if report.get("status") == "blocked" or report.get("reason"): + reason = show(report.get("reason") or report.get("status"), language) + status = show(report.get("status") or "blocked", language) + return _rows_from([{"status": status, "value": reason}], language, [("status", "text"), ("value", "text")]) + profiles = [] + for key in ("duration_profile", "sequence_profile", "seed_profile", "dual_lord_profile", "antardasha_profile"): + if report.get(key): + profiles.append(show(report.get(key), language)) + lines = [_rows_from([{"method": ";".join(profiles) if language == "zh" else "; ".join(profiles), "years": report.get("total_cycle_years")}], language, [("method", "text"), ("years", "text")])] + lines.append(_rows_from(report.get("mahadasha_sequence") or [], language, [ + ("sign", "sign"), ("lord", "planet"), ("years", "text"), ("start", "text"), ("end", "text"), + ])) + return "\n\n".join(lines) + + +def _narayana(packet: dict, language: str) -> str: + module = _first(packet, ("timing_and_predictive_systems", "narayana_dasha")) or {} + comparison = module.get("method_comparison") if isinstance(module, dict) else {} + if not isinstance(comparison, dict): + comparison = {} + source = comparison.get("source_aware") + legacy = comparison.get("legacy") or module + source_title = "来源感知口径" if language == "zh" else "Source-aware method" + legacy_title = "既有口径" if language == "zh" else "Legacy method" + missing = undetermined(language) + return "\n\n".join([ + f"### {source_title}", + "", + _narayana_block(source, language, missing), + "", + f"### {legacy_title}", + "", + _narayana_block(legacy, language, missing), + ]) + + +def _annual(packet: dict, language: str) -> str: + pack = _first(packet, ("timing_and_predictive_systems", "annual_tajika_pack")) or {} + solar = pack.get("solar_return") if isinstance(pack, dict) else {} + chart = pack.get("annual_chart") if isinstance(pack, dict) else {} + muntha = pack.get("muntha") if isinstance(pack, dict) else {} + year_lord = pack.get("year_lord") if isinstance(pack, dict) else {} + solar_data = solar.get("data") if isinstance(solar, dict) else {} + chart_data = chart.get("data") if isinstance(chart, dict) else {} + muntha_data = muntha.get("data") if isinstance(muntha, dict) else {} + lord_data = year_lord.get("data") if isinstance(year_lord, dict) else {} + row = { + "date": (solar_data or {}).get("dt_local"), + "sign": (chart_data or {}).get("asc_sign"), + "value": (muntha_data or {}).get("muntha_sign"), + "lord": (lord_data or {}).get("year_lord"), + } + return _rows_from([row], language, [("date", "text"), ("sign", "sign"), ("value", "sign"), ("lord", "planet")]) + + +def _tajika_candidates(packet: dict, language: str) -> str: + pack = _first(packet, ("timing_and_predictive_systems", "annual_tajika_pack")) or {} + field = pack.get("tajika_yogas") if isinstance(pack, dict) else {} + data = field.get("data") if isinstance(field, dict) else {} + rows = [] + for item in (data or {}).get("candidate_yogas") or (data or {}).get("interactions") or []: + if not isinstance(item, dict): + continue + rows.append({ + "name": item.get("name") or item.get("yoga") or item.get("type"), + "planet": item.get("planet") or item.get("planets") or item.get("forming_planets"), + "status": item.get("present") if "present" in item else item.get("motion") or item.get("status"), + }) + return _rows_from(rows, language, [("name", "text"), ("planet", "planets"), ("status", "text")]) + + +def _tajika_strength(packet: dict, language: str) -> str: + pack = _first(packet, ("timing_and_predictive_systems", "annual_tajika_pack")) or {} + field = pack.get("tajika_strength") if isinstance(pack, dict) else {} + data = field.get("data") if isinstance(field, dict) else {} + rows = [] + for label, key in (("Harsha", "harsha_bala"), ("Panchavargiya", "panchavargiya_bala")): + table = (data or {}).get(key) if isinstance(data, dict) else None + if not isinstance(table, dict): + continue + for planet, item in table.items(): + score = item.get("score") if isinstance(item, dict) else item + rows.append({"name": label, "planet": planet, "score": score}) + return _rows_from(rows, language, [("name", "text"), ("planet", "planet"), ("score", "text")]) + + +def _shadbala_planets(packet: dict) -> dict: + module = _first(packet, ("strengths_and_scores", "shadbala")) or {} + planets = module.get("planets") if isinstance(module, dict) else None + return planets if isinstance(planets, dict) else {} + + +def _component_number(value: Any) -> Any: + if isinstance(value, (int, float)) and not isinstance(value, bool): + return value + if isinstance(value, dict): + for key in ("total", "score", "value", "virupas"): + if isinstance(value.get(key), (int, float)) and not isinstance(value.get(key), bool): + return value[key] + return None + + +def _shadbala_parts(packet: dict, language: str, result_only: bool) -> str: + rows = [] + for planet, item in _shadbala_planets(packet).items(): + if not isinstance(item, dict): + continue + if result_only: + rows.append({"planet": planet, "total": item.get("total") or item.get("shadbala") or item.get("total_virupas")}) + continue + rows.append({ + "planet": planet, + "total": _component_number(item.get("sthana_bala") or item.get("sthana")), + "value": _component_number(item.get("dig_bala") or item.get("dig")), + "score": _component_number(item.get("kala_bala") or item.get("kala")), + "years": _component_number(item.get("chesta_bala") or item.get("cheshta_bala") or item.get("chesta")), + "degree": _component_number(item.get("naisargika_bala") or item.get("naisargika")), + "orb": _component_number(item.get("drik_bala") or item.get("drik")), + }) + columns = [("planet", "planet"), ("total", "text")] if result_only else [ + ("planet", "planet"), ("total", "text"), ("value", "text"), ("score", "text"), + ("years", "text"), ("degree", "text"), ("orb", "text"), + ] + return _rows_from(rows, language, columns) + + +def _coordinates(packet: dict, language: str) -> str: + core = _dig(packet, ("core_chart", "planets")) or {} + rows = [] + for planet, item in _shadbala_planets(packet).items(): + kala = item.get("kala_bala") if isinstance(item, dict) else None + declination = kala.get("declination") if isinstance(kala, dict) else None + speed = core.get(planet, {}).get("speed") if isinstance(core.get(planet), dict) else None + rows.append({"planet": planet, "declination": declination, "speed": speed}) + return _rows_from(rows, language, [("planet", "planet"), ("declination", "text"), ("speed", "text")]) + + +def _bhava(packet: dict, language: str) -> str: + module = _first(packet, ("strengths_and_scores", "bhava_bala")) or {} + houses = module.get("houses") if isinstance(module, dict) else None + rows = [] + for item in houses or []: + if not isinstance(item, dict): + continue + rows.append({ + "house": item.get("house"), + "sign": item.get("sign"), + "adhipathi": item.get("adhipathi_bala"), + "dig": item.get("dig_bala"), + "drik": item.get("drik_bala"), + "total": item.get("total"), + }) + return _rows_from(rows, language, [("house", "text"), ("sign", "sign"), ("adhipathi", "text"), ("dig", "text"), ("drik", "text"), ("total", "text")]) + + +def _vimsopaka(packet: dict, language: str) -> str: + module = _first(packet, ("strengths_and_scores", "vimsopaka")) or {} + rows = [] + planets = module.get("planets") if isinstance(module, dict) else module + if isinstance(planets, dict): + for planet, item in planets.items(): + if isinstance(item, dict) and ("total_score" in item or "category" in item): + rows.append({"planet": planet, "score": item.get("total_score"), "category": item.get("category")}) + elif isinstance(planets, list): + rows = [item for item in planets if isinstance(item, dict)] + return _rows_from(rows, language, [("planet", "planet"), ("score", "text"), ("category", "text")]) + + +def _ashtakavarga(packet: dict) -> dict: + module = _first(packet, ("strengths_and_scores", "ashtakavarga")) + return module if isinstance(module, dict) else {} + + +def _av_reduction(packet: dict, language: str) -> str: + av = _ashtakavarga(packet) + rows = [] + reduction = av.get("shodhya_pinda") or av.get("reduction") or av.get("sodhya") + if isinstance(reduction, dict): + for key, value in reduction.items(): + if isinstance(value, (int, float)) and not isinstance(value, bool): + rows.append({"sign": key, "score": value}) + elif isinstance(value, dict): + rows.append({"sign": value.get("sign") or key, "score": value.get("shodhya") or value.get("score") or value.get("total")}) + return _rows_from(rows, language, [("sign", "sign"), ("score", "text")]) + + +def _av_pinda(packet: dict, language: str) -> str: + av = _ashtakavarga(packet) + pinda = av.get("yoga_pinda") + rows = [] + if isinstance(pinda, dict): + for key, value in pinda.items(): + if isinstance(value, (int, float)) and not isinstance(value, bool): + rows.append({"name": key, "score": value}) + elif isinstance(value, dict): + rows.append({"name": key, "score": value.get("yoga_pinda") or value.get("total") or value.get("score")}) + return _rows_from(rows, language, [("name", "text"), ("score", "text")]) + + +def _av_signs(packet: dict, language: str) -> str: + sav = _ashtakavarga(packet).get("sav") + scores = sav.get("scores") if isinstance(sav, dict) else sav + assessment = sav.get("assessment") if isinstance(sav, dict) else None + rows = [] + if isinstance(scores, dict): + for sign, score in scores.items(): + extra = assessment.get(sign) if isinstance(assessment, dict) else None + rows.append({"sign": sign, "score": score, "status": extra if not isinstance(extra, dict) else extra.get("assessment") or extra.get("status")}) + return _rows_from(rows, language, [("sign", "sign"), ("score", "text"), ("status", "text")]) + + +def _prastara(packet: dict, language: str) -> str: + av = _ashtakavarga(packet) + prastara = av.get("prastara") or av.get("bav") + rows = [] + if isinstance(prastara, dict): + for planet, item in prastara.items(): + if isinstance(item, dict): + total = item.get("total") + if total is None and all(isinstance(value, (int, float)) for value in item.values()): + total = sum(value for value in item.values() if isinstance(value, (int, float))) + rows.append({"planet": planet, "total": total}) + return _rows_from(rows, language, [("planet", "planet"), ("total", "text")]) + + +def _avastha(packet: dict, language: str) -> str: + module = _first(packet, ("advanced_systems", "avasthas")) or {} + rows = [] + planets = module.get("planets") if isinstance(module, dict) else module + if isinstance(planets, dict): + for planet, item in planets.items(): + if isinstance(item, dict): + rows.append({"planet": planet, "status": item.get("jagradadi") or item.get("state") or item.get("avastha"), "degree": item.get("degree") or item.get("degree_in_sign")}) + elif isinstance(planets, list): + rows = [item for item in planets if isinstance(item, dict)] + return _rows_from(rows, language, [("planet", "planet"), ("status", "text"), ("degree", "text")]) + + +def _d30(packet: dict, language: str, source: bool) -> str: + module = _first(packet, ("advanced_systems", "trimshamsa_d30")) or _first(packet, ("divisional_and_special_charts", "varga_full", "D30")) + rows = [] + planets = module.get("planets") if isinstance(module, dict) else None + if isinstance(planets, dict): + for planet, item in planets.items(): + if not isinstance(item, dict): + continue + if source: + rows.append({"planet": planet, "degree": item.get("degree_in_sign") or item.get("degree"), "sign": item.get("sign")}) + else: + rows.append({"planet": planet, "sign": item.get("sign"), "status": item.get("dignity") or item.get("status")}) + columns = [("planet", "planet"), ("degree", "text"), ("sign", "sign")] if source else [("planet", "planet"), ("sign", "sign"), ("status", "text")] + return _rows_from(rows, language, columns) + + +def _pushkara(packet: dict, language: str) -> str: + module = _first(packet, ("strengths_and_scores", "pushkara")) or {} + rows = [] + if isinstance(module, dict): + for planet, item in module.items(): + if not isinstance(item, dict): + continue + navamsa = item.get("pushkara_navamsa_meta") if isinstance(item.get("pushkara_navamsa_meta"), dict) else {} + bhaga = item.get("pushkara_bhaga_meta") if isinstance(item.get("pushkara_bhaga_meta"), dict) else {} + rows.append({ + "planet": planet, + "status": navamsa.get("is_pushkara") if "is_pushkara" in navamsa else item.get("pushkara_navamsa"), + "value": bhaga.get("is_pushkara_bhaga") if "is_pushkara_bhaga" in bhaga else item.get("pushkara_bhaga"), + }) + return _rows_from(rows, language, [("planet", "planet"), ("status", "text"), ("value", "text")]) + + +def _lagna_vargottama(packet: dict, language: str) -> str: + module = _first(packet, ("strengths_and_scores", "lagna_vargottamamsa")) or {} + if not isinstance(module, dict) or not module: + return _blank(language, ["sign", "status"]) + row = { + "sign": module.get("sign") or module.get("lagna_sign"), + "status": module.get("is_vargottama") if "is_vargottama" in module else module.get("status"), + "value": module.get("d9_sign") or module.get("navamsa_sign"), + } + return _rows_from([row], language, [("sign", "sign"), ("status", "text"), ("value", "sign")]) + + +def _goel(packet: dict, language: str) -> str: + jaimini = _first(packet, ("advanced_systems", "jaimini")) or {} + strength = jaimini.get("goel_full_strength") if isinstance(jaimini, dict) else {} + points = jaimini.get("goel_special_points") if isinstance(jaimini, dict) else {} + if not isinstance(strength, dict): + strength = {} + citation = ( + "出处:V. P. Goel《Predicting through Jaimini Astrology》,印刷页 10–17。" + if language == "zh" + else "Source: V. P. Goel, Predicting through Jaimini Astrology, printed pages 10-17." + ) + policy = show(strength.get("node_occupancy_policy"), language) + ak = show(strength.get("atmakaraka"), language, "planet") + intro = _rows_from([{"policy": policy, "atmakaraka": ak, "status": strength.get("status")}], language, [("policy", "text"), ("atmakaraka", "text"), ("status", "text")]) + planet_rows = [] + example = strength.get("worked_example_node_exclusion") if isinstance(strength.get("worked_example_node_exclusion"), dict) else {} + example_planets = example.get("planets") if isinstance(example.get("planets"), dict) else {} + for planet, item in (strength.get("planets") or {}).items() if isinstance(strength.get("planets"), dict) else []: + if not isinstance(item, dict): + continue + example_total = example_planets.get(planet, {}).get("total") if isinstance(example_planets.get(planet), dict) else None + planet_rows.append({ + "planet": planet, + "sign": item.get("sign_idx"), + "degree": item.get("degree_in_sign"), + "dignity": item.get("dignity"), + "total": item.get("total"), + "example_total": example_total, + }) + rashi_rows = [] + example_rashi = example.get("rashi_strengths") if isinstance(example.get("rashi_strengths"), list) else [] + example_by_sign = {row.get("sign"): row.get("total") for row in example_rashi if isinstance(row, dict)} + for item in strength.get("rashi_strengths") or []: + if not isinstance(item, dict): + continue + rashi_rows.append({ + "sign": item.get("sign"), + "lord": item.get("lord"), + "permanent": item.get("permanent"), + "occupant": item.get("occupants"), + "total": item.get("total"), + "example_total": example_by_sign.get(item.get("sign")), + }) + point_rows = [] + if isinstance(points, dict): + for name, item in points.items(): + if not isinstance(item, dict): + continue + point_rows.append({ + "point": name, + "status": item.get("status"), + "value": item.get("value"), + "rule": item.get("rule"), + }) + return "\n\n".join([ + citation, + intro, + _rows_from(planet_rows, language, [("planet", "planet"), ("sign", "sign"), ("degree", "text"), ("dignity", "text"), ("total", "text"), ("example_total", "text")]), + _rows_from(rashi_rows, language, [("sign", "sign"), ("lord", "planet"), ("permanent", "text"), ("occupant", "planets"), ("total", "text"), ("example_total", "text")]), + _rows_from(point_rows, language, [("point", "text"), ("status", "text"), ("value", "planet"), ("rule", "text")]), + ]) + + +def _friendship(packet: dict, language: str) -> str: + snapshot = _first(packet, ("strengths_and_scores", "planetary_friendship")) + headers, rows = friendship_table(snapshot, language) + if not rows: + return _blank(language, ["planet", "friends", "enemies", "neutral", "compound"]) + return _md_table(headers, rows) + + +def _relationship_note(packet: dict, language: str) -> str: + del packet + if language == "zh": + return "天然关系来自常量表。临时关系来自两星的星座距离。复合关系由这两层合成,印成极友、友、中性、敌、极敌。" + return "Natural relations come from the permanent table. Temporary relations come from the sign distance of the two planets. The compound relation joins those two layers as great friend, friend, neutral, enemy, or great enemy." + + +def _chain_rows(module: Any, terminal: bool) -> list[dict]: + rows = [] + if isinstance(module, dict): + items = module.get("chains") or module.get("terminals") or module + if isinstance(items, dict): + for planet, item in items.items(): + if isinstance(item, list) and item: + last = item[-1] if terminal else item[0] + if isinstance(last, dict): + rows.append({"planet": planet, "sign": last.get("sign"), "lord": last.get("dispositor") or last.get("planet")}) + elif isinstance(item, dict): + rows.append({"planet": planet, "sign": item.get("sign") or item.get("final_sign"), "lord": item.get("final") or item.get("dispositor") or item.get("terminal")}) + elif isinstance(items, list): + rows = [item for item in items if isinstance(item, dict)] + return rows + + +def _dispositor(packet: dict, language: str, terminal: bool) -> str: + module = _first(packet, ("advanced_systems", "final_dispositors" if terminal else "dispositor_chains")) + return _rows_from(_chain_rows(module, terminal), language, [("planet", "planet"), ("sign", "sign"), ("lord", "planet")]) + + +def _aspects(packet: dict, language: str) -> str: + module = _first(packet, ("advanced_systems", "aspects")) or {} + items = module.get("aspects") if isinstance(module, dict) else module + rows = [] + for item in items or []: + if not isinstance(item, dict): + continue + rows.append({ + "planet": item.get("planet1") or item.get("planet"), + "value": item.get("planet2") or item.get("target"), + "aspect": item.get("type") or item.get("aspect"), + "orb": item.get("orb") or item.get("actual_diff"), + }) + return _rows_from(rows, language, [("planet", "planet"), ("value", "planet"), ("aspect", "text"), ("orb", "text")]) + + +def _named_status(module: Any, language: str, columns: list[tuple[str, str]]) -> str: + if not isinstance(module, dict) or not module: + return _blank(language, [key for key, _kind in columns]) + rows = [] + if all(not isinstance(value, (dict, list)) for value in module.values()): + rows.append({key: module.get(key) for key, _kind in columns}) + else: + for key, value in module.items(): + if isinstance(value, dict): + row = {"name": key} + row.update(value) + rows.append(row) + elif not isinstance(value, (list, dict)): + rows.append({"name": key, "value": value}) + return _rows_from(rows, language, columns) + + +def _argala(packet: dict, language: str) -> str: + return _named_status(_first(packet, ("advanced_systems", "argala")), language, [("name", "text"), ("status", "text"), ("value", "text")]) + + +def _congregation(packet: dict, language: str) -> str: + module = _first(packet, ("timing_and_predictive_systems", "planetary_congregation")) or _first(packet, ("advanced_systems", "congregation")) + return _named_status(module, language, [("name", "text"), ("planet", "planets"), ("house", "text"), ("sign", "sign")]) + + +def _geometry(packet: dict, language: str) -> str: + module = _first(packet, ("advanced_systems", "marriage_counting")) or {} + if not isinstance(module, dict): + return _blank(language, ["name", "value"]) + rows = [] + for key in ("d1_7th_lord", "point_A", "point_B", "distance", "parivartana"): + if key in module: + kind = "planet" if "lord" in key or key.startswith("point") else "text" + rows.append({"name": show(key, language) if key in ENUMS else ("七宫主" if language == "zh" and key == "d1_7th_lord" else key.replace("_", " ")), "value": module.get(key) if kind == "text" else module.get(key)}) + # The field marriage_count is a geometry output, not a verified event count. + note = ( + "距离只描述点与点的几何关系,不把它当成已发生的婚姻次数。" + if language == "zh" + else "The distance describes geometry between the points. It is not a verified count of marriages." + ) + table = _rows_from([ + {"name": "七宫主" if language == "zh" else "7th lord", "value": module.get("d1_7th_lord")}, + {"name": "A", "value": module.get("point_A")}, + {"name": "B", "value": module.get("point_B")}, + {"name": "距离" if language == "zh" else "Distance", "value": module.get("distance")}, + ], language, [("name", "text"), ("value", "text")]) + return note + "\n\n" + table + + +def _public_yoga_name(item: dict, language: str) -> str: + try: + from scripts.pl9_interpretation_packs import _yoga_name + except ModuleNotFoundError: # pragma: no cover + from pl9_interpretation_packs import _yoga_name + return _yoga_name(item, language) + + +def _yoga_participants(packet: dict, language: str) -> str: + yoga = _first(packet, ("advanced_systems", "yoga")) or {} + pack = yoga.get("interpretation_pack") if isinstance(yoga, dict) else {} + pages = pack.get("pages") if isinstance(pack, dict) else None + rows = [] + for page in pages or []: + if not isinstance(page, dict): + continue + rows.append({"name": _public_yoga_name(page, language) if page.get("name") or page.get("name_cn") else undetermined(language), "planet": page.get("planets")}) + if not rows: + detected = yoga.get("detected_yogas") if isinstance(yoga, dict) else [] + for item in detected or []: + if isinstance(item, dict): + rows.append({"name": _public_yoga_name(item, language), "planet": item.get("planets")}) + cleaned = [] + for row in rows: + name = show(row.get("name"), language) + if language == "zh" and re.search(r"\b(in|and|or|from|planet|lord)\b", str(row.get("name") or ""), re.IGNORECASE): + name = undetermined(language) + cleaned.append({"name": name, "planet": row.get("planet")}) + return _rows_from(cleaned, language, [("name", "text"), ("planet", "planets")]) + + +def _canonical_yogas(packet: dict, language: str) -> str: + yoga = _first(packet, ("advanced_systems", "yoga")) or {} + rows = [] + for item in yoga.get("detected_yogas") or []: + if not isinstance(item, dict): + continue + rows.append({"name": _public_yoga_name(item, language), "category": item.get("category"), "planet": item.get("planets")}) + return _rows_from(rows, language, [("name", "text"), ("category", "text"), ("planet", "planets")]) + + +def _occupants(packet: dict, language: str) -> str: + d1 = _first(packet, ("d1_rasi_bhava",)) or {} + planets = d1.get("planets") if isinstance(d1, dict) else {} + houses: dict[str, list[str]] = {} + if isinstance(planets, dict): + for planet, item in planets.items(): + if isinstance(item, dict) and item.get("house"): + houses.setdefault(str(item.get("house")), []).append(str(planet)) + rows = [{"house": house, "planet": names} for house, names in sorted(houses.items(), key=lambda pair: int(pair[0]) if str(pair[0]).isdigit() else 99)] + return _rows_from(rows, language, [("house", "text"), ("planet", "planets")]) + + +def _nakshatra_reference(packet: dict, language: str) -> str: + module = _first(packet, ("advanced_systems", "nakshatra_adv")) or {} + natal = module.get("natal_nakshatras") if isinstance(module, dict) else None + rows = [] + if isinstance(natal, dict): + for planet, item in natal.items(): + if isinstance(item, dict): + rows.append({"planet": planet, "nakshatra": item.get("nakshatra") or item.get("name"), "pada": item.get("pada"), "lord": item.get("lord")}) + elif isinstance(natal, list): + rows = [item for item in natal if isinstance(item, dict)] + return _rows_from(rows, language, [("planet", "planet"), ("nakshatra", "text"), ("pada", "text"), ("lord", "planet")]) + + +def _tara(packet: dict, language: str) -> str: + module = _first(packet, ("advanced_systems", "nakshatra_adv")) or {} + rows = [] + for key in ("tara_bala", "chandra_bala"): + table = module.get(key) if isinstance(module, dict) else None + if isinstance(table, dict): + for name, value in table.items(): + if not isinstance(value, (dict, list)): + rows.append({"name": key, "planet": name, "score": value}) + elif isinstance(value, dict): + rows.append({"name": key, "planet": name, "score": value.get("score") or value.get("value")}) + return _rows_from(rows, language, [("name", "text"), ("planet", "text"), ("score", "text")]) + + +def _simple_module(packet: dict, path: tuple[str, ...], language: str, columns: list[tuple[str, str]]) -> str: + module = _first(packet, path) + if isinstance(module, list): + return _rows_from(module, language, columns) + if isinstance(module, dict): + nested = None + for key in ("planets", "rows", "activities", "points", "data"): + if isinstance(module.get(key), list): + nested = module[key] + break + if nested is not None: + return _rows_from(nested, language, columns) + return _named_status(module, language, columns) + return _blank(language, [key for key, _kind in columns]) + + +def _panchanga(packet: dict, language: str) -> str: + module = _first(packet, ("birth_and_parameters", "panchanga")) or _first(packet, ("advanced_systems", "panchanga")) + return _named_status(module if isinstance(module, dict) else {}, language, [("name", "text"), ("value", "text")]) + + +def _saham_basis(packet: dict, language: str) -> str: + module = _first(packet, ("advanced_systems", "sahams")) or {} + rows = [] + if isinstance(module, dict): + for name, item in module.items(): + if not isinstance(item, dict): + continue + if "formula_status" not in item and "formula_variant" not in item and "formula_operands" not in item: + continue + rows.append({ + "name": name, + "status": item.get("formula_status") or item.get("formula_variant"), + "value": item.get("base_longitude_before_exception") or item.get("longitude"), + }) + return _rows_from(rows, language, [("name", "text"), ("status", "text"), ("value", "text")]) + + +def _special_points(packet: dict, language: str) -> str: + module = _first(packet, ("divisional_and_special_charts", "special_lagnas")) or {} + rows = [] + if isinstance(module, dict): + for name, item in module.items(): + if isinstance(item, dict): + rows.append({"point": name, "sign": item.get("sign"), "degree": item.get("degree_in_sign") or item.get("degree")}) + wanted = { + "Arudha_Lagna": ("Arudha lagna", "Arudha 上升"), + "Upapada_Lagna": ("Upapada lagna", "Upapada 上升"), + "A10": ("A10", "A10"), + "Arudha": ("Arudha", "Arudha"), + "UL": ("Upapada", "Upapada"), + } + picked = [] + for row in rows: + pair = wanted.get(str(row["point"])) + if pair: + row = dict(row) + row["point"] = pair[1] if language == "zh" else pair[0] + picked.append(row) + picked = picked or rows + return _rows_from(picked, language, [("point", "text"), ("sign", "sign"), ("degree", "text")]) + + +def _jaimini_details(packet: dict, language: str) -> str: + jaimini = _first(packet, ("advanced_systems", "jaimini")) or {} + table = jaimini.get("chara_karaka_7") if isinstance(jaimini, dict) else {} + rows = [] + if isinstance(table, dict): + planets = table.get("planets") or table.get("karakas") or table + if isinstance(planets, dict): + for role, planet in planets.items(): + if isinstance(planet, str): + rows.append({"role": role, "planet": planet}) + elif isinstance(planet, dict): + rows.append({"role": role, "planet": planet.get("planet") or planet.get("name"), "degree": planet.get("degree")}) + return _rows_from(rows, language, [("role", "text"), ("planet", "planet"), ("degree", "text")]) + + +def _graha_pada(packet: dict, language: str) -> str: + jaimini = _first(packet, ("advanced_systems", "jaimini")) or {} + padas = jaimini.get("graha_padas") if isinstance(jaimini, dict) else {} + inner = padas.get("graha_padas") if isinstance(padas, dict) and isinstance(padas.get("graha_padas"), dict) else {} + rows = [] + for planet, item in inner.items(): + if not isinstance(item, dict): + continue + rows.append({ + "planet": planet, + "sign": item.get("graha_pada_sign") or item.get("sign"), + "lord": item.get("lord"), + }) + return _rows_from(rows, language, [("planet", "planet"), ("sign", "sign"), ("lord", "planet")]) + + +def _one_point(packet: dict, language: str, key: str) -> str: + jaimini = _first(packet, ("advanced_systems", "jaimini")) or {} + item = jaimini.get(key) if isinstance(jaimini, dict) else None + if isinstance(item, str): + return _rows_from([{"planet": item}], language, [("planet", "planet")]) + if isinstance(item, dict): + return _rows_from([item], language, [("planet", "planet"), ("sign", "sign"), ("house", "text")]) + return _blank(language, ["planet", "sign"]) + + +def _rashi_tulya(packet: dict, language: str) -> str: + module = _first(packet, ("advanced_systems", "rashi_tulya_navamsa")) or {} + analysis = module.get("analysis") if isinstance(module, dict) else module + if not isinstance(analysis, dict): + return _blank(language, ["name", "value"]) + labels = { + "lagna_sign": "上升星座" if language == "zh" else "Ascendant sign", + "strength_score": "力量分" if language == "zh" else "Strength score", + "weakness_score": "薄弱分" if language == "zh" else "Weakness score", + } + rows = [] + for key, label in labels.items(): + if key in analysis: + rows.append({"name": label, "value": analysis.get(key)}) + planets = analysis.get("planets") + if isinstance(planets, dict): + for planet, item in planets.items(): + sign = item.get("sign") if isinstance(item, dict) else item + rows.append({"name": planet, "value": sign}) + return _rows_from(rows, language, [("name", "text"), ("value", "text")]) + + +def _moon(packet: dict, language: str) -> str: + module = _first(packet, ("divisional_and_special_charts", "moon_chart")) or {} + planets = module.get("planets") if isinstance(module, dict) else None + if planets is None and isinstance(module.get("raw"), dict): + planets = module["raw"].get("planets") + rows = [] + if isinstance(planets, dict): + for planet, item in planets.items(): + if isinstance(item, dict): + rows.append({"planet": planet, "sign": item.get("sign"), "house": item.get("house") or item.get("house_from_moon")}) + return _rows_from(rows, language, [("planet", "planet"), ("sign", "sign"), ("house", "text")]) + + +def _sudarshana(packet: dict, language: str) -> str: + module = _first(packet, ("divisional_and_special_charts", "sudarshana")) or {} + points = module.get("reference_points") if isinstance(module, dict) else {} + rows = [] + labels = { + "ascendant_lagna": ("Ascendant reference", "出生上升参考点"), + "moon_lagna": ("Moon reference", "月亮参考点"), + "sun_lagna": ("Sun reference", "太阳参考点"), + } + if isinstance(points, dict): + for key, item in points.items(): + if isinstance(item, dict): + pair = labels.get(key) + rows.append({"point": (pair[1] if language == "zh" else pair[0]) if pair else undetermined(language), "sign": item.get("sign")}) + return _rows_from(rows, language, [("point", "text"), ("sign", "sign")]) + + +def _transit(packet: dict, language: str) -> str: + module = _first(packet, ("timing_and_predictive_systems", "transit_positions")) or _first(packet, ("advanced_systems", "transit_positions")) + if not isinstance(module, dict): + module = {} + planets = module.get("planets") or module.get("positions") or module + rows = [] + if isinstance(planets, dict): + for planet, item in planets.items(): + if isinstance(item, dict): + rows.append({"planet": planet, "sign": item.get("sign"), "date": item.get("date") or module.get("date")}) + return _rows_from(rows, language, [("planet", "planet"), ("sign", "sign"), ("date", "text")]) + + +def _multi_transit(packet: dict, language: str) -> str: + return _simple_module(packet, ("timing_and_predictive_systems", "transit_multi_reference"), language, [("name", "text"), ("planet", "planet"), ("sign", "sign"), ("house", "text")]) + + +def _double_transit(packet: dict, language: str) -> str: + return _simple_module(packet, ("timing_and_predictive_systems", "double_transit_pac"), language, [("name", "text"), ("status", "text"), ("date", "text")]) + + +def _lord_transit(packet: dict, language: str) -> str: + return _simple_module(packet, ("timing_and_predictive_systems", "transit_ll7l"), language, [("name", "text"), ("planet", "planet"), ("sign", "sign"), ("house", "text")]) + + +def _settings(packet: dict, language: str) -> str: + 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 {} + row = { + "name": birth.get("ayanamsa_display") or birth.get("ayanamsa_name") or profile.get("ayanamsa"), + "value": birth.get("node_mode") or profile.get("node_mode"), + "method": profile.get("house_system") or "whole sign", + } + return _rows_from([row], language, [("name", "text"), ("value", "text"), ("method", "text")]) + + +def _kp(packet: dict, language: str) -> str: + module = _first(packet, ("advanced_systems", "kp")) or {} + row = { + "name": module.get("ayanamsa") or module.get("ayanamsa_name"), + "value": module.get("node_mode"), + "method": "KP", + } + return _rows_from([row], language, [("name", "text"), ("value", "text"), ("method", "text")]) + + +def _d9(packet: dict, language: str) -> str: + varga = _first(packet, ("divisional_and_special_charts", "varga_full")) or {} + chart = None + if isinstance(varga, dict): + for key in ("D9", "d9", "Navamsa", "navamsa"): + if isinstance(varga.get(key), dict): + chart = varga[key] + break + planets = chart.get("planets") if isinstance(chart, dict) else None + rows = [] + if isinstance(planets, dict): + for planet, item in planets.items(): + if isinstance(item, dict): + rows.append({"planet": planet, "sign": item.get("sign"), "dignity": item.get("dignity") or item.get("status")}) + return _rows_from(rows, language, [("planet", "planet"), ("sign", "sign"), ("dignity", "text")]) + + +def _mapping(packet: dict, language: str) -> str: + varga = _first(packet, ("divisional_and_special_charts", "varga_full")) or {} + rows = [] + if isinstance(varga, dict): + for name, chart in varga.items(): + if any(token in str(name).upper() for token in ("D81", "D108", "D144")): + continue + sign = None + if isinstance(chart, dict): + asc = chart.get("ascendant") if isinstance(chart.get("ascendant"), dict) else {} + sign = asc.get("sign") or chart.get("asc_sign") or chart.get("lagna_sign") + rows.append({"name": name, "sign": sign}) + return _rows_from(rows, language, [("name", "text"), ("sign", "sign")]) + + +def _bhava_raw(packet: dict) -> dict: + module = _first(packet, ("divisional_and_special_charts", "bhava_chalit")) or {} + raw = module.get("raw") if isinstance(module, dict) else None + return raw if isinstance(raw, dict) else module if isinstance(module, dict) else {} + + +def _chalit_planets(packet: dict, language: str) -> str: + raw = _bhava_raw(packet) + rows = [] + for planet, item in (raw.get("planets") or {}).items(): + if isinstance(item, dict): + sign = item.get("sign_cn") if language == "zh" and item.get("sign_cn") else item.get("sign") + rows.append({"planet": planet, "sign": sign, "rashi_house": item.get("rashi_house"), "bhava_house": item.get("bhava_house"), "degree": item.get("degree_in_sign")}) + return _rows_from(rows, language, [("planet", "planet"), ("sign", "sign"), ("rashi_house", "text"), ("bhava_house", "text"), ("degree", "text")]) + + +def _chalit_centres(packet: dict, language: str) -> str: + raw = _bhava_raw(packet) + boundaries = raw.get("boundaries") if isinstance(raw.get("boundaries"), dict) else {} + details = boundaries.get("houses_detail") if isinstance(boundaries, dict) else None + rows = [] + for item in details or []: + if isinstance(item, dict): + sign = item.get("cusp_sign_cn") if language == "zh" and item.get("cusp_sign_cn") else item.get("cusp_sign") + rows.append({"house": item.get("house"), "sign": sign, "degree": item.get("cusp_degree_in_sign"), "longitude": item.get("cusp_lon")}) + return _rows_from(rows, language, [("house", "text"), ("sign", "sign"), ("degree", "text"), ("longitude", "text")]) + + +def _chalit_convention(packet: dict, language: str) -> str: + raw = _bhava_raw(packet) + system = raw.get("house_system") + assignment = raw.get("house_assignment") + note = ( + "宫位用 Sripati 宫心。行星落宫按交界,不按宫头到宫头。" + if language == "zh" + else "Houses use Sripati centres. A planet's house is read from the sandhi, not from cusp to cusp." + ) + if system and str(system).lower() != "sripati": + note = show(system, language) + row = {"method": note, "value": assignment} + return _rows_from([row], language, [("method", "text"), ("value", "text")]) + + +def _tithi(packet: dict, language: str) -> str: + module = _first(packet, ("advanced_systems", "tithi_lord")) or _first(packet, ("birth_and_parameters", "panchanga")) + if isinstance(module, dict) and module.get("tithi_lord"): + return _rows_from([{"lord": module.get("tithi_lord"), "name": module.get("tithi") or module.get("name")}], language, [("lord", "planet"), ("name", "text")]) + return _named_status(module if isinstance(module, dict) else {}, language, [("name", "text"), ("value", "text"), ("lord", "planet")]) + + +def _vivah(packet: dict, language: str) -> str: + module = _first(packet, ("advanced_systems", "vivah_saham")) + if not isinstance(module, dict): + sahams = _first(packet, ("advanced_systems", "sahams")) or {} + module = sahams.get("vivah_saham") if isinstance(sahams, dict) else {} + if not isinstance(module, dict): + return _blank(language, ["sign", "degree", "longitude"]) + return _rows_from([module], language, [("sign", "sign"), ("degree_in_sign", "text"), ("longitude", "text"), ("degree", "text")]) + + +_BUILDERS = { + "Vimshottari Birth-Balance": _vimshottari, + "Kalachakra Dasha": _kalachakra, + "Yogini Periods": _yogini, + "Tribhagi Profile": _tribhagi_profile, + "Tribhagi Periods": _tribhagi_periods, + "Navamsha AD Duration": _navamsha, + "Nakshatra Dasha Context": _nakshatra_context, + "Bhrigu Cycle": _bhrigu_cycle, + "Bhrigu Approximate Progression": _bhrigu_sequence, + "Conditional Dasha Applicability": _applicability, + "Dasha Boundary Scan": _sandhi, + "Current Dasha Convergence": _convergence, + "Chara Dasha Period Durations": _chara, + "Narayana Method Comparison": _narayana, + "Annual Return Calculation Contract": _annual, + "Tajika Aspect and Yoga Candidates": _tajika_candidates, + "Annual Tajika Strength": _tajika_strength, +} + + +def _chapter_sections() -> list[list[str]]: + order = [title for title, _zh in SECTIONS] + groups = [ + order[:14], + order[14:17], + order[17:32], + order[32:43], + order[43:58], + order[58:], + ] + return groups + + +def _render_named(packet: dict, language: str, title: str) -> str: + builder = _BUILDERS.get(title) + if builder: + return builder(packet, language) + extra = { + "Shadbala Subcomponent": lambda: _shadbala_parts(packet, language, False), + "Shadbala Result": lambda: _shadbala_parts(packet, language, True), + "House Components and Physical Coordinates": lambda: _coordinates(packet, language), + "Bhava Bala Three-Component": lambda: _bhava(packet, language), + "Vimsopaka Contribution": lambda: _vimsopaka(packet, language), + "AV Contribution and Reduction": lambda: _av_reduction(packet, language), + "AV Yoga Pinda": lambda: _av_pinda(packet, language), + "AV Sign Assessments": lambda: _av_signs(packet, language), + "Prastara Totals": lambda: _prastara(packet, language), + "Avastha Inputs": lambda: _avastha(packet, language), + "D30 States": lambda: _d30(packet, language, False), + "D30 Source Input": lambda: _d30(packet, language, True), + "Pushkara Basis": lambda: _pushkara(packet, language), + "Lagna Vargottamamsa": lambda: _lagna_vargottama(packet, language), + "Goel Jaimini Strength and Special Points": lambda: _goel(packet, language), + "Neutral and Compound Relationships": lambda: _friendship(packet, language), + "Relationship Conventions": lambda: _relationship_note(packet, language), + "Dispositor Terminals": lambda: _dispositor(packet, language, True), + "Dispositor Chain": lambda: _dispositor(packet, language, False), + "Aspect Geometry": lambda: _aspects(packet, language), + "Argala": lambda: _argala(packet, language), + "Congregation": lambda: _congregation(packet, language), + "Bhrigu Relationship Geometry": lambda: _geometry(packet, language), + "Yoga Participants": lambda: _yoga_participants(packet, language), + "Canonical Yoga Rows": lambda: _canonical_yogas(packet, language), + "Whole-Sign Occupants": lambda: _occupants(packet, language), + "Nakshatra Reference": lambda: _nakshatra_reference(packet, language), + "Tara-Chandra Matrix": lambda: _tara(packet, language), + "Natal Tithi Lord": lambda: _tithi(packet, language), + "Birth Calendar": lambda: _panchanga(packet, language), + "Pancha Pakshi": lambda: _simple_module(packet, ("advanced_systems", "pancha_pakshi"), language, [("name", "text"), ("value", "text"), ("status", "text")]), + "Natal Vivah Saham": lambda: _vivah(packet, language), + "Natal Saham Formula Basis": lambda: _saham_basis(packet, language), + "Arudha / UL / A10": lambda: _special_points(packet, language), + "Jaimini Calculation Details": lambda: _jaimini_details(packet, language), + "Graha Pada": lambda: _graha_pada(packet, language), + "Karakamsha": lambda: _one_point(packet, language, "karakamsha"), + "Darakaraka": lambda: _one_point(packet, language, "darakaraka"), + "Rashi Tulya Navamsa": lambda: _rashi_tulya(packet, language), + "Moon Chart Reference": lambda: _moon(packet, language), + "Sudarshana Points": lambda: _sudarshana(packet, language), + "Dated Transit": lambda: _transit(packet, language), + "Multi-Reference Transit": lambda: _multi_transit(packet, language), + "Double Transit PAC": lambda: _double_transit(packet, language), + "Lagna / 7th Lord Transit": lambda: _lord_transit(packet, language), + "Natal Calculation Conventions": lambda: _settings(packet, language), + "KP Calculation Conventions": lambda: _kp(packet, language), + "D9 Dignity": lambda: _d9(packet, language), + "Divisional Mapping": lambda: _mapping(packet, language), + "Bhava Chalit Planet Placements": lambda: _chalit_planets(packet, language), + "Bhava Chalit Centres": lambda: _chalit_centres(packet, language), + "Bhava Chalit Convention": lambda: _chalit_convention(packet, language), + } + renderer = extra.get(title) + if renderer is None: + raise KeyError(title) + return renderer() + + +def render_parity_chapters(packet: dict, language: str = "zh") -> str: + """Append-only chapters. language is ``zh`` or ``en``.""" + if language not in {"zh", "en"}: + raise ValueError(language) + packet = packet if isinstance(packet, dict) else {} + blocks = [] + for (chapter_en, chapter_zh), titles in zip(CHAPTERS, _chapter_sections()): + heading = chapter_zh if language == "zh" else chapter_en + parts = [f"# {heading}"] + for title in titles: + parts.append(_section(title, language, _render_named(packet, language, title))) + blocks.append("\n\n".join(parts)) + return "\n\n".join(blocks).strip() + "\n" + + +def column_keys_used() -> list[str]: + return sorted(LABELS) diff --git a/tests/test_full_data_parity_chapters.py b/tests/test_full_data_parity_chapters.py new file mode 100644 index 00000000..a00bb832 --- /dev/null +++ b/tests/test_full_data_parity_chapters.py @@ -0,0 +1,156 @@ +"""Six parity chapters for issue #91 section A. + +C-class sections stay absent. Labels are bilingual. Narayana's two methods +are separate headings. Fictional charts only. +""" + +from __future__ import annotations + +import re + +import pytest + +from scripts.pl9_full_data_export import render_full_data_markdown +from scripts.pl9_reader_english import HAN +from scripts.professional_report_reference import _english_edition +from scripts.report_render.appendix_language import chinese_prose_gates +from scripts.report_render.parity_chapters import ( + CHAPTERS, + FORBIDDEN_TITLES, + LABELS, + SECTIONS, + _chapter_sections, + lab, + render_parity_chapters, +) +from tests.report_english_fictional import _load_packet + +_SNAKE = re.compile(r"[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+") +_KAAL_ZH = re.compile(r"\d+ 颗中 \d+ 颗") +_KAAL_EN = re.compile(r"\d+ of \d+") + + +def _chapter_text(markdown: str, language: str) -> str: + marker = "# 大运附表" if language == "zh" else "# Dasha Appendix" + assert marker in markdown + return markdown.split(marker, 1)[1] + + +def _section_body(markdown: str, heading: str) -> str: + marker = f"## {heading}" + start = markdown.index(marker) + len(marker) + rest = markdown[start:] + ends = [pos for pos in (rest.find("\n## "), rest.find("\n# ")) if pos >= 0] + return rest[: min(ends)] if ends else rest + + +def test_chapter_titles_follow_the_task_groups() -> None: + groups = _chapter_sections() + assert len(groups) == len(CHAPTERS) == 6 + assert [title for group in groups for title in group] == [title for title, _zh in SECTIONS] + for (chapter_en, chapter_zh), titles in zip(CHAPTERS, groups): + assert chapter_en and chapter_zh + assert titles + + +def test_labels_cover_both_languages_and_a_missing_label_fails() -> None: + for key, pair in LABELS.items(): + assert pair[0] and pair[1], key + assert lab(key, "en") == pair[0] + assert lab(key, "zh") == pair[1] + assert "_" not in pair[0] and "_" not in pair[1] + with pytest.raises(KeyError): + lab("not_a_real_column", "zh") + + +def test_empty_packet_chapters_are_headed_and_do_not_print_raw_keys() -> None: + for language in ("zh", "en"): + text = render_parity_chapters({}, language) + titles = SECTIONS + cursor = 0 + for title_en, title_zh in titles: + heading = title_zh if language == "zh" else title_en + body = _section_body(text, heading) + assert body.strip(), heading + cursor_next = text.index(f"## {heading}", cursor) + assert cursor_next >= cursor + cursor = cursor_next + if "|---|" in body or "| --- |" in body: + lines = [line for line in body.splitlines() if line.startswith("|")] + assert len(lines) >= 3, heading + for title in FORBIDDEN_TITLES: + assert f"## {title}" not in text + assert f"# {title}" not in text + assert _SNAKE.search(text) is None + zh = render_parity_chapters({}, "zh") + en = render_parity_chapters({}, "en") + assert zh.index("### 来源感知口径") < zh.index("### 既有口径") + assert "来源感知口径" not in zh.split("### 既有口径", 1)[1] + assert en.index("### Source-aware method") < en.index("### Legacy method") + assert "Source-aware method" not in en.split("### Legacy method", 1)[1] + assert "印刷页 10" in zh + assert "printed pages 10-17" in en + for banned in ("source_locator", "profile_id", "niryaana_shoola_strength", "source_discrepancy", "sign_idx"): + assert banned not in zh + assert banned not in en + + +@pytest.fixture(scope="module") +def fictional_reports() -> dict[int, tuple[str, str]]: + reports = {} + for index in (0, 1, 9): + packet = _load_packet(index) + edition = _english_edition(packet, True) + chinese = render_full_data_markdown(packet) + english = edition.get("markdown_en") or "" + assert edition.get("english_unavailable") in (None, "") + assert english + reports[index] = (chinese, english) + return reports + + +def test_three_fictional_charts_render_the_same_chapters(fictional_reports: dict[int, tuple[str, str]]) -> None: + for index, (chinese, english) in fictional_reports.items(): + assert chinese_prose_gates(chinese) == {"english_signs": 0, "han_function_glue": 0} + assert HAN.findall(english) == [] + assert "[translation missing:" not in english + zh = _chapter_text(chinese, "zh") + en = _chapter_text(english, "en") + zh_at = 0 + en_at = 0 + for title_en, title_zh in SECTIONS: + zh_at = zh.index(f"## {title_zh}", zh_at) + en_at = en.index(f"## {title_en}", en_at) + for body, heading in ((_section_body(zh, title_zh), title_zh), (_section_body(en, title_en), title_en)): + assert body.strip(), f"{index} {heading}" + for chapter_en, chapter_zh in CHAPTERS: + assert chinese.count(f"# {chapter_zh}") == 1 + assert english.count(f"# {chapter_en}") == 1 + for title in FORBIDDEN_TITLES: + assert title not in zh + assert title not in en + assert _SNAKE.search(zh) is None + assert _SNAKE.search(en) is None + assert "印刷页 10" in zh and "printed pages 10-17" in en + assert "### 来源感知口径" in zh and "### 既有口径" in zh + assert "### Source-aware method" in en and "### Legacy method" in en + assert "再小运" in _section_body(zh, "Chara 大运时长") + assert "Pratyantardasha" in _section_body(en, "Chara Dasha Period Durations") + assert "marriage_windows" not in _section_body(zh, "Bhrigu 近似推进序列") + assert "venus_encounter" not in _section_body(en, "Bhrigu Approximate Progression") + assert "Raja Yoga" not in zh + assert "blocked" not in zh.lower() + + +def test_chart_nine_partial_kaal_and_friendship(fictional_reports: dict[int, tuple[str, str]]) -> None: + chinese, english = fictional_reports[9] + assert _KAAL_ZH.search(chinese) + assert _KAAL_EN.search(english) + zh_friends = _section_body(_chapter_text(chinese, "zh"), "行星中性与复合友敌关系") + en_friends = _section_body(_chapter_text(english, "en"), "Neutral and Compound Relationships") + zh_rows = [line for line in zh_friends.splitlines() if line.startswith("| 太阳") or line.startswith("| 月亮") or line.startswith("| 水星")] + assert len([line for line in zh_friends.splitlines() if line.startswith("| ") and "---" not in line]) >= 10 + assert zh_rows + assert "极友" in zh_friends + assert "Great friend" in en_friends + assert "没有大运或小运交界" in chinese or "距参考日天数" in _section_body(_chapter_text(chinese, "zh"), "大运与小运交界检查")