diff --git a/scripts/report_render/parity_chapters.py b/scripts/report_render/parity_chapters.py index 130027bf..8491f36e 100644 --- a/scripts/report_render/parity_chapters.py +++ b/scripts/report_render/parity_chapters.py @@ -108,6 +108,38 @@ LABELS: dict[str, tuple[str, str]] = { "adhipathi": ("Adhipathi", "宫主力量"), "dig": ("Dig", "方位力量"), "drik": ("Drik", "相位力量"), + "raw_total": ("Raw BAV total", "原始 BAV 合计"), + "trikona_total": ("After Trikona reduction", "三方净化后"), + "sodhita_total": ("After Ekadhipatya reduction", "单宫主净化后"), + "rashi_pinda": ("Rashi Pinda", "Rashi Pinda"), + "graha_pinda": ("Graha Pinda", "Graha Pinda"), + "shodhya_pinda": ("Shodhya Pinda", "Shodhya Pinda"), + "yoga_pinda": ("Yoga Pinda", "Yoga Pinda"), + "own_bindu": ("Bindus in own sign", "所在星座点数"), + "weight": ("Planet weight", "行星权重"), + "signs": ("Sign pair", "星座对"), + "before": ("Before", "净化前"), + "after": ("After", "净化后"), + "argala": ("Argala count", "Argala 数"), + "virodhargala": ("Virodhargala count", "Virodhargala 数"), + "effective": ("Effective Argala houses", "有效 Argala 宫"), + "priority": ("Priority", "优先级"), + "layer": ("Layer", "层"), + "target": ("Target", "目标"), + "seven_planet": ("Seven-karaka planet", "七曜口径行星"), + "seven_degree": ("Seven-karaka degree", "七曜口径度数"), + "eight_planet": ("Eight-karaka planet", "八曜口径行星"), + "eight_degree": ("Eight-karaka ranking degree", "八曜口径排序度数"), + "rahu_reversed": ("Rahu counted in reverse", "罗睺倒数"), + "tara_index": ("Tara index", "Tara 序号"), + "tara": ("Tara", "Tara"), + "tara_quality": ("Tara quality", "Tara 吉凶"), + "chandra_offset": ("Chandra place", "Chandra 位"), + "chandra": ("Chandra", "Chandra"), + "chandra_quality": ("Chandra quality", "Chandra 吉凶"), + "combined": ("Combined", "合并结果"), + "quality": ("Quality", "吉凶"), + "residual": ("Residual degrees", "剩余度数"), } CHAPTERS: tuple[tuple[str, str], ...] = ( @@ -240,6 +272,16 @@ ENUMS: dict[str, tuple[str, str]] = { "applicable": ("Applicable", "适用"), "mean": ("Mean node", "平交点"), "true": ("True node", "真交点"), + "parameter_sensitive": ("Parameter-sensitive", "随参数变化"), + "friend": ("Friend", "友"), + "enemy": ("Enemy", "敌"), + "neutral": ("Neutral", "中性"), + "great_friend": ("Great friend", "极友"), + "great_enemy": ("Great enemy", "极敌"), + "applying": ("Applying", "入相位"), + "separating": ("Separating", "离相位"), + "Ithasala_candidate": ("Ithasala candidate", "Ithasala 候选"), + "Easarapha_candidate": ("Easarapha candidate", "Easarapha 候选"), } PROFILES: dict[str, tuple[str, str]] = { @@ -391,7 +433,8 @@ def show(value: Any, language: str, kind: str = "text") -> str: mapped = _enum(text, language) if mapped: return mapped - if _SNAKE.fullmatch(text) and "_" in text: + if _SNAKE.fullmatch(text) and re.search(r"[a-z]", text): + # A bare lower-case token is an internal key or enum, not reader 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) @@ -444,7 +487,7 @@ def _section(title_en: str, language: str, body: str) -> str: zh = titles[title_en] heading = zh if language == "zh" else title_en text = body.strip() or undetermined(language) - return f"## {heading}\n\n{text}" + return f"### {heading}\n\n{text}" def _blank(language: str, columns: list[str]) -> str: @@ -797,11 +840,11 @@ def _narayana(packet: dict, language: str) -> str: legacy_title = "既有口径" if language == "zh" else "Legacy method" missing = undetermined(language) return "\n\n".join([ - f"### {source_title}", + f"#### {source_title}", "", _narayana_block(source, language, missing), "", - f"### {legacy_title}", + f"#### {legacy_title}", "", _narayana_block(legacy, language, missing), ]) @@ -837,9 +880,11 @@ def _tajika_candidates(packet: dict, language: str) -> str: 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"), + "aspect": item.get("aspect"), + "residual": item.get("residual"), "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")]) + return _rows_from(rows, language, [("name", "text"), ("planet", "planets"), ("aspect", "text"), ("residual", "text"), ("status", "text")]) def _tajika_strength(packet: dict, language: str) -> str: @@ -962,30 +1007,8 @@ def _ashtakavarga(packet: dict) -> dict: 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: @@ -1088,16 +1111,6 @@ def _pushkara(packet: dict, language: str) -> str: 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: @@ -1171,11 +1184,6 @@ def _friendship(packet: dict, language: str) -> str: 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]: @@ -1234,30 +1242,6 @@ def _aspects(packet: dict, language: str) -> str: 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: @@ -1315,13 +1299,52 @@ def _yoga_participants(packet: dict, language: str) -> str: return _rows_from(cleaned, language, [("name", "text"), ("planet", "planets")]) +YOGA_CATEGORIES: dict[str, tuple[str, str]] = { + "raja": ("Raja", "王瑜伽"), + "dhana": ("Wealth", "财富瑜伽"), + "kalatra": ("Marriage", "婚恋"), + "durbhaga": ("Adverse", "不利组合"), + "nabhasa": ("Nabhasa", "天空瑜伽"), + "mahapurusha": ("Mahapurusha", "大人格瑜伽"), + "pancha_mahapurusha": ("Mahapurusha", "大人格瑜伽"), + "solar_yoga": ("Solar", "太阳瑜伽"), + "lunar_yoga": ("Lunar", "月亮瑜伽"), + "surya": ("Solar", "太阳相关"), + "chandra": ("Lunar", "月亮相关"), + "chandra_extended": ("Lunar", "月亮相关"), + "special": ("Special", "特殊组合"), + "auspicious": ("Auspicious", "吉祥组合"), + "putra": ("Children", "子女"), + "conjunction": ("Conjunction", "合相"), + "health": ("Health", "健康"), + "ayur": ("Longevity", "寿元"), + "karma": ("Career", "事业"), + "moksha": ("Liberation", "解脱"), + "aryama": ("Aryama", "Aryama"), + "asha": ("Wishes", "愿望"), + "guru": ("Jupiter", "木星相关"), + "neecha_bhanga": ("Debilitation cancellation", "落陷取消"), + "vidya": ("Learning", "学业"), + "configuration": ("Configuration", "格局"), + "travel": ("Travel", "远行"), + "ari": ("Opponents", "对手"), + "relationship_observation": ("Relationship observation", "关系观察"), + "shakti": ("Shakti", "Shakti"), + "varga": ("Divisional", "分盘"), +} + + 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")}) + rows.append({ + "name": _public_yoga_name(item, language), + "category": _pair(YOGA_CATEGORIES, item.get("category"), language), + "planet": item.get("planets"), + }) return _rows_from(rows, language, [("name", "text"), ("category", "text"), ("planet", "planets")]) @@ -1350,39 +1373,8 @@ def _nakshatra_reference(packet: dict, language: str) -> str: 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: @@ -1431,19 +1423,6 @@ def _special_points(packet: dict, language: str) -> str: 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: @@ -1560,16 +1539,10 @@ def _transit(packet: dict, language: str) -> str: return _rows_from(rows, language, [("planet", "planet"), ("sign", "sign"), ("degree", "text"), ("date", "text"), ("nakshatra", "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: @@ -1583,14 +1556,6 @@ def _settings(packet: dict, language: str) -> str: 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: @@ -1672,11 +1637,6 @@ def _chalit_convention(packet: dict, language: str) -> str: 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: @@ -1689,6 +1649,700 @@ def _vivah(packet: dict, language: str) -> str: return _rows_from([module], language, [("sign", "sign"), ("degree_in_sign", "text"), ("longitude", "text"), ("degree", "text")]) +def _pair(table: dict, key: Any, language: str) -> str | None: + pair = table.get(str(key)) if key is not None else None + if not pair: + return None + return pair[1] if language == "zh" else pair[0] + + +def _items_table(rows: list[tuple[tuple[str, str], Any, str]], language: str) -> str: + """Two columns: a fixed bilingual label and the packet value.""" + body = [] + for (label_en, label_zh), value, kind in rows: + body.append([label_zh if language == "zh" else label_en, show(value, language, kind)]) + if not any(cell != undetermined(language) for _label, cell in body): + return _blank(language, ["item", "value"]) + return _md_table([lab("item", language), lab("value", language)], body) + + +def _bav_sum(values: Any) -> Any: + if isinstance(values, list) and values and all(isinstance(item, (int, float)) and not isinstance(item, bool) for item in values): + return sum(values) + return None + + +def _av_reduction(packet: dict, language: str) -> str: + av = _ashtakavarga(packet) + sodhita = av.get("sodhita") if isinstance(av.get("sodhita"), dict) else {} + shodhya = av.get("shodhya_pinda") if isinstance(av.get("shodhya_pinda"), dict) else {} + raw = sodhita.get("raw_bav") if isinstance(sodhita.get("raw_bav"), dict) else {} + trikona = sodhita.get("trikona_bav") if isinstance(sodhita.get("trikona_bav"), dict) else {} + reduced = sodhita.get("sodhita_bav") if isinstance(sodhita.get("sodhita_bav"), dict) else {} + rows = [] + for planet in _PLANETS: + if planet not in raw and planet not in shodhya: + continue + pinda = shodhya.get(planet) if isinstance(shodhya.get(planet), dict) else {} + rows.append({ + "planet": planet, + "raw_total": _bav_sum(raw.get(planet)), + "trikona_total": _bav_sum(trikona.get(planet)), + "sodhita_total": _bav_sum(reduced.get(planet)), + "rashi_pinda": pinda.get("rashi_pinda"), + "graha_pinda": pinda.get("graha_pinda"), + "shodhya_pinda": pinda.get("total_pinda"), + }) + table = _rows_from(rows, language, [ + ("planet", "planet"), ("raw_total", "text"), ("trikona_total", "text"), ("sodhita_total", "text"), + ("rashi_pinda", "text"), ("graha_pinda", "text"), ("shodhya_pinda", "text"), + ]) + audit = [] + for item in sodhita.get("ekadhipatya_audit") or []: + if not isinstance(item, dict): + continue + pair = item.get("pair") if isinstance(item.get("pair"), list) else [] + before = item.get("before") if isinstance(item.get("before"), list) else [] + after = item.get("after") if isinstance(item.get("after"), list) else [] + if len(pair) != 2 or len(before) != 2 or len(after) != 2: + continue + signs = [show(index, language, "sign") for index in pair] + joiner = "、" if language == "zh" else " / " + audit.append({ + "planet": item.get("planet"), + "signs": joiner.join(signs), + "before": f"{before[0]} / {before[1]}", + "after": f"{after[0]} / {after[1]}", + }) + parts = [table] + if audit: + note = ( + "下表是单宫主净化(Ekadhipatya)实际改动的星座对。" + if language == "zh" + else "The rows below are the sign pairs changed by Ekadhipatya reduction." + ) + parts += [note, _rows_from(audit, language, [("planet", "planet"), ("signs", "text"), ("before", "text"), ("after", "text")])] + return "\n\n".join(parts) + + +def _av_pinda(packet: dict, language: str) -> str: + pinda = _ashtakavarga(packet).get("yoga_pinda") + if not isinstance(pinda, dict): + return _blank(language, ["planet", "yoga_pinda"]) + planets = pinda.get("planets") if isinstance(pinda.get("planets"), dict) else {} + weights = pinda.get("planet_weights") if isinstance(pinda.get("planet_weights"), dict) else {} + rows = [] + for planet in _PLANETS: + item = planets.get(planet) + if not isinstance(item, dict): + continue + rows.append({ + "planet": planet, + "sign": item.get("sign"), + "own_bindu": item.get("bindu_at_own_sign"), + "rashi_pinda": item.get("rashi_pinda"), + "graha_pinda": item.get("graha_pinda"), + "yoga_pinda": item.get("yoga_pinda"), + "weight": weights.get(planet), + }) + table = _rows_from(rows, language, [ + ("planet", "planet"), ("sign", "sign"), ("own_bindu", "text"), ("rashi_pinda", "text"), + ("graha_pinda", "text"), ("yoga_pinda", "text"), ("weight", "text"), + ]) + summary = pinda.get("summary") if isinstance(pinda.get("summary"), dict) else {} + if not summary: + return table + totals = _items_table([ + (("Strongest planet", "最强行星"), summary.get("strongest_planet"), "planet"), + (("Weakest planet", "最弱行星"), summary.get("weakest_planet"), "planet"), + (("Total Yoga Pinda", "Yoga Pinda 合计"), summary.get("total_yoga_pinda"), "text"), + (("Average Yoga Pinda", "Yoga Pinda 平均"), summary.get("average_yoga_pinda"), "text"), + ], language) + return table + "\n\n" + totals + + +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, ["item", "value"]) + flag = module.get("is_lagna_vargottamamsa") + if flag is None: + flag = module.get("is_vargottama") + return _items_table([ + (("D1 ascendant sign", "D1 上升星座"), module.get("d1_sign") or module.get("lagna_sign") or module.get("sign"), "sign"), + (("D9 ascendant sign", "D9 上升星座"), module.get("d9_sign") or module.get("navamsa_sign"), "sign"), + (("Lagna Vargottamamsa", "上升 Vargottamamsa"), flag if isinstance(flag, bool) else None, "text"), + ], language) + + +_COMPOUND_ZH = { + "local_permanent_relationship_constants_plus_temporary_sign_distance": ( + "Permanent relations from the constant table; temporary relations from the sign distance", + "天然关系查常量表,临时关系按两星的星座距离", + ), +} + + +def _relationship_note(packet: dict, language: str) -> str: + if language == "zh": + note = "天然关系来自常量表。临时关系来自两星的星座距离。复合关系由这两层合成,印成极友、友、中性、敌、极敌。" + else: + note = "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." + snapshot = _first(packet, ("strengths_and_scores", "planetary_friendship")) + if not isinstance(snapshot, dict): + return note + "\n\n" + _blank(language, ["item", "value"]) + sources = snapshot.get("source_planets") if isinstance(snapshot.get("source_planets"), list) else [] + rows = snapshot.get("rows") if isinstance(snapshot.get("rows"), list) else [] + nodes = [planet for planet in sources if planet in ("Rahu", "Ketu")] + table = _items_table([ + (("Method", "口径"), _pair(_COMPOUND_ZH, snapshot.get("method"), language), "text"), + (("Bodies counted", "计入星体"), sources or None, "planet_list"), + (("Nodes counted", "是否计入交点"), bool(nodes) if sources else None, "text"), + (("Rows", "行数"), len(rows) if rows else None, "text"), + (("Status", "状态"), snapshot.get("status"), "text"), + ], language) + return note + "\n\n" + table + + +_ARGALA_RESULT = { + "supported": ("Supported", "支持"), + "obstructed": ("Obstructed", "受阻"), + "mixed": ("Mixed", "混合"), + "neutral": ("Neutral", "中性"), + "strength_comparison_required": ("Needs a strength comparison", "需比较强弱"), +} + + +def _argala(packet: dict, language: str) -> str: + module = _first(packet, ("advanced_systems", "argala")) or {} + houses = module.get("houses") if isinstance(module, dict) else None + if not isinstance(houses, dict): + return _blank(language, ["house", "status"]) + rows = [] + for number in range(1, 13): + item = houses.get(f"house_{number}") + if not isinstance(item, dict): + continue + primary = item.get("primary") if isinstance(item.get("primary"), dict) else {} + effective = [] + for key, entry in primary.items(): + if isinstance(entry, dict) and entry.get("effective") is True and isinstance(entry.get("house_from_reference"), int): + effective.append(entry["house_from_reference"]) + joiner = "、" if language == "zh" else ", " + rows.append({ + "house": number, + "sign": item.get("ref_sign"), + "argala": item.get("argala_count"), + "virodhargala": item.get("virodhargala_count"), + "effective": joiner.join(str(value) for value in sorted(effective)) if effective else ("无" if language == "zh" else "None"), + "status": _pair(_ARGALA_RESULT, item.get("net_result"), language), + }) + return _rows_from(rows, language, [ + ("house", "text"), ("sign", "sign"), ("argala", "text"), ("virodhargala", "text"), + ("effective", "text"), ("status", "text"), + ]) + + +_TRANSIT_SLOTS = ( + ("natal_lagna", ("Natal ascendant house", "本命上升宫")), + ("natal_house_7", ("Natal 7th house", "本命第七宫")), +) + + +def _congregation(packet: dict, language: str) -> str: + module = _first(packet, ("timing_and_predictive_systems", "planetary_congregation")) + if not isinstance(module, dict) or not module: + return _blank(language, ["item", "planet"]) + none = "无" if language == "zh" else "None" + natal = module.get("natal") if isinstance(module.get("natal"), dict) else {} + transit = module.get("transit") if isinstance(module.get("transit"), dict) else {} + rows = [] + for key, (label_en, label_zh) in (("lagna", _TRANSIT_SLOTS[0][1]), ("house_7", _TRANSIT_SLOTS[1][1])): + if isinstance(natal.get(key), list): + planets = list(dict.fromkeys(natal.get(key))) + rows.append([label_zh if language == "zh" else label_en, show(planets, language, "planets") if planets else none]) + for number in range(1, 13): + planets = transit.get(str(number)) + if isinstance(planets, list) and planets: + planets = list(dict.fromkeys(planets)) + label = f"行运第 {number} 宫" if language == "zh" else f"Transit house {number}" + rows.append([label, show(planets, language, "planets")]) + if not rows: + return _blank(language, ["item", "planet"]) + hit = module.get("hit") + date = module.get("reference_date") or module.get("transit_date") + lead = _items_table([ + (("Reference date", "参考日"), date, "text"), + (("Congregation found", "是否有聚集"), hit if isinstance(hit, bool) else None, "text"), + ], language) + return lead + "\n\n" + _md_table([lab("item", language), lab("planet", language)], rows) + + +_REFERENCES = { + "Lagna": ("Ascendant", "上升点"), + "Chandra_Lagna": ("Moon ascendant", "月亮上升"), + "Arudha_Lagna": ("Arudha lagna", "Arudha 上升"), + "Navamsa_Lagna": ("Navamsa ascendant", "D9 上升"), +} + + +def _multi_transit(packet: dict, language: str) -> str: + module = _first(packet, ("timing_and_predictive_systems", "transit_multi_reference")) + if not isinstance(module, dict): + return _blank(language, ["reference", "sign"]) + references = module.get("references") if isinstance(module.get("references"), dict) else {} + keys = [key for key in _REFERENCES if key in references] + reference_rows = [] + for key in keys: + item = references.get(key) if isinstance(references.get(key), dict) else {} + reference_rows.append({"point": _pair(_REFERENCES, key, language), "sign": item.get("sign"), "priority": item.get("priority")}) + parts = [_rows_from(reference_rows, language, [("point", "text"), ("sign", "sign"), ("priority", "text")])] if reference_rows else [] + analysis = module.get("transit_analysis") if isinstance(module.get("transit_analysis"), dict) else {} + headers = [lab("planet", language), lab("sign", language), lab("degree", language)] + for key in keys: + name = _pair(_REFERENCES, key, language) + headers.append(f"从{name}起的宫" if language == "zh" else f"House from {name.lower()}") + rows = [] + for planet in _PLANETS: + item = analysis.get(planet) + if not isinstance(item, dict): + continue + houses = item.get("house_from_ref") if isinstance(item.get("house_from_ref"), dict) else {} + row = [show(planet, language, "planet"), show(item.get("sign"), language, "sign"), show(item.get("degree_in_sign"), language)] + for key in keys: + entry = houses.get(key) if isinstance(houses.get(key), dict) else {} + row.append(show(entry.get("house"), language)) + rows.append(row) + if rows: + date = module.get("target_date") + if date: + parts.insert(0, f"参考日:{date}。" if language == "zh" else f"Reference date: {date}.") + parts.append(_md_table(headers, rows)) + if not parts: + return _blank(language, ["reference", "sign"]) + return "\n\n".join(parts) + + +_PAC_KIND = { + "Position": ("Same house", "同宫"), + "Conjunction": ("Conjunction", "合相"), +} + + +def _pac_text(entries: Any, language: str) -> str | None: + if not isinstance(entries, list): + return None + parts = [] + for entry in entries: + if not isinstance(entry, dict): + continue + kind = entry.get("type") + if kind == "Aspect" and isinstance(entry.get("offset"), int): + offset = entry["offset"] + parts.append(f"{offset} 宫相位" if language == "zh" else f"Aspect to the {offset}th") + continue + label = _pair(_PAC_KIND, kind, language) + if label: + parts.append(label) + if not parts: + return None + return ("、" if language == "zh" else ", ").join(parts) + + +_TARGET_PATTERNS = ( + (re.compile(r"^(D9_|CL_)?(\d+)宫\((\w+)\)$"), "house"), + (re.compile(r"^(D9_|CL_)?(\w+)\(宫主\)$"), "lord"), + (re.compile(r"^(\w+)\(LL\)$"), "lagna_lord"), + (re.compile(r"^(\w+)\(对宫主\)$"), "opposite_lord"), + (re.compile(r"^(\w+)_D9\((\w+)\)$"), "d9_sign"), +) + + +def _pac_target(name: Any, language: str) -> str | None: + text = str(name or "") + for pattern, kind in _TARGET_PATTERNS: + match = pattern.match(text) + if not match: + continue + if kind == "house": + house, sign = match.group(2), match.group(3) + if sign not in SIGNS: + return None + sign_text = sign_name(sign, language) + return f"第 {house} 宫({sign_text})" if language == "zh" else f"House {house} ({sign_text})" + planet = match.group(2) if kind == "lord" else match.group(1) + if not _is_planet(planet): + return None + planet_text = planet_name(planet, language) + if kind == "lord": + return f"{planet_text}(事件宫主)" if language == "zh" else f"{planet_text} (event-house lord)" + if kind == "lagna_lord": + return f"{planet_text}(上升主)" if language == "zh" else f"{planet_text} (ascendant lord)" + if kind == "opposite_lord": + return f"{planet_text}(七宫主)" if language == "zh" else f"{planet_text} (7th lord)" + sign = match.group(2) + if sign not in SIGNS: + return None + sign_text = sign_name(sign, language) + return f"{planet_text} 的 D9 星座({sign_text})" if language == "zh" else f"D9 sign of {planet_text} ({sign_text})" + return None + + +_PAC_LAYERS = ( + ("d1", ("D1", "D1")), + ("d9", ("D9", "D9")), + ("cl", ("Moon ascendant", "月亮上升")), +) + + +def _double_transit(packet: dict, language: str) -> str: + module = _first(packet, ("timing_and_predictive_systems", "double_transit_pac")) + if not isinstance(module, dict) or not module: + return _blank(language, ["layer", "target"]) + rows = [] + for key, pair in _PAC_LAYERS: + layer = module.get(key) if isinstance(module.get(key), dict) else {} + for mover in ("jupiter", "saturn"): + targets = layer.get(mover) if isinstance(layer.get(mover), dict) else {} + for target, entries in targets.items(): + rows.append({ + "layer": pair[1] if language == "zh" else pair[0], + "planet": mover.title(), + "target": _pac_target(target, language), + "aspect": _pac_text(entries, language), + }) + stats = module.get("stats") if isinstance(module.get("stats"), dict) else {} + hits = [item for item in module.get("double_transit") or [] if isinstance(item, dict)] + lead = _items_table([ + (("Reference date", "参考日"), module.get("reference_date") or module.get("transit_date"), "text"), + (("Event house", "事件宫"), module.get("event_house"), "text"), + (("D9 ascendant", "D9 上升"), stats.get("d9_ascendant"), "sign"), + (("Moon ascendant", "月亮上升"), stats.get("chandra_lagna"), "sign"), + (("Double transit hits", "双重行运命中数"), len(hits) if "double_transit" in module else None, "text"), + ], language) + parts = [lead] + if rows: + parts.append(_rows_from(rows, language, [("layer", "text"), ("planet", "planet"), ("target", "text"), ("aspect", "text")])) + else: + parts.append( + "参考日木星与土星都没有连接事件宫目标。" + if language == "zh" + else "Neither transiting Jupiter nor Saturn links an event-house target on the reference date." + ) + return "\n\n".join(parts) + + +_DIRECTION = re.compile(r"^Transit (\w+) → natal (\w+)$") +_P8 = re.compile(r"^Transit (\w+)\((\w+)\)在(7H|Lagna)$") +_SWAP = re.compile(r"Transit (\w+)在(\w+)") + + +def _ll7l_detail(kind: str, item: Any, language: str) -> str | None: + if kind == "p5" and isinstance(item, dict): + match = _DIRECTION.match(str(item.get("direction") or "")) + if not match or not all(_is_planet(value) for value in match.groups()): + return None + mover, target = (planet_name(value, language) for value in match.groups()) + pac = _pac_text(item.get("connections"), language) + if language == "zh": + return f"行运{mover} → 本命{target}" + (f"({pac})" if pac else "") + return f"Transit {mover} to natal {target}" + (f" ({pac})" if pac else "") + if kind == "p8": + match = _P8.match(str(item or "")) + if not match or not _is_planet(match.group(1)) or match.group(2) not in SIGNS: + return None + mover = planet_name(match.group(1), language) + sign = sign_name(match.group(2), language) + if language == "zh": + return f"行运{mover}在{sign}," + ("落第七宫" if match.group(3) == "7H" else "落上升宫") + return f"Transit {mover} in {sign}, " + ("in the 7th house" if match.group(3) == "7H" else "in the ascendant house") + if kind == "parivartana": + found = _SWAP.findall(str(item or "")) + if not found or not all(_is_planet(planet) and sign in SIGNS for planet, sign in found): + return None + joiner = ";" if language == "zh" else "; " + partial = str(item).startswith("部分") + if language == "zh": + text = joiner.join(f"行运{planet_name(planet, language)}在{sign_name(sign, language)}" for planet, sign in found) + return ("单边:" if partial else "") + text + text = joiner.join(f"Transit {planet_name(planet, language)} in {sign_name(sign, language)}" for planet, sign in found) + return ("One side only: " if partial else "") + text + return None + + +_LL7L_CHECKS = ( + ("p5", ("Transit ascendant lord and 7th lord PAC", "行运上升主与七宫主 PAC 连接")), + ("p8", ("Ascendant lord through the 7th, or 7th lord through the ascendant", "上升主过七宫或七宫主过上升")), + ("parivartana", ("Sign exchange", "互换星座")), +) + + +def _lord_transit(packet: dict, language: str) -> str: + module = _first(packet, ("timing_and_predictive_systems", "transit_ll7l")) + if not isinstance(module, dict) or not module: + return _blank(language, ["item", "value"]) + lead = _items_table([ + (("Reference date", "参考日"), module.get("reference_date") or module.get("transit_date"), "text"), + (("Ascendant lord", "上升主"), module.get("lagna_lord"), "planet"), + (("7th lord", "七宫主"), module.get("seventh_lord"), "planet"), + ], language) + rows = [] + for key, (label_en, label_zh) in _LL7L_CHECKS: + check = module.get(key) if isinstance(module.get(key), dict) else {} + details = [_ll7l_detail(key, item, language) for item in check.get("details") or []] + details = [item for item in details if item] + hit = check.get("hit") + rows.append([ + label_zh if language == "zh" else label_en, + show(hit, language) if isinstance(hit, bool) else undetermined(language), + (";" if language == "zh" else "; ").join(details) if details else ("无" if language == "zh" else "None"), + ]) + table = _md_table([lab("item", language), lab("detected", language), lab("value", language)], rows) + return lead + "\n\n" + table + + +_KP_VALUES = { + "placidus": ("Placidus", "Placidus"), + "whole_sign": ("Whole sign", "整宫制"), + "swiss_ephemeris_bhava_chalit": ("Swiss Ephemeris cusps", "Swiss Ephemeris 宫头"), + "explicit_cusps": ("Explicit cusps", "按宫头"), + "cusp_to_cusp": ("Cusp to cusp", "宫头到宫头"), + "candidate_window_only": ("Candidate windows only", "只给候选窗口"), +} +_KP_RULING = ( + ("day_lord", ("Day lord", "日主")), + ("moon_sign_lord", ("Moon sign lord", "月亮星座主")), + ("moon_star_lord", ("Moon star lord", "月亮星宿主")), + ("moon_sub_lord", ("Moon sub lord", "月亮 Sub 主")), + ("ascendant_sign_lord", ("Ascendant sign lord", "上升星座主")), + ("ascendant_star_lord", ("Ascendant star lord", "上升星宿主")), + ("ascendant_sub_lord", ("Ascendant sub lord", "上升 Sub 主")), +) + + +def _kp(packet: dict, language: str) -> str: + module = _first(packet, ("advanced_systems", "kp")) or {} + if not isinstance(module, dict) or not module: + return _blank(language, ["item", "value"]) + profile = module.get("kp_profile") if isinstance(module.get("kp_profile"), dict) else {} + settings = module.get("calculation_profile") if isinstance(module.get("calculation_profile"), dict) else {} + ayanamsa = profile.get("ayanamsa_display") + if not isinstance(ayanamsa, str) or "_" in ayanamsa: + ayanamsa = None + requested = profile.get("requested_report_ayanamsa") + table = _items_table([ + (("KP ayanamsa", "KP 岁差"), ayanamsa, "text"), + (("Main report ayanamsa", "主报告岁差"), requested.title() if isinstance(requested, str) and requested.isalpha() else None, "text"), + (("House system", "宫制"), _pair(_KP_VALUES, profile.get("house_system"), language), "text"), + (("Cusp source", "宫头来源"), _pair(_KP_VALUES, profile.get("cusp_source"), language), "text"), + (("House basis", "宫位依据"), _pair(_KP_VALUES, module.get("house_basis"), language), "text"), + (("Occupancy basis", "落宫依据"), _pair(_KP_VALUES, module.get("house_occupancy_basis"), language), "text"), + (("Node mode", "交点模式"), settings.get("node_mode"), "text"), + (("Timing policy", "应期口径"), _pair(_KP_VALUES, profile.get("timing_policy"), language), "text"), + (("Status", "状态"), profile.get("status"), "text"), + ], language) + ruling = module.get("ruling_planets") if isinstance(module.get("ruling_planets"), dict) else {} + rows = [] + for key, (label_en, label_zh) in _KP_RULING: + if _is_planet(ruling.get(key)): + rows.append([label_zh if language == "zh" else label_en, show(ruling.get(key), language, "planet")]) + if not rows: + return table + return table + "\n\n" + _md_table([lab("role", language), lab("planet", language)], rows) + + +_KARAKA_ROLES = ( + ("Atmakaraka", ("Atmakaraka (AK)", "灵魂指示星(AK)")), + ("Amatyakaraka", ("Amatyakaraka (AmK)", "事业指示星(AmK)")), + ("Bhratrikaraka", ("Bhratrikaraka (BK)", "兄弟指示星(BK)")), + ("Matrikaraka", ("Matrikaraka (MK)", "母亲指示星(MK)")), + ("Pitrukaraka", ("Pitrukaraka (PiK)", "父亲指示星(PiK)")), + ("Putrakaraka", ("Putrakaraka (PK)", "子女指示星(PK)")), + ("Gnatikaraka", ("Gnatikaraka (GK)", "障碍指示星(GK)")), + ("Darakaraka", ("Darakaraka (DK)", "配偶指示星(DK)")), +) + + +def _jaimini_details(packet: dict, language: str) -> str: + jaimini = _first(packet, ("advanced_systems", "jaimini")) or {} + if not isinstance(jaimini, dict): + return _blank(language, ["karaka", "planet"]) + seven_root = jaimini.get("chara_karaka_7") if isinstance(jaimini.get("chara_karaka_7"), dict) else {} + eight_root = jaimini.get("chara_karaka_8") if isinstance(jaimini.get("chara_karaka_8"), dict) else {} + seven = seven_root.get("karaka_table") if isinstance(seven_root.get("karaka_table"), dict) else {} + eight = eight_root.get("karaka_table_8") if isinstance(eight_root.get("karaka_table_8"), dict) else {} + rows = [] + for key, (label_en, label_zh) in _KARAKA_ROLES: + one = seven.get(key) if isinstance(seven.get(key), dict) else {} + two = eight.get(key) if isinstance(eight.get(key), dict) else {} + if not one and not two: + continue + reversed_flag = two.get("rahu_reverse_applied") + rows.append({ + "karaka": label_zh if language == "zh" else label_en, + "seven_planet": one.get("planet"), + "seven_degree": one.get("degree_in_sign"), + "eight_planet": two.get("planet"), + "eight_degree": two.get("effective_degree_in_sign"), + "rahu_reversed": reversed_flag if isinstance(reversed_flag, bool) else None, + }) + note = ( + "七曜口径不计罗睺;八曜口径计入罗睺,罗睺按 30° 减去度数排序。两种排序分开看,不混排。" + if language == "zh" + else "The seven-karaka scheme leaves Rahu out. The eight-karaka scheme counts Rahu by 30 degrees minus its degree. Read the two rankings separately." + ) + table = _rows_from(rows, language, [ + ("karaka", "text"), ("seven_planet", "planet"), ("seven_degree", "text"), + ("eight_planet", "planet"), ("eight_degree", "text"), ("rahu_reversed", "text"), + ]) + return note + "\n\n" + table + + +_TARA_COMBINED = { + "double_auspicious": ("Both favourable", "双吉"), + "double_dangerous": ("Both unfavourable", "双凶"), + "tara_good_chandra_bad": ("Star favourable, sign unfavourable", "星吉宫凶"), + "tara_bad_chandra_good": ("Star unfavourable, sign favourable", "星凶宫吉"), + "mixed_favorable": ("Leaning favourable", "偏吉"), + "mixed_unfavorable": ("Leaning unfavourable", "偏凶"), + "neutral": ("Neutral", "中性"), +} +_QUALITY = { + "auspicious": ("Favourable", "吉"), + "dangerous": ("Unfavourable", "凶"), + "neutral": ("Neutral", "中性"), +} + + +def _tara_name(entry: dict, key_en: str, key_zh: str, language: str) -> str | None: + if language == "zh": + value = entry.get(key_zh) + return value if isinstance(value, str) and value.strip() else None + value = entry.get(key_en) + if not isinstance(value, str): + return None + head = value.split("(", 1)[0].strip() + return head or None + + +def _tara(packet: dict, language: str) -> str: + module = _first(packet, ("advanced_systems", "nakshatra_adv")) or {} + combined = module.get("tara_chandra_combined") if isinstance(module, dict) else None + if not isinstance(combined, dict): + return _blank(language, ["planet", "tara"]) + rows = [] + for planet in _PLANETS: + item = combined.get(planet) + if not isinstance(item, dict): + continue + tara = item.get("tara") if isinstance(item.get("tara"), dict) else {} + chandra = item.get("chandra") if isinstance(item.get("chandra"), dict) else {} + offset = chandra.get("rashi_from_moon") + rows.append({ + "planet": planet, + "nakshatra": item.get("nakshatra"), + "tara_index": tara.get("tara_index") + 1 if isinstance(tara.get("tara_index"), int) and not isinstance(tara.get("tara_index"), bool) else None, + "tara": _tara_name(tara, "tara_name", "tara_cn", language), + "tara_quality": _pair(_QUALITY, tara.get("quality"), language), + "chandra_offset": offset + 1 if isinstance(offset, int) and not isinstance(offset, bool) else None, + "chandra": _tara_name(chandra, "chandra_name", "chandra_cn", language), + "chandra_quality": _pair(_QUALITY, chandra.get("quality"), language), + "combined": _pair(_TARA_COMBINED, item.get("combined_score"), language), + }) + note = ( + "相对本命月亮的分类,不是当前行运。Tara 从月亮星宿起数,Chandra 位从月亮星座起数,起点都记为 1。" + if language == "zh" + else "Classes relative to the natal Moon, not current transits. Tara counts from the Moon's nakshatra and the Chandra place from the Moon's sign; both start at 1." + ) + return note + "\n\n" + _rows_from(rows, language, [ + ("planet", "planet"), ("nakshatra", "text"), ("tara_index", "text"), ("tara", "text"), + ("tara_quality", "text"), ("chandra_offset", "text"), ("chandra", "text"), + ("chandra_quality", "text"), ("combined", "text"), + ]) + + +_PAKSHA = {"Shukla": ("Shukla (waxing)", "白分(上半月)"), "Krishna": ("Krishna (waning)", "黑分(下半月)")} +_WEEKDAY = { + "Sunday": ("Sunday", "星期日"), "Monday": ("Monday", "星期一"), "Tuesday": ("Tuesday", "星期二"), + "Wednesday": ("Wednesday", "星期三"), "Thursday": ("Thursday", "星期四"), "Friday": ("Friday", "星期五"), + "Saturday": ("Saturday", "星期六"), +} +_SUBHA = { + "subha": ("Auspicious", "吉"), + "asubha": ("Inauspicious", "凶"), + "mishra": ("Mixed", "中"), + "mixed": ("Mixed", "中"), + "neutral": ("Neutral", "中性"), +} + + +def _panchanga_raw(packet: dict) -> dict: + module = _first(packet, ("birth_and_parameters", "panchanga")) or _first(packet, ("advanced_systems", "panchanga")) + raw = module.get("raw") if isinstance(module, dict) and isinstance(module.get("raw"), dict) else module + return raw if isinstance(raw, dict) else {} + + +def _tithi(packet: dict, language: str) -> str: + tithi = _panchanga_raw(packet).get("tithi") + if not isinstance(tithi, dict): + return _blank(language, ["item", "value"]) + table = _items_table([ + (("Tithi", "Tithi"), tithi.get("full_name") or tithi.get("name"), "text"), + (("Paksha", "月相半月"), _pair(_PAKSHA, tithi.get("paksha"), language), "text"), + (("Tithi in paksha", "半月内序号"), tithi.get("tithi_in_paksha"), "text"), + (("Tithi number (1-30)", "Tithi 序号(1–30)"), tithi.get("tithi_num"), "text"), + (("Moon minus Sun", "月日黄经差"), tithi.get("moon_sun_diff"), "text"), + ], language) + note = ( + "Tithi 主星模块没有进公开报告资料包,这里只列出生 Tithi 本身,不另推主星。" + if language == "zh" + else "The Tithi-lord module is not in the public report packet. This section lists the birth Tithi only and does not derive its lord." + ) + return table + "\n\n" + note + + +def _panchanga(packet: dict, language: str) -> str: + raw = _panchanga_raw(packet) + if not raw: + return _blank(language, ["item", "value", "status"]) + + def part(key: str) -> dict: + value = raw.get(key) + return value if isinstance(value, dict) else {} + + tithi, nakshatra, yoga, karana, vara, hora = (part(key) for key in ("tithi", "nakshatra", "yoga", "karana", "vara", "hora")) + pada = nakshatra.get("pada") + star = nakshatra.get("nakshatra") + if isinstance(star, str) and pada is not None: + star = f"{star} {pada}" + weekday = _pair(_WEEKDAY, vara.get("vara"), language) + rows = [ + (("Tithi", "Tithi"), tithi.get("full_name") or tithi.get("name"), tithi.get("quality")), + (("Nakshatra", "星宿"), star, nakshatra.get("quality")), + (("Yoga", "Yoga"), yoga.get("yoga"), yoga.get("quality")), + (("Karana", "Karana"), karana.get("karana"), karana.get("quality")), + (("Weekday", "星期"), weekday, vara.get("quality")), + (("Weekday lord", "星期主星"), planet_name(vara.get("vara_lord"), language) if _is_planet(vara.get("vara_lord")) else None, None), + (("Hora lord", "Hora 主星"), planet_name(hora.get("hora_lord"), language) if _is_planet(hora.get("hora_lord")) else None, hora.get("quality")), + ] + body = [] + for (label_en, label_zh), value, quality in rows: + body.append([ + label_zh if language == "zh" else label_en, + show(value, language), + _pair(_SUBHA, quality, language) or ("—" if quality is None else undetermined(language)), + ]) + count = raw.get("auspicious_count") + total = raw.get("total_elements") + if isinstance(count, int) and isinstance(total, int): + score = raw.get("overall_score") + value = f"{count} / {total}" + if isinstance(score, (int, float)) and not isinstance(score, bool): + value += f"({show(score, language)})" if language == "zh" else f" ({show(score, language)})" + body.append(["吉性要素" if language == "zh" else "Auspicious elements", value, "—"]) + return _md_table([lab("item", language), lab("value", language), lab("quality", language)], body) + + _BUILDERS = { "Vimshottari Birth-Balance": _vimshottari, "Kalachakra Dasha": _kalachakra, @@ -1795,7 +2449,7 @@ def render_parity_chapters(packet: dict, language: str = "zh") -> str: blocks = [] for (chapter_en, chapter_zh), titles in zip(CHAPTERS, _chapter_sections()): heading = chapter_zh if language == "zh" else chapter_en - parts = [f"# {heading}"] + parts = [f"## {heading}"] for title in titles: parts.append(_section(title, language, _render_named(packet, language, title))) blocks.append("\n\n".join(parts)) diff --git a/tests/test_full_data_parity_chapters.py b/tests/test_full_data_parity_chapters.py index 3c952f9b..f5edf451 100644 --- a/tests/test_full_data_parity_chapters.py +++ b/tests/test_full_data_parity_chapters.py @@ -32,19 +32,24 @@ _KAAL_EN = re.compile(r"\d+ of \d+") def _chapter_text(markdown: str, language: str) -> str: - marker = "# 大运附表" if language == "zh" else "# Dasha Appendix" + marker = "\n## 大运附表\n" if language == "zh" else "\n## Dasha Appendix\n" assert marker in markdown return markdown.split(marker, 1)[1] def _section_body(markdown: str, heading: str) -> str: - marker = f"## {heading}" + marker = f"### {heading}\n" start = markdown.index(marker) + len(marker) rest = markdown[start:] - ends = [pos for pos in (rest.find("\n## "), rest.find("\n# ")) if pos >= 0] + ends = [pos for pos in (rest.find("\n### "), rest.find("\n## "), rest.find("\n# ")) if pos >= 0] return rest[: min(ends)] if ends else rest +def _heading_lines(markdown: str, level: int) -> list[str]: + prefix = "#" * level + " " + return [line[len(prefix):].strip() for line in markdown.splitlines() if line.startswith(prefix)] + + def test_chapter_titles_follow_the_task_groups() -> None: groups = _chapter_sections() assert len(groups) == len(CHAPTERS) == 6 @@ -73,22 +78,26 @@ def test_empty_packet_chapters_are_headed_and_do_not_print_raw_keys() -> None: 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) + cursor_next = text.index(f"### {heading}\n", 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 f"# {title}" not in text assert _SNAKE.search(text) is None + assert _heading_lines(text, 1) == [] + chapters = [chapter_zh if language == "zh" else chapter_en for chapter_en, chapter_zh in CHAPTERS] + assert _heading_lines(text, 2) == chapters 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 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"): @@ -120,21 +129,21 @@ def test_three_fictional_charts_render_the_same_chapters(fictional_reports: dict 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) + zh_at = zh.index(f"### {title_zh}\n", zh_at) + en_at = en.index(f"### {title_en}\n", 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 + assert _heading_lines(chinese, 2).count(chapter_zh) == 1 + assert _heading_lines(english, 2).count(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 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 近似推进序列") @@ -184,17 +193,40 @@ def _table_blocks(body: str) -> list[list[list[str]]]: return parsed +_NOT_VALUES = {"未判定", "Not determined", "—", "-", ""} +# A whole cell that looks like a packet key (`summary`, `p5`, `house_7`). +_RAW_KEY_CELL = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)*$") + + def _section_has_real_cell(body: str, undetermined: str) -> bool: + """A table must carry a value outside its first (label) column. + + The first column holds row labels, so it never proves that the packet was + read. A one-column table therefore never passes. A body with no table at all + may be a plain sentence (for example "no boundary within 90 days"). + """ blocks = _table_blocks(body) if blocks: return any( - cell and cell != undetermined + cell not in _NOT_VALUES and cell != undetermined for rows in blocks for row in rows - for cell in row + for cell in row[1:] ) - prose = [line.strip() for line in body.splitlines() if line.strip()] - return any(line and line != undetermined for line in prose) + prose = [line.strip() for line in body.splitlines() if line.strip() and not line.startswith("#")] + return any(line != undetermined for line in prose) + + +def _all_cells(markdown: str) -> list[str]: + cells = [] + for line in markdown.splitlines(): + if not line.startswith("|"): + continue + parts = [cell.strip() for cell in line.strip().strip("|").split("|")] + if parts and all(set(cell) <= set("-: ") and cell for cell in parts): + continue + cells.extend(parts) + return cells def test_every_section_has_a_real_cell_except_the_four_public_gaps(fictional_reports: dict[int, tuple[str, str]]) -> None: @@ -214,6 +246,55 @@ def test_every_section_has_a_real_cell_except_the_four_public_gaps(fictional_rep assert _section_has_real_cell(body, _UNDETERMINED[language]), f"{index} {heading}" +def test_every_section_has_a_real_cell_outside_the_label_column_unit() -> None: + one_column = "| 角色 |\n|---|\n| 未判定 |\n| summary |\n" + assert not _section_has_real_cell(one_column, "未判定") + labels_only = "| 项目 | 内容 |\n|---|---|\n| 状态 | 未判定 |\n| 宫位 | — |\n" + assert not _section_has_real_cell(labels_only, "未判定") + real = "| 项目 | 内容 |\n|---|---|\n| 宫位 | 7 |\n" + assert _section_has_real_cell(real, "未判定") + assert _section_has_real_cell("参考日起 90 天内没有大运或小运交界。", "未判定") + assert not _section_has_real_cell("未判定", "未判定") + + +def test_no_table_cell_is_a_raw_packet_key(fictional_reports: dict[int, tuple[str, str]]) -> None: + for index, (chinese, english) in fictional_reports.items(): + for language, markdown in (("zh", chinese), ("en", english)): + chapter = _chapter_text(markdown, language) + keys = sorted({cell for cell in _all_cells(chapter) if _RAW_KEY_CELL.fullmatch(cell)}) + assert keys == [], f"{index} {language} {keys[:10]}" + + +def test_six_chapters_are_reader_chapters_and_sections_sit_below_them(monkeypatch: pytest.MonkeyPatch) -> None: + """The reader splits on `## ` and ignores `# `. The six chapters must be `##`.""" + import scripts.pl9_full_data_export as export + + zh_chapters = [chapter_zh for _chapter_en, chapter_zh in CHAPTERS] + en_chapters = [chapter_en for chapter_en, _chapter_zh in CHAPTERS] + packet = _load_packet(0) + full_zh = render_full_data_markdown(copy.deepcopy(packet)) + full_en = _english_edition(copy.deepcopy(packet), True).get("markdown_en") or "" + monkeypatch.setattr(export, "_append_parity_chapters", lambda markdown, _packet, _language: markdown) + base_zh = render_full_data_markdown(copy.deepcopy(packet)) + base_en = _english_edition(copy.deepcopy(packet), True).get("markdown_en") or "" + assert base_en + for full, base, chapters in ((full_zh, base_zh, zh_chapters), (full_en, base_en, en_chapters)): + h1 = _heading_lines(full, 1) + assert len(h1) == 1, h1 + assert full.startswith("# " + h1[0]) + assert _heading_lines(base, 1) == h1 + full_h2 = _heading_lines(full, 2) + base_h2 = _heading_lines(base, 2) + assert len(full_h2) == len(base_h2) + len(chapters) + assert full_h2[: len(base_h2)] == base_h2 + assert full_h2[len(base_h2):] == chapters + tail = full.split("\n## " + chapters[0] + "\n", 1)[1] + assert _heading_lines(tail, 3)[: len(_chapter_sections()[0])] == [ + (dict(SECTIONS)[title] if chapters is zh_chapters else title) for title in _chapter_sections()[0] + ] + assert len(_heading_lines(tail, 3)) == len(SECTIONS) + + def test_parity_chapters_go_through_local_conventions() -> None: from scripts.pl9_full_data_export import _apply_local_report_conventions @@ -236,11 +317,13 @@ def test_parity_chapters_go_through_local_conventions() -> None: english_packet = copy.deepcopy(packet) english_packet["report_language"] = "en" - english_packet["worksheets"]["strengths_and_scores"]["vimsopaka"]["Sun"]["category"] = "divorce" + english_packet["worksheets"]["strengths_and_scores"]["vimsopaka"]["Sun"]["category"] = "Divorce risk" + # A bare lower-case word is now treated as an internal key and is not + # printed, so the probe is a reader-style phrase (fix2 F1). english = render_full_data_markdown(english_packet) en_body = _section_body(english, "Vimsopaka Contribution") - en_replaced = _apply_local_report_conventions("divorce", language="en") - assert en_replaced == "relationship strain" + en_replaced = _apply_local_report_conventions("Divorce risk", language="en") + assert en_replaced == "relationship strain risk" assert en_replaced in en_body assert "divorce" not in en_body.lower()