From 6b906f85c4f526b68cc7ef67bb3c9d7b9424f1cb Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Thu, 22 Oct 2026 23:01:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=87=E5=87=86=E5=A4=A7=E8=BF=90=E8=A1=A8?= =?UTF-8?q?=E4=BB=A5=E5=89=8D=E5=8F=AA=E5=8D=B0=E4=B8=BB=E8=BF=90=E3=80=82?= =?UTF-8?q?=E5=B7=B2=E6=9C=89=E5=87=BA=E5=A4=84=E7=9A=84=E5=AD=90=E8=BF=90?= =?UTF-8?q?=E5=86=99=E8=BF=9B=E4=B8=BB=E5=8C=85=E5=89=AF=E6=9C=AC=E5=B9=B6?= =?UTF-8?q?=E5=87=BA=E8=A1=A8=EF=BC=8CKalachakra=20=E5=AD=90=E8=BF=90?= =?UTF-8?q?=E6=8C=89=E7=AE=80=E5=8C=96=E6=A8=A1=E5=9E=8B=E6=8C=82=E5=9C=A8?= =?UTF-8?q?=E7=8E=B0=E6=9C=89=E4=B8=BB=E8=BF=90=E4=B8=8A=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor --- scripts/annual_native/dasha_children.py | 209 ++++++++++++++++++++++++ scripts/jyotish_engine.py | 10 +- scripts/pl9_reader_export.py | 32 +++- tests/test_dasha_master_children.py | 144 ++++++++++++++++ 4 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 scripts/annual_native/dasha_children.py create mode 100644 tests/test_dasha_master_children.py diff --git a/scripts/annual_native/dasha_children.py b/scripts/annual_native/dasha_children.py new file mode 100644 index 00000000..5305152a --- /dev/null +++ b/scripts/annual_native/dasha_children.py @@ -0,0 +1,209 @@ +"""Attach sourced dasha children onto a master-pack copy. + +The natal modules keep their own rows. This module never imports PyJHora. +Kalachakra children are the upstream proportional model, labeled simplified, +and they hang on the majors this repository already calculated. +""" + +from __future__ import annotations + +from copy import deepcopy +from datetime import datetime, timedelta +from typing import Any + + +def _rotate_cycle_rows(cycle_rows: list[dict], start_rashi: str | None) -> list[dict]: + if not cycle_rows: + return [] + if not start_rashi: + return list(cycle_rows) + start_index = next( + (index for index, row in enumerate(cycle_rows) if row.get("rashi") == start_rashi), + 0, + ) + return list(cycle_rows[start_index:]) + list(cycle_rows[:start_index]) + + +def _build_subperiods( + parent_period: dict, + cycle_rows: list[dict], + total_cycle_years: float, + level_key: str, + year_days: float = 365.25, +) -> list[dict]: + """Proportional Kalachakra children. Last child ends on the parent end.""" + if not cycle_rows: + return [] + rotated_rows = _rotate_cycle_rows(cycle_rows, parent_period.get("rashi")) + cycle_basis_total = sum(float(row.get("years") or 0.0) for row in rotated_rows) + denominator = cycle_basis_total if cycle_basis_total > 0 else float(total_cycle_years or 0.0) + if denominator <= 0: + return [] + start_dt = datetime.fromisoformat(str(parent_period["start_date"])) + current_dt = start_dt + child_periods = [] + for row in rotated_rows: + child_years = float(parent_period["years"]) * float(row["years"]) / denominator + end_dt = current_dt + timedelta(days=child_years * year_days) + child_periods.append({ + "level": level_key, + "lord": row.get("lord"), + "rashi": row.get("rashi"), + "sign": row.get("rashi"), + "rashi_index": row.get("rashi_index"), + "years": child_years, + "start_date": current_dt.isoformat(), + "end_date": end_dt.isoformat(), + "status": "parameter_sensitive", + "derivation": "native_kalachakra_recursive_proportional_variant", + "model": "simplified", + }) + current_dt = end_dt + if child_periods: + child_periods[-1]["end_date"] = parent_period["end_date"] + return child_periods + + +def attach_kalachakra_simplified_children( + major_periods: list[dict], + total_cycle_years: float | None = None, + year_days: float = 365.25, +) -> tuple[list[dict], list[dict], list[dict]]: + """Return a copy of the existing majors with simplified AD and PD rows. + + The caller's major rows are not modified. Replacing those majors is out of + scope: if a row has no start_date, this raises instead of inventing one. + """ + majors = deepcopy(major_periods) + cycle_rows = [ + { + "lord": row.get("lord"), + "rashi": row.get("rashi"), + "rashi_index": row.get("rashi_index"), + "years": row.get("years"), + } + for row in majors + if isinstance(row, dict) + ] + if total_cycle_years is None: + total_cycle_years = sum(float(row.get("years") or 0.0) for row in cycle_rows) + all_antardashas: list[dict] = [] + all_pratyantardashas: list[dict] = [] + for period in majors: + if not isinstance(period, dict): + continue + antardasha = _build_subperiods(period, cycle_rows, float(total_cycle_years), "antardasha", year_days) + for child in antardasha: + child["parent_lord"] = period.get("lord") + child["parent_rashi"] = period.get("rashi") + child["parent_sign"] = period.get("rashi") + period["antardasha"] = antardasha + all_antardashas.extend(antardasha) + for child in antardasha: + pratyantardasha = _build_subperiods( + child, cycle_rows, float(total_cycle_years), "pratyantardasha", year_days, + ) + for grand in pratyantardasha: + grand["parent_lord"] = child.get("lord") + grand["parent_rashi"] = child.get("rashi") + grand["parent_sign"] = child.get("rashi") + grand["main"] = period.get("rashi") or period.get("lord") + grand["parent_antardasha"] = child.get("rashi") or child.get("lord") + child["pratyantardasha"] = pratyantardasha + all_pratyantardashas.extend(pratyantardasha) + return majors, all_antardashas, all_pratyantardashas + + +def _copy_rows(rows: list[dict]) -> list[dict]: + return deepcopy([row for row in rows if isinstance(row, dict)]) + + +def _narayana_periods(module: dict) -> list[dict]: + sequence = module.get("mahadasha_sequence") + if not isinstance(sequence, list): + return [] + children_by_parent: dict[str, list[dict]] = {} + boundaries = module.get("dense_boundaries") + if isinstance(boundaries, list): + for row in boundaries: + if not isinstance(row, dict) or row.get("level") != "AD" or not row.get("sign"): + continue + parent = str(row.get("parent_period_key") or row.get("parent_md") or "") + children_by_parent.setdefault(parent, []).append({ + "level": "antardasha", + "sign": row.get("sign"), + "lord": row.get("lord"), + "years": row.get("duration_years"), + "start": row.get("start_age"), + "end": row.get("end_age"), + "parent_sign": row.get("parent_md"), + "parent_lord": row.get("parent_md"), + "source": "narayana_dense_boundaries", + }) + periods = [] + for row in sequence: + if not isinstance(row, dict): + continue + sign = row.get("sign") + parent_key = str(row.get("period_key") or sign or "") + periods.append({ + "sign": sign, + "lord": row.get("lord"), + "years": row.get("years"), + "start": row.get("start_age"), + "end": row.get("end_age"), + "antardasha": children_by_parent.get(parent_key, []), + }) + return periods + + +def _yogini_periods(module: dict, rows: list[dict]) -> list[dict]: + if any(isinstance(row.get("antardasha"), list) and row.get("antardasha") for row in rows): + return rows + try: + from yogini_dasha import yogini_antardashas + except ImportError: + from scripts.yogini_dasha import yogini_antardashas + year_days = module.get("dasha_year_days") or 365.25 + birth = None + if rows: + birth = rows[0].get("start_date") + if not birth: + return rows + attached = [] + for row in rows: + copied = dict(row) + try: + copied["antardasha"] = yogini_antardashas(row, birth_dt=birth, year_days=float(year_days)) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError(f"yogini antardasha failed for {row.get('yogini')}: {exc}") from exc + for child in copied["antardasha"]: + child["parent_lord"] = row.get("planet") or row.get("lord") + child["main"] = row.get("yogini") + attached.append(copied) + return attached + + +def enrich_master_family(key: str, module: dict | None, periods: list | None) -> list[dict]: + """Copy family rows and attach children that already have a sourced rule. + + Vimshottari is left untouched: its three layers are rendered from the + dasha timeline, not from this pack. + """ + module = module if isinstance(module, dict) else {} + rows = _copy_rows(periods or []) + if key == "vimshottari": + return rows + if key == "narayana": + return _narayana_periods(module) + if key == "yogini": + return _yogini_periods(module, rows) + if key == "kala_chakra": + if not rows: + return rows + enriched, _ad, _pd = attach_kalachakra_simplified_children( + rows, + module.get("total_cycle"), + ) + return enriched + return rows diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 15da82e6..b7120e4c 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -2977,10 +2977,18 @@ def _native_dasha_master_families(modules: dict) -> dict: 'chaturshitisama': modules.get('chaturshitisama_dasha'), 'shastihayani': modules.get('shastihayani_dasha'), } + try: + from annual_native.dasha_children import enrich_master_family + except ImportError: + from scripts.annual_native.dasha_children import enrich_master_family families = {} for key, module in mapping.items(): family = _native_dasha_family_status(module) - periods = _normalize_dasha_family_periods(module) + periods = enrich_master_family( + key, + module if isinstance(module, dict) else {}, + _normalize_dasha_family_periods(module), + ) if periods: family['periods'] = periods families[key] = family diff --git a/scripts/pl9_reader_export.py b/scripts/pl9_reader_export.py index b3faca64..80966529 100644 --- a/scripts/pl9_reader_export.py +++ b/scripts/pl9_reader_export.py @@ -1955,7 +1955,17 @@ def render_pl9_parity_markdown(packet: dict) -> str: periods = _list(family.get('periods')) if not periods: continue - lines.extend([f'### p{page} {title_text}', '']) + heading = f'### p{page} {title_text}' + if 'Kala Chakra' in title_text: + heading += ' (Simplified model)' if _lang == 'en' else '(简化模型)' + lines.extend([heading, '']) + if 'Kala Chakra' in title_text: + lines.extend([ + '- Sub-periods use a simplified model. The major periods are unchanged.' + if _lang == 'en' + else '- 子运和次小运按简化模型,挂在现有主运上。主运本身没有改。', + '', + ]) if 'Narayana' in title_text: # BUG-1215: the product Narayana is the legacy algorithm. if _lang == 'en': @@ -2032,6 +2042,26 @@ def render_pl9_parity_markdown(packet: dict) -> str: f"{_period_start(child) or _cell(child_raw.get('start_date') or child_raw.get('start'))} | " f"{_period_end(child) or _cell(child_raw.get('end_date') or child_raw.get('end'))} |" ) + pd_rows = [] + for child in child_rows: + for grand in _list(child.get('pratyantardasha')): + if isinstance(grand, dict): + pd_rows.append((child, grand)) + if pd_rows: + lines.extend([ + '', + '| PD | 主层 | 子层 | 次小运 | 起始 | 结束 |' if _lang != 'en' else '| PD | Maha | Antar | Pratyantar | Start | End |', + '|----|------|------|--------|------|------|', + ]) + for pd_index, (child, grand) in enumerate(pd_rows, start=1): + grand_lord = grand.get('lord') or grand.get('planet') or grand.get('rashi') or grand.get('sign') + lines.append( + f"| {pd_index} | {_cell(_token(child.get('parent_sign') or child.get('parent_lord') or child.get('main')))} | " + f"{_cell(_token(child.get('lord') or child.get('rashi') or child.get('sign')))} | " + f"{_cell(_token(grand_lord))} | {_period_start(grand)} | {_period_end(grand)} |" + ) + else: + lines.append('This system does not provide sub-periods.' if _lang == 'en' else '本体系未提供子运') lines.append('') # BUG-1215 / BUG-1218: the full-data edition keeps the legacy label and the # Rath table already printed by the reference renderer. Display only. diff --git a/tests/test_dasha_master_children.py b/tests/test_dasha_master_children.py new file mode 100644 index 00000000..2a088278 --- /dev/null +++ b/tests/test_dasha_master_children.py @@ -0,0 +1,144 @@ +"""T5: sourced dasha children are copied into the master pack and rendered.""" + +from __future__ import annotations + +from datetime import datetime + +from scripts.annual_native.dasha_children import ( + attach_kalachakra_simplified_children, + enrich_master_family, +) +from scripts.ashtottari_dasha import calculate_ashtottari_dasha +from scripts.jyotish_engine import _native_dasha_master_families +from scripts.kalachakra_dasha import calculate_kalachakra_dasha +from scripts.pl9_reader_export import render_pl9_parity_markdown +from scripts.yogini_dasha import calculate_yogini_dasha + + +def _major_view(row: dict) -> tuple: + return ( + row.get("lord") or row.get("planet") or row.get("rashi") or row.get("sign"), + row.get("rashi") or row.get("sign"), + row.get("years"), + row.get("start_date") or row.get("start"), + row.get("end_date") or row.get("end"), + ) + + +def _applicable_ashtottari() -> dict: + for index in range(27): + result = calculate_ashtottari_dasha({ + "moon_nakshatra_index": index, + "is_shukla_paksha": True, + "moon_longitude": index * (360.0 / 27.0) + 1.0, + "birth_datetime": datetime(1990, 1, 1, 12, 0), + }) + if result.get("major"): + return result + raise AssertionError("no applicable Ashtottari fixture") + + +def test_kalachakra_children_hang_on_existing_majors() -> None: + source = calculate_kalachakra_dasha({ + "moon_nakshatra_index": 0, + "moon_pada": 1, + "birth_datetime": datetime(1990, 6, 15, 10, 30), + }) + before = [_major_view(row) for row in source["major"]] + enriched, antardasha, pratyantar = attach_kalachakra_simplified_children( + source["major"], source["total_cycle"], + ) + assert [_major_view(row) for row in source["major"]] == before + assert "antardasha" not in source["major"][0] + assert [_major_view(row) for row in enriched] == before + assert antardasha and pratyantar + assert enriched[0]["antardasha"][-1]["end_date"] == enriched[0]["end_date"] + assert enriched[0]["antardasha"][0]["model"] == "simplified" + assert enriched[0]["antardasha"][0]["pratyantardasha"] + + +def test_master_pack_keeps_sourced_children_and_leaves_modules_alone() -> None: + ashtottari = _applicable_ashtottari() + yogini = calculate_yogini_dasha({ + "birth_datetime": datetime(1990, 6, 15, 10, 30), + "moon_longitude": 10.0, + "as_of": datetime(2020, 1, 1), + }) + kalachakra = calculate_kalachakra_dasha({ + "moon_nakshatra_index": 0, + "moon_pada": 1, + "birth_datetime": datetime(1990, 6, 15, 10, 30), + }) + narayana = { + "mahadasha_sequence": [{ + "sign": "Aries", "lord": "Mars", "years": 7, + "start_age": 0, "end_age": 7, "period_key": "md-0", + }], + "dense_boundaries": [{ + "level": "AD", "sign": "Taurus", "lord": "Venus", + "duration_years": 1, "start_age": 0, "end_age": 1, + "parent_md": "Aries", "parent_period_key": "md-0", + }], + } + tribhagi = {"major": [{ + "lord": "Sun", "years": 1, + "start_date": "2000-01-01T00:00:00", "end_date": "2001-01-01T00:00:00", + }]} + families = _native_dasha_master_families({ + "ashtottari_dasha": ashtottari, + "yogini_dasha": yogini, + "kalachakra_dasha": kalachakra, + "narayana_dasha": narayana, + "tribhagi_dasha": tribhagi, + "dasha": {"timeline": [{"lord": "Sun", "start": "2000-01-01", "end": "2006-01-01"}]}, + }) + assert families["ashtottari"]["periods"][0]["antardasha"] + assert families["yogini"]["periods"][0]["antardasha"] + assert families["kala_chakra"]["periods"][0]["antardasha"][0]["pratyantardasha"] + assert families["narayana"]["periods"][0]["antardasha"][0]["sign"] == "Taurus" + assert "antardasha" not in tribhagi["major"][0] + assert "antardasha" not in yogini["major"][0] + assert "antardasha" not in kalachakra["major"][0] + assert "antardasha" not in narayana["mahadasha_sequence"][0] + assert [_major_view(row) for row in families["kala_chakra"]["periods"]] == [ + _major_view(row) for row in kalachakra["major"] + ] + assert families["vimshottari"]["periods"][0]["lord"] == "Sun" + assert "antardasha" not in families["vimshottari"]["periods"][0] + + +def test_missing_child_rule_and_kalachakra_model_are_labeled() -> None: + packet = { + "report_language": "zh", + "worksheets": {"timing_and_predictive_systems": {"dasha_master_pack": {"families": { + "tribhagi": {"periods": [{ + "lord": "Sun", "years": 1, + "start_date": "2000-01-01T00:00:00", "end_date": "2001-01-01T00:00:00", + }]}, + "kala_chakra": {"periods": [{ + "lord": "Sun", "rashi": "Aries", "years": 7, + "start_date": "2000-01-01T00:00:00", "end_date": "2007-01-01T00:00:00", + "antardasha": [{ + "lord": "Moon", "rashi": "Cancer", "parent_rashi": "Aries", + "start_date": "2000-01-01T00:00:00", "end_date": "2001-01-01T00:00:00", + "pratyantardasha": [{ + "lord": "Mars", "rashi": "Aries", + "start_date": "2000-01-01T00:00:00", "end_date": "2000-02-01T00:00:00", + }], + }], + }]}, + }}}}, + } + chinese = render_pl9_parity_markdown(packet) + assert "本体系未提供子运" in chinese + assert "简化模型" in chinese + assert "次小运" in chinese + english = render_pl9_parity_markdown({**packet, "report_language": "en"}) + assert "This system does not provide sub-periods." in english + assert "Simplified model" in english + + +def test_enrich_does_not_invent_children_for_vimshottari() -> None: + rows = enrich_master_family("vimshottari", {"timeline": []}, [{"lord": "Sun", "years": 6}]) + assert rows == [{"lord": "Sun", "years": 6}] + assert "antardasha" not in rows[0]