From b2faf72e83422e443b13ec4acd68e947097306c3 Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Sun, 7 Jun 2026 14:26:51 +0800 Subject: [PATCH] v6.1.2: Merge 4 hidden modules from session workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added modules: - ashtottari_dasha.py — 108-year cycle conditional dasha (BPHS Ch.19) - yogini_dasha.py — 36-year cycle, 8 Yoginis dasha system - kalachakra_dasha.py — Savya/Apasavya dual-mode dasha - report_orchestrator.py — 5-theme narrative report generator with cross-validation engine --- scripts/ashtottari_dasha.py | 251 +++++++ scripts/kalachakra_dasha.py | 301 +++++++++ scripts/report_orchestrator.py | 1158 ++++++++++++++++++++++++++++++++ scripts/yogini_dasha.py | 209 ++++++ 4 files changed, 1919 insertions(+) create mode 100644 scripts/ashtottari_dasha.py create mode 100644 scripts/kalachakra_dasha.py create mode 100644 scripts/report_orchestrator.py create mode 100644 scripts/yogini_dasha.py diff --git a/scripts/ashtottari_dasha.py b/scripts/ashtottari_dasha.py new file mode 100644 index 00000000..01d65f76 --- /dev/null +++ b/scripts/ashtottari_dasha.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +ashtottari_dasha.py — Ashtottari Dasha System (108-Year Cycle) + +Reference: Brihat Parashara Hora Shastra (BPHS), Chapter 19 + +Ashtottari Dasha is a conditional dasha system with a total cycle of 108 years. +It is applicable only when the Moon is in specific Nakshatras: + + - Krishna Paksha (waning Moon): Rohini, Ardra, Pushya, Ashlesha, Magha, Revati + - Shukla Paksha (waxing Moon): Ashwini, Mrigashira, Punarvasu, Chitra, Shravana, Dhanishta + +The 8 planetary lords and their year allotments: + 1. Sun — 6 years + 2. Moon — 15 years + 3. Mars — 8 years + 4. Mercury — 17 years + 5. Saturn — 10 years + 6. Jupiter — 19 years + 7. Rahu — 12 years + 8. Venus — 21 years + +Total: 6 + 15 + 8 + 17 + 10 + 19 + 12 + 21 = 108 years. + +The dasha sequence always runs: Sun → Moon → Mars → Mercury → Saturn → Jupiter → Rahu → Venus. +The starting lord is determined by the Moon's Nakshatra at birth. +""" + +from datetime import datetime, timedelta + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +# Nakshatra indices (0-based): Ashwini=0, Bharani=1, ..., Revati=26 +NAKSHATRAS = [ + "Ashwini", "Bharani", "Krittika", "Rohini", "Mrigashira", "Ardra", + "Punarvasu", "Pushya", "Ashlesha", "Magha", "Purva Phalguni", + "Uttara Phalguni", "Hasta", "Chitra", "Swati", "Vishakha", + "Anuradha", "Jyeshtha", "Mula", "Purva Ashadha", "Uttara Ashadha", + "Shravana", "Dhanishta", "Shatabhisha", "Purva Bhadrapada", + "Uttara Bhadrapada", "Revati" +] + +# Applicable Nakshatras for Ashtottari Dasha +SHUKLA_PAKSHA_NAKSHATRAS = {0, 4, 6, 13, 20, 22} # Ashwini, Mrigashira, Punarvasu, Chitra, Shravana, Dhanishta +KRISHNA_PAKSHA_NAKSHATRAS = {3, 5, 7, 8, 9, 26} # Rohini, Ardra, Pushya, Ashlesha, Magha, Revati + +# Ashtottari planetary sequence and year allotments (BPHS Ch.19) +DASHA_SEQUENCE = [ + {"planet": "Sun", "years": 6}, + {"planet": "Moon", "years": 15}, + {"planet": "Mars", "years": 8}, + {"planet": "Mercury", "years": 17}, + {"planet": "Saturn", "years": 10}, + {"planet": "Jupiter", "years": 19}, + {"planet": "Rahu", "years": 12}, + {"planet": "Venus", "years": 21}, +] + +TOTAL_CYCLE = 108 # years + +# Mapping from starting Nakshatra index to first dasha lord index in DASHA_SEQUENCE +# Per BPHS Ch.19: +# - Rohini (3), Ardra (5), Pushya (7), Ashlesha (8), Magha (9), Revati (26) +# → start from Sun (0) +# - Ashwini (0), Mrigashira (4), Punarvasu (6) +# → start from Mars (2) +# - Chitra (13), Shravana (20), Dhanishta (22) +# → start from Jupiter (5) +STARTING_LORD_MAP = { + 3: 0, # Rohini -> Sun + 5: 0, # Ardra -> Sun + 7: 0, # Pushya -> Sun + 8: 0, # Ashlesha -> Sun + 9: 0, # Magha -> Sun + 26: 0, # Revati -> Sun + 0: 2, # Ashwini -> Mars + 4: 2, # Mrigashira -> Mars + 6: 2, # Punarvasu -> Mars + 13: 5, # Chitra -> Jupiter + 20: 5, # Shravana -> Jupiter + 22: 5, # Dhanishta -> Jupiter +} + +# --------------------------------------------------------------------------- +# Core Functions +# --------------------------------------------------------------------------- + +def is_ashtottari_applicable(moon_nakshatra_index: int, is_shukla_paksha: bool) -> bool: + """ + Check whether Ashtottari Dasha is applicable for the given birth conditions. + + Args: + moon_nakshatra_index: 0-based index of Moon's Nakshatra (0 = Ashwini). + is_shukla_paksha: True if birth is in Shukla Paksha (waxing Moon), else False. + + Returns: + bool: True if Ashtottari Dasha applies. + """ + if is_shukla_paksha: + return moon_nakshatra_index in SHUKLA_PAKSHA_NAKSHATRAS + else: + return moon_nakshatra_index in KRISHNA_PAKSHA_NAKSHATRAS + + +def _build_major_periods(start_lord_idx: int, birth_date: datetime) -> list: + """ + Build the full list of major periods (Mahadashas) given the starting lord. + + Returns a list of dicts with keys: planet, years, start_date, end_date. + """ + periods = [] + seq_len = len(DASHA_SEQUENCE) + current_date = birth_date + + for i in range(seq_len): + idx = (start_lord_idx + i) % seq_len + lord = DASHA_SEQUENCE[idx] + end_date = current_date + timedelta(days=lord["years"] * 365.25) + periods.append({ + "planet": lord["planet"], + "years": lord["years"], + "start_date": current_date.isoformat(), + "end_date": end_date.isoformat(), + }) + current_date = end_date + + return periods + + +def calculate_ashtottari_dasha(birth_info: dict) -> dict: + """ + Calculate Ashtottari Dasha for a native. + + Args: + birth_info: dict containing: + - "moon_nakshatra_index": int (0-26) + - "is_shukla_paksha": bool + - "birth_datetime": datetime object or ISO-format string + + Returns: + dict with keys: + - "applicable": bool — whether this dasha system applies + - "major": list of major period dicts (planet, years, start/end dates) + - "current": dict — the currently running major period + - "total_cycle": int — 108 + - "starting_planet": str — first dasha lord + """ + moon_idx = birth_info.get("moon_nakshatra_index") + is_shukla = birth_info.get("is_shukla_paksha", True) + birth_dt = birth_info.get("birth_datetime") + + if isinstance(birth_dt, str): + birth_dt = datetime.fromisoformat(birth_dt) + if birth_dt is None: + birth_dt = datetime.now() + + applicable = is_ashtottari_applicable(moon_idx, is_shukla) + + if not applicable: + return { + "applicable": False, + "major": [], + "current": None, + "total_cycle": TOTAL_CYCLE, + "starting_planet": None, + "reason": f"Moon Nakshatra '{NAKSHATRAS[moon_idx]}' does not qualify for Ashtottari Dasha under {'Shukla' if is_shukla else 'Krishna'} Paksha.", + } + + start_lord_idx = STARTING_LORD_MAP.get(moon_idx, 0) + starting_planet = DASHA_SEQUENCE[start_lord_idx]["planet"] + + major_periods = _build_major_periods(start_lord_idx, birth_dt) + + # Determine current period + now = datetime.now() + current_period = None + for p in major_periods: + p_start = datetime.fromisoformat(p["start_date"]) + p_end = datetime.fromisoformat(p["end_date"]) + if p_start <= now < p_end: + current_period = p.copy() + current_period["elapsed_years"] = (now - p_start).days / 365.25 + current_period["remaining_years"] = (p_end - now).days / 365.25 + break + + return { + "applicable": True, + "major": major_periods, + "current": current_period, + "total_cycle": TOTAL_CYCLE, + "starting_planet": starting_planet, + } + + +# --------------------------------------------------------------------------- +# Self-test +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + # Example 1: Applicable case — Moon in Rohini, Krishna Paksha + test_birth_1 = { + "moon_nakshatra_index": 3, # Rohini + "is_shukla_paksha": False, # Krishna Paksha + "birth_datetime": datetime(1990, 6, 15, 10, 30), + } + result_1 = calculate_ashtottari_dasha(test_birth_1) + print("=" * 60) + print("Test 1: Moon in Rohini, Krishna Paksha") + print("=" * 60) + print(f"Applicable: {result_1['applicable']}") + print(f"Starting Planet: {result_1['starting_planet']}") + print(f"Total Cycle: {result_1['total_cycle']} years") + print("Major Periods:") + for p in result_1["major"]: + print(f" {p['planet']:10s} | {p['years']:2d} years | {p['start_date'][:10]} → {p['end_date'][:10]}") + print(f"Current Period: {result_1['current']}") + print() + + # Example 2: Applicable case — Moon in Ashwini, Shukla Paksha + test_birth_2 = { + "moon_nakshatra_index": 0, # Ashwini + "is_shukla_paksha": True, # Shukla Paksha + "birth_datetime": datetime(1985, 3, 20, 8, 0), + } + result_2 = calculate_ashtottari_dasha(test_birth_2) + print("=" * 60) + print("Test 2: Moon in Ashwini, Shukla Paksha") + print("=" * 60) + print(f"Applicable: {result_2['applicable']}") + print(f"Starting Planet: {result_2['starting_planet']}") + print(f"Total Cycle: {result_2['total_cycle']} years") + for p in result_2["major"]: + print(f" {p['planet']:10s} | {p['years']:2d} years | {p['start_date'][:10]} → {p['end_date'][:10]}") + print(f"Current Period: {result_2['current']}") + print() + + # Example 3: Non-applicable case — Moon in Bharani, Shukla Paksha + test_birth_3 = { + "moon_nakshatra_index": 1, # Bharani + "is_shukla_paksha": True, + "birth_datetime": datetime(2000, 1, 1, 0, 0), + } + result_3 = calculate_ashtottari_dasha(test_birth_3) + print("=" * 60) + print("Test 3: Moon in Bharani, Shukla Paksha (Non-applicable)") + print("=" * 60) + print(f"Applicable: {result_3['applicable']}") + print(f"Reason: {result_3['reason']}") diff --git a/scripts/kalachakra_dasha.py b/scripts/kalachakra_dasha.py new file mode 100644 index 00000000..5c6462bb --- /dev/null +++ b/scripts/kalachakra_dasha.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +kalachakra_dasha.py — Kalachakra Dasha System (Variable Cycle) + +The Kalachakra Dasha is one of the most intricate Nakshatra-based dasha systems. +It is based on the Moon's Nakshatra and its Pada (quarter) at birth. + +Key Concepts: + - 27 Nakshatras × 4 Padas = 108 Padas. + - Each Pada maps to a specific Rashi (Zodiac sign). + - Two directional modes: + * Savya (clockwise / forward) + * Apasavya (anti-clockwise / backward) + - 9 lords (Navatara-like) govern the periods in a fixed sequence. + +Nakshatra Grouping (per standard texts): + Savya (forward) Nakshatras: + Ashwini, Bharani, Krittika, Ashlesha, Magha, Moola, + Purva Ashadha, Uttara Ashadha, Shravana, Dhanishta, Shatabhisha + + Apasavya (backward) Nakshatras: + Rohini, Mrigashira, Ardra, Punarvasu, Pushya, Chitra, + Swati, Vishakha, Anuradha, Jyeshtha, Purva Bhadrapada, + Uttara Bhadrapada, Revati + + (Note: Some authorities include Abhijit; this implementation uses the 27-Nakshatra scheme.) + +9-Lord Sequence: + The 9 dasha lords correspond to the Navagraha sequence used in Kalachakra: + 1. Ketu 2. Venus 3. Sun 4. Moon 5. Mars 6. Mercury 7. Jupiter 8. Saturn 9. Rahu + + Their year allotments (one common traditional set): + Ketu: 7, Venus: 20, Sun: 6, Moon: 10, Mars: 7, + Mercury: 18, Jupiter: 16, Saturn: 19, Rahu: 17 + Total: 7+20+6+10+7+18+16+19+17 = 120 years + + However, Kalachakra is more commonly implemented with Rashi-based years. + This module uses a simplified Rashi-year model (see _RASHI_YEARS below). + +Rashi Dasha Sequence and Years (simplified standard model): + + Savya mode (forward through zodiac): + Capricorn 7 → Aquarius 8 → Pisces 9 → Aries 10 → + Taurus 11 → Gemini 12 → Cancer 13 → Leo 14 → + Virgo 15 → Libra 16 → Scorpio 17 → Sagittarius 18 + Total = 150 years + + Apasavya mode (backward through zodiac): + Cancer 13 → Gemini 12 → Taurus 11 → Aries 10 → + Pisces 9 → Aquarius 8 → Capricorn 7 → Sagittarius 18 → + Scorpio 17 → Libra 16 → Virgo 15 → Leo 14 + Total = 150 years + + Each of the 9 lords rules one consecutive Rashi in the sequence. + The starting point is determined by Moon's Nakshatra Pada. + +Reference: Brihat Parashara Hora Shastra and various classical commentaries on Kalachakra. +""" + +from datetime import datetime, timedelta + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +NAKSHATRAS = [ + "Ashwini", "Bharani", "Krittika", "Rohini", "Mrigashira", "Ardra", + "Punarvasu", "Pushya", "Ashlesha", "Magha", "Purva Phalguni", + "Uttara Phalguni", "Hasta", "Chitra", "Swati", "Vishakha", + "Anuradha", "Jyeshtha", "Mula", "Purva Ashadha", "Uttara Ashadha", + "Shravana", "Dhanishta", "Shatabhisha", "Purva Bhadrapada", + "Uttara Bhadrapada", "Revati" +] + +RASHIS = [ + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces" +] + +# 9 Kalachakra lords in order +KALA_LORDS = [ + "Ketu", "Venus", "Sun", "Moon", "Mars", + "Mercury", "Jupiter", "Saturn", "Rahu" +] + +# Savya Rashi sequence (starting from Capricorn, forward) +SAVYA_RASHI_SEQ = [9, 10, 11, 0, 1, 2, 3, 4, 5, 6, 7, 8] +# Apasavya Rashi sequence (starting from Cancer, backward) +APASAVYA_RASHI_SEQ = [3, 2, 1, 0, 11, 10, 9, 8, 7, 6, 5, 4] + +# Rashi year allotments (index 0-11) +# Savya: Cap 7, Aqua 8, Pis 9, Ari 10, Tau 11, Gem 12, Can 13, Leo 14, Vir 15, Lib 16, Sco 17, Sag 18 +# Apasavya: Can 13, Gem 12, Tau 11, Ari 10, Pis 9, Aqua 8, Cap 7, Sag 18, Sco 17, Lib 16, Vir 15, Leo 14 +_RASHI_YEARS_SAVYA = { + 9: 7, 10: 8, 11: 9, 0: 10, 1: 11, 2: 12, + 3: 13, 4: 14, 5: 15, 6: 16, 7: 17, 8: 18 +} +_RASHI_YEARS_APASAVYA = { + 3: 13, 2: 12, 1: 11, 0: 10, 11: 9, 10: 8, + 9: 7, 8: 18, 7: 17, 6: 16, 5: 15, 4: 14 +} + +# Nakshatra → mode mapping (Savya = True, Apasavya = False) +# Per standard grouping +SAVYA_NAKSHATRAS = {0, 1, 2, 8, 9, 18, 19, 20, 21, 22, 23} +APASAVYA_NAKSHATRAS = {3, 4, 5, 6, 7, 13, 14, 15, 16, 17, 24, 25, 26} + +# Pada → starting Rashi offset within the sequence (simplified model) +# For Savya: Pada 1 → Cap(0), Pada 2 → Aqu(1), Pada 3 → Pis(2), Pada 4 → Ari(3) +# For Apasavya: Pada 1 → Can(0), Pada 2 → Gem(1), Pada 3 → Tau(2), Pada 4 → Ari(3) +# The actual classical mapping is more nuanced; this is a practical approximation. +PADA_OFFSET_MAP = {1: 0, 2: 1, 3: 2, 4: 3} + + +# --------------------------------------------------------------------------- +# Helper Functions +# --------------------------------------------------------------------------- + +def _get_mode(nakshatra_index: int) -> str: + """Return 'savya' or 'apasavya' based on Nakshatra.""" + if nakshatra_index in SAVYA_NAKSHATRAS: + return "savya" + elif nakshatra_index in APASAVYA_NAKSHATRAS: + return "apasavya" + else: + # Default fallback (should not happen with valid 0-26 input) + return "savya" + + +def _build_major_periods(mode: str, start_offset: int, birth_date: datetime) -> list: + """ + Build major periods for Kalachakra Dasha. + + Args: + mode: 'savya' or 'apasavya'. + start_offset: offset into the 12-sign Rashi sequence. + birth_date: datetime of birth. + + Returns: + list of period dicts. + """ + if mode == "savya": + rashi_seq = SAVYA_RASHI_SEQ + years_map = _RASHI_YEARS_SAVYA + else: + rashi_seq = APASAVYA_RASHI_SEQ + years_map = _RASHI_YEARS_APASAVYA + + periods = [] + current_date = birth_date + seq_len = len(rashi_seq) + + for i in range(seq_len): + seq_idx = (start_offset + i) % seq_len + rashi_idx = rashi_seq[seq_idx] + lord_idx = i % 9 # 9 lords cycle through 12 rashis + lord = KALA_LORDS[lord_idx] + years = years_map[rashi_idx] + end_date = current_date + timedelta(days=years * 365.25) + + periods.append({ + "lord": lord, + "rashi": RASHIS[rashi_idx], + "rashi_index": rashi_idx, + "years": years, + "start_date": current_date.isoformat(), + "end_date": end_date.isoformat(), + }) + current_date = end_date + + return periods + + +# --------------------------------------------------------------------------- +# Core Functions +# --------------------------------------------------------------------------- + +def calculate_kalachakra_dasha(birth_info: dict) -> dict: + """ + Calculate Kalachakra Dasha for a native. + + Args: + birth_info: dict containing: + - "moon_nakshatra_index": int (0-26) + - "moon_pada": int (1-4) + - "birth_datetime": datetime object or ISO-format string (optional) + + Returns: + dict with keys: + - "mode": str — 'savya' or 'apasavya' + - "major": list of major period dicts + - "current": dict — the currently running major period + - "total_cycle": int — sum of years in one full cycle + - "starting_lord": str + - "starting_rashi": str + """ + nakshatra_idx = birth_info.get("moon_nakshatra_index", 0) + pada = birth_info.get("moon_pada", 1) + birth_dt = birth_info.get("birth_datetime") + + if isinstance(birth_dt, str): + birth_dt = datetime.fromisoformat(birth_dt) + if birth_dt is None: + birth_dt = datetime.now() + + # Clamp pada to 1-4 + if not 1 <= pada <= 4: + pada = ((pada - 1) % 4) + 1 + + mode = _get_mode(nakshatra_idx) + start_offset = PADA_OFFSET_MAP.get(pada, 0) + + major_periods = _build_major_periods(mode, start_offset, birth_dt) + total_cycle = sum(p["years"] for p in major_periods) + + starting_lord = major_periods[0]["lord"] if major_periods else None + starting_rashi = major_periods[0]["rashi"] if major_periods else None + + # Determine current period + now = datetime.now() + current_period = None + for p in major_periods: + p_start = datetime.fromisoformat(p["start_date"]) + p_end = datetime.fromisoformat(p["end_date"]) + if p_start <= now < p_end: + current_period = p.copy() + current_period["elapsed_years"] = (now - p_start).days / 365.25 + current_period["remaining_years"] = (p_end - now).days / 365.25 + break + + return { + "mode": mode, + "major": major_periods, + "current": current_period, + "total_cycle": total_cycle, + "starting_lord": starting_lord, + "starting_rashi": starting_rashi, + } + + +# --------------------------------------------------------------------------- +# Self-test +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + # Example 1: Savya mode — Moon in Ashwini (Savya), Pada 1 + test_birth_1 = { + "moon_nakshatra_index": 0, # Ashwini (Savya) + "moon_pada": 1, + "birth_datetime": datetime(1990, 6, 15, 10, 30), + } + result_1 = calculate_kalachakra_dasha(test_birth_1) + print("=" * 70) + print("Test 1: Moon in Ashwini (Savya), Pada 1") + print("=" * 70) + print(f"Mode : {result_1['mode']}") + print(f"Starting Lord : {result_1['starting_lord']}") + print(f"Starting Rashi : {result_1['starting_rashi']}") + print(f"Total Cycle : {result_1['total_cycle']} years") + print("Major Periods:") + for p in result_1["major"]: + print(f" {p['lord']:8s} | {p['rashi']:12s} | {p['years']:2d} years | {p['start_date'][:10]} → {p['end_date'][:10]}") + print(f"Current Period : {result_1['current']}") + print() + + # Example 2: Apasavya mode — Moon in Rohini (Apasavya), Pada 2 + test_birth_2 = { + "moon_nakshatra_index": 3, # Rohini (Apasavya) + "moon_pada": 2, + "birth_datetime": datetime(1985, 3, 20, 8, 0), + } + result_2 = calculate_kalachakra_dasha(test_birth_2) + print("=" * 70) + print("Test 2: Moon in Rohini (Apasavya), Pada 2") + print("=" * 70) + print(f"Mode : {result_2['mode']}") + print(f"Starting Lord : {result_2['starting_lord']}") + print(f"Starting Rashi : {result_2['starting_rashi']}") + print(f"Total Cycle : {result_2['total_cycle']} years") + for p in result_2["major"]: + print(f" {p['lord']:8s} | {p['rashi']:12s} | {p['years']:2d} years | {p['start_date'][:10]} → {p['end_date'][:10]}") + print(f"Current Period : {result_2['current']}") + print() + + # Example 3: Savya mode — Moon in Shravana (Savya), Pada 4 + test_birth_3 = { + "moon_nakshatra_index": 21, # Shravana (Savya) + "moon_pada": 4, + "birth_datetime": datetime(2000, 1, 1, 0, 0), + } + result_3 = calculate_kalachakra_dasha(test_birth_3) + print("=" * 70) + print("Test 3: Moon in Shravana (Savya), Pada 4") + print("=" * 70) + print(f"Mode : {result_3['mode']}") + print(f"Starting Lord : {result_3['starting_lord']}") + print(f"Starting Rashi : {result_3['starting_rashi']}") + print(f"Total Cycle : {result_3['total_cycle']} years") + for p in result_3["major"]: + print(f" {p['lord']:8s} | {p['rashi']:12s} | {p['years']:2d} years | {p['start_date'][:10]} → {p['end_date'][:10]}") diff --git a/scripts/report_orchestrator.py b/scripts/report_orchestrator.py new file mode 100644 index 00000000..9aacb3b4 --- /dev/null +++ b/scripts/report_orchestrator.py @@ -0,0 +1,1158 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Jyotish Thematic Report Orchestrator +===================================== +主题化解盘编排器 —— 将多种印度占星技法整合为主题化叙事报告。 + +架构设计: +- 主题定义层 (Theme Definitions):5 大人生主题的技法映射 +- 交叉验证引擎 (Cross-Validation Engine):多源证据聚合与矛盾裁决 +- 叙事生成器 (Narrative Generator):将技法结论转译为连贯段落 +- 时间锚定器 (Timing Anchor):将静态格局与动态 Dasha 周期关联 + +作者:Architect +日期:2026-06-07 +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field, asdict +from enum import Enum +from typing import Any, Dict, List, Optional, Callable +from datetime import datetime + + +# ═══════════════════════════════════════════════════════════════ +# 核心枚举与常量 +# ═══════════════════════════════════════════════════════════════ + +class StrengthLevel(str, Enum): + """结论强度分级""" + WEAK = "weak" + MODERATE = "moderate" + STRONG = "strong" + + +class ThemeName(str, Enum): + """五大分析主题""" + MARRIAGE = "marriage" + CAREER = "career" + WEALTH = "wealth" + HEALTH = "health" + SPIRITUALITY = "spirituality" + + +# 经典矛盾裁决优先级:D9 > D1(婚姻),D10 > D1(事业),D30 > D1(健康) +# 数值越大优先级越高 +CHART_PRIORITY = { + "D1": 1, + "D2": 2, + "D7": 3, + "D9": 5, # 婚姻分盘最高优先级 + "D10": 5, # 事业分盘最高优先级 + "D20": 4, + "D30": 5, # 健康分盘最高优先级 + "D60": 4, + "Rashi": 1, + "Navamsa": 5, + "Dashamsha": 5, + "Trimsamsa": 5, +} + + +# ═══════════════════════════════════════════════════════════════ +# 数据模型 +# ═══════════════════════════════════════════════════════════════ + +@dataclass +class TechniqueResult: + """单一技法分析结果""" + technique: str # 技法名称,如 "D1-7th-house", "Venus-strength" + chart: str # 所属分盘,如 "D1", "D9", "D10" + conclusion: str # 结论描述 + sentiment: str # 情感倾向: "positive" | "negative" | "neutral" + strength: StrengthLevel # 该技法本身的证据强度 + details: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ConflictResolution: + """矛盾裁决记录""" + technique_a: str + conclusion_a: str + technique_b: str + conclusion_b: str + resolution: str # 裁决结果 + reasoning: str # 裁决依据 + winner: str # 采纳哪方结论 + + +@dataclass +class TimingAnchor: + """时间锚定信息""" + dasha_period: str # 如 "Jupiter-Mercury" + start_year: int + end_year: int + activation_description: str # 激活描述 + is_current: bool = False + + +@dataclass +class ThemeReport: + """单一主题完整报告""" + theme: ThemeName + summary: str # 一句话总结 + narrative: str # 详细叙事段落 + evidence: List[Dict[str, Any]] + strength: StrengthLevel + timing: Optional[TimingAnchor] + conflicts: List[ConflictResolution] + recommendations: List[str] + + def to_dict(self) -> Dict[str, Any]: + d = asdict(self) + d["theme"] = self.theme.value + d["strength"] = self.strength.value + return d + + def to_json(self, indent: int = 2, ensure_ascii: bool = False) -> str: + return json.dumps(self.to_dict(), indent=indent, ensure_ascii=ensure_ascii) + + +@dataclass +class BirthChartData: + """模拟本命盘数据结构(实际项目应由排盘引擎提供)""" + d1_houses: Dict[int, Dict[str, Any]] = field(default_factory=dict) + d9_houses: Dict[int, Dict[str, Any]] = field(default_factory=dict) + d10_houses: Dict[int, Dict[str, Any]] = field(default_factory=dict) + d2_houses: Dict[int, Dict[str, Any]] = field(default_factory=dict) + d20_houses: Dict[int, Dict[str, Any]] = field(default_factory=dict) + d30_houses: Dict[int, Dict[str, Any]] = field(default_factory=dict) + d60_houses: Dict[int, Dict[str, Any]] = field(default_factory=dict) + planets: Dict[str, Dict[str, Any]] = field(default_factory=dict) + current_dasha: str = "" + dasha_timeline: List[Dict[str, Any]] = field(default_factory=list) + yogas: List[Dict[str, Any]] = field(default_factory=list) + ashtakavarga: Dict[str, Any] = field(default_factory=dict) + + +# ═══════════════════════════════════════════════════════════════ +# 主题定义与技法映射 +# ═══════════════════════════════════════════════════════════════ + +THEME_TECHNIQUE_MAP: Dict[ThemeName, Dict[str, Any]] = { + ThemeName.MARRIAGE: { + "name_zh": "婚姻", + "techniques": [ + ("D1-7th-house", "D1", "第7宫主星及宫内星分析"), + ("D9-lagna", "D9", "Navamsa 上升及主星状态"), + ("DK-position", "D1", "Dara Karaka 所在宫位与星座"), + ("Upapada-lagna", "D1", "Upapada Lagna 及主星"), + ("RTN-analysis", "D1", "Navamsa Tulya Rashi 分析"), + ("Venus-strength", "D1", "Venus 庙旺落陷及相位"), + ("Tithi-lord", "D1", "出生 Tithi 主星状态"), + ], + "primary_chart": "D9", + "conflict_rule": "婚姻议题以 D9 (Navamsa) 为最终裁决依据", + }, + ThemeName.CAREER: { + "name_zh": "事业", + "techniques": [ + ("D1-10th-house", "D1", "第10宫主星、宫内星及相位"), + ("D10-lagna", "D10", "Dashamsha 上升及主星"), + ("Karaka-position", "D1", "Amatyakaraka 所在宫位"), + ("Amatyakaraka-dignity", "D1", "Amatyakaraka 庙旺状态"), + ("Dasha-career-link", "Dasha", "大运与10宫主关联"), + ("Raja-Yoga-10th", "D1", "10宫相关 Raja Yoga"), + ], + "primary_chart": "D10", + "conflict_rule": "事业议题以 D10 (Dashamsha) 为最终裁决依据", + }, + ThemeName.WEALTH: { + "name_zh": "财富", + "techniques": [ + ("D2-analysis", "D2", "Hora 分盘日月属性分布"), + ("Dhana-Yoga", "D1", "Dhana Yoga 组合识别"), + ("2nd-lord", "D1", "第2宫主星状态"), + ("11th-lord", "D1", "第11宫主星状态"), + ("Ashtakavarga-wealth", "AV", "2/11宫 Ashtakavarga 分值"), + ("Lakshmi-Yoga", "D1", "Lakshmi Yoga 检测"), + ], + "primary_chart": "D2", + "conflict_rule": "财富议题综合 D2 与 Ashtakavarga,Dhana Yoga 为辅助验证", + }, + ThemeName.HEALTH: { + "name_zh": "健康", + "techniques": [ + ("D1-6th-house", "D1", "第6宫疾病宫位分析"), + ("D1-8th-house", "D1", "第8宫长寿/意外宫位"), + ("D1-12th-house", "D1", "第12宫住院/虚弱宫位"), + ("D30-analysis", "D30", "Trimsamsa 健康分盘"), + ("Arista-Yoga", "D1", "Arista Yoga 凶星组合"), + ("Malefic-conjunction", "D1", "凶星合相第1宫或日月"), + ("Saturn-Mars", "D1", "Saturn-Mars 合相/对冲"), + ], + "primary_chart": "D30", + "conflict_rule": "健康议题以 D30 (Trimsamsa) 为最终裁决依据", + }, + ThemeName.SPIRITUALITY: { + "name_zh": "灵性", + "techniques": [ + ("D20-analysis", "D20", "Vimsamsa 灵性分盘"), + ("D60-analysis", "D60", "Shashtyamsha 业力分盘"), + ("Karakamsha", "D1", "Karakamsha 宫位及主星"), + ("Jupiter-dignity", "D1", "Jupiter 庙旺状态及宫位"), + ("12th-house-spirit", "D1", "第12宫解脱/出离宫位"), + ("Moksha-trikena", "D1", "4/8/12宫 Moksha 三角分析"), + ("Ishta-Devata", "D1", "Ishta Devata 守护 deity"), + ], + "primary_chart": "D20", + "conflict_rule": "灵性议题以 D20 (Vimsamsa) 为核心,D60 验证深层业力", + }, +} + + +# ═══════════════════════════════════════════════════════════════ +# 交叉验证引擎 +# ═══════════════════════════════════════════════════════════════ + +class CrossValidationEngine: + """ + 交叉验证引擎 + + 职责: + 1. 收集同一主题下多个技法的结论 + 2. 检测正面/负面结论之间的矛盾 + 3. 依据经典规则裁决矛盾(如 D9 > D1) + 4. 综合评定整体强度等级 + """ + + def __init__(self, theme: ThemeName): + self.theme = theme + self.techniques: List[TechniqueResult] = [] + self.resolutions: List[ConflictResolution] = [] + + def add_technique(self, result: TechniqueResult) -> None: + self.techniques.append(result) + + def detect_conflicts(self) -> List[ConflictResolution]: + """ + 检测矛盾:同一主题下 sentiment 相反的结论构成矛盾对。 + 采用 O(n^2) 配对检测,实际应用中可按 chart 类型分组优化。 + """ + conflicts = [] + n = len(self.techniques) + for i in range(n): + for j in range(i + 1, n): + a, b = self.techniques[i], self.techniques[j] + # 仅当 sentiment 相反且均非 neutral 时视为矛盾 + if self._is_opposite(a.sentiment, b.sentiment): + resolution = self._adjudicate(a, b) + conflicts.append(resolution) + self.resolutions = conflicts + return conflicts + + @staticmethod + def _is_opposite(s1: str, s2: str) -> bool: + opposite_pairs = {("positive", "negative"), ("negative", "positive")} + return (s1, s2) in opposite_pairs + + def _adjudicate(self, a: TechniqueResult, b: TechniqueResult) -> ConflictResolution: + """ + 矛盾裁决核心逻辑。 + 优先级:1) 分盘优先级(CHART_PRIORITY) 2) 证据强度 3) 主题主盘 + """ + pri_a = CHART_PRIORITY.get(a.chart, 1) + pri_b = CHART_PRIORITY.get(b.chart, 1) + theme_info = THEME_TECHNIQUE_MAP[self.theme] + primary = theme_info["primary_chart"] + + # 规则1:若一方来自主题主盘,优先采纳 + if a.chart == primary and b.chart != primary: + winner, reasoning = a.technique, f"{a.chart} 是本主题主盘,优先采纳" + elif b.chart == primary and a.chart != primary: + winner, reasoning = b.technique, f"{b.chart} 是本主题主盘,优先采纳" + # 规则2:比较分盘优先级 + elif pri_a > pri_b: + winner, reasoning = a.technique, f"{a.chart} 优先级({pri_a})高于{b.chart}({pri_b})" + elif pri_b > pri_a: + winner, reasoning = b.technique, f"{b.chart} 优先级({pri_b})高于{a.chart}({pri_a})" + # 规则3:优先级相同,比较证据强度 + else: + strength_order = {StrengthLevel.STRONG: 3, StrengthLevel.MODERATE: 2, StrengthLevel.WEAK: 1} + sa, sb = strength_order.get(a.strength, 0), strength_order.get(b.strength, 0) + if sa > sb: + winner, reasoning = a.technique, "同等分盘下,证据强度更高" + elif sb > sa: + winner, reasoning = b.technique, "同等分盘下,证据强度更高" + else: + winner, reasoning = a.technique, "势均力敌,保守采纳先出现的结论" + + return ConflictResolution( + technique_a=a.technique, + conclusion_a=a.conclusion, + technique_b=b.technique, + conclusion_b=b.conclusion, + resolution=f"采纳 [{winner}] 的结论", + reasoning=reasoning, + winner=winner, + ) + + def compute_overall_strength(self) -> StrengthLevel: + """ + 综合强度评定算法: + - 统计所有技法 sentiment + - positive 占多数 → strong + - mixed 或 moderate 占多数 → moderate + - negative 占多数且无 strong positive 抵消 → weak + """ + sentiments = [t.sentiment for t in self.techniques] + pos = sentiments.count("positive") + neg = sentiments.count("negative") + neu = sentiments.count("neutral") + total = len(sentiments) or 1 + + # 考虑已裁决的冲突:被裁决为 winner 的负面结论需要加权 + for res in self.resolutions: + # 若 winner 是负面结论,降低整体强度 + winner_tech = res.winner + for t in self.techniques: + if t.technique == winner_tech and t.sentiment == "negative": + neg += 0.5 + + if pos / total >= 0.6 and neg == 0: + return StrengthLevel.STRONG + elif pos >= neg: + return StrengthLevel.MODERATE + else: + return StrengthLevel.WEAK + + def get_dominant_sentiment(self) -> str: + """获取裁决后的主导 sentiment""" + # 应用裁决结果:loser 的 sentiment 被覆盖 + suppressed = set() + for res in self.resolutions: + loser = res.technique_b if res.winner == res.technique_a else res.technique_a + suppressed.add(loser) + + valid = [t for t in self.techniques if t.technique not in suppressed] + if not valid: + valid = self.techniques + + sentiments = [t.sentiment for t in valid] + pos = sentiments.count("positive") + neg = sentiments.count("negative") + if pos > neg: + return "positive" + elif neg > pos: + return "negative" + return "neutral" + + +# ═══════════════════════════════════════════════════════════════ +# 叙事生成器 +# ═══════════════════════════════════════════════════════════════ + +class NarrativeGenerator: + """ + 叙事生成器 + + 将离散的技法结论编织为连贯段落,避免罗列式输出。 + 采用模板引擎 + 语境缝合策略。 + """ + + # 主题级叙事模板 + TEMPLATES: Dict[ThemeName, List[str]] = { + ThemeName.MARRIAGE: [ + "你的婚姻格局中,{d1_summary},而 {d9_summary}。{cross_verification}。{timing_sentence}", + "从 D1 本命盘来看,{d1_detail};Navamsa (D9) 则进一步揭示 {d9_detail}。{conclusion}。", + ], + ThemeName.CAREER: [ + "事业维度上,{d1_summary};深入 Dashamsha (D10) 分盘,{d10_summary}。{cross_verification}。{timing_sentence}", + "本命盘第10宫显示 {d1_detail},而 D10 分盘则精细刻画了 {d10_detail}。{conclusion}。", + ], + ThemeName.WEALTH: [ + "财富格局呈现出 {overall_tone} 态势:{d1_summary},Hora 分盘 (D2) 则 {d2_summary}。{av_sentence} {timing_sentence}", + "从 Dhana Yoga 检测来看,{yoga_detail};2宫主与11宫主的联动表明 {lord_detail}。{conclusion}。", + ], + ThemeName.HEALTH: [ + "健康层面,本命盘的 6/8/12 宫格局显示 {d1_summary};Trimsamsa (D30) 分盘则 {d30_summary}。{cross_verification}。{timing_sentence}", + "需要关注的是,{arista_detail};不过 {positive_offset}。{recommendation_preview}", + ], + ThemeName.SPIRITUALITY: [ + "灵性成长路径上,{d20_summary};Shashtyamsha (D60) 则 {d60_summary}。{karakamsha_detail}。{timing_sentence}", + "Jupiter 作为灵性能量核心,{jupiter_detail};第12宫的配置暗示 {twelfth_detail}。{conclusion}。", + ], + } + + def __init__(self, theme: ThemeName): + self.theme = theme + self.theme_zh = THEME_TECHNIQUE_MAP[theme]["name_zh"] + + def generate( + self, + techniques: List[TechniqueResult], + resolutions: List[ConflictResolution], + timing: Optional[TimingAnchor], + strength: StrengthLevel, + ) -> str: + """生成连贯叙事段落""" + # 1. 按 chart 分组提取关键结论 + by_chart: Dict[str, List[TechniqueResult]] = {} + for t in techniques: + by_chart.setdefault(t.chart, []).append(t) + + # 2. 构建模板变量 + ctx = self._build_context(by_chart, resolutions, timing, strength) + + # 3. 选择并渲染模板 + templates = self.TEMPLATES.get(self.theme, ["{overall_tone}格局。{cross_verification}。"]) + template = templates[0] if strength != StrengthLevel.WEAK else templates[-1] + + try: + narrative = template.format(**ctx) + except KeyError: + # 降级:直接拼接关键句 + narrative = self._fallback_narrative(ctx, strength) + + return narrative + + def _build_context( + self, + by_chart: Dict[str, List[TechniqueResult]], + resolutions: List[ConflictResolution], + timing: Optional[TimingAnchor], + strength: StrengthLevel, + ) -> Dict[str, str]: + """构建模板渲染上下文""" + # 提取各分盘摘要 + d1_items = by_chart.get("D1", []) + d9_items = by_chart.get("D9", []) + d10_items = by_chart.get("D10", []) + d2_items = by_chart.get("D2", []) + d20_items = by_chart.get("D20", []) + d30_items = by_chart.get("D30", []) + d60_items = by_chart.get("D60", []) + + ctx = { + "d1_summary": self._summarize_group(d1_items, "本命盘"), + "d9_summary": self._summarize_group(d9_items, "Navamsa"), + "d10_summary": self._summarize_group(d10_items, "Dashamsha"), + "d2_summary": self._summarize_group(d2_items, "Hora"), + "d20_summary": self._summarize_group(d20_items, "Vimsamsa"), + "d30_summary": self._summarize_group(d30_items, "Trimsamsa"), + "d60_summary": self._summarize_group(d60_items, "Shashtyamsha"), + "d1_detail": self._detail_group(d1_items), + "d9_detail": self._detail_group(d9_items), + "d10_detail": self._detail_group(d10_items), + "overall_tone": self._tone_description(strength), + "cross_verification": self._cross_verification_sentence(resolutions), + "timing_sentence": self._timing_sentence(timing), + "conclusion": self._conclusion_sentence(strength, resolutions), + "yoga_detail": self._extract_by_keyword(d1_items, "Yoga"), + "lord_detail": self._extract_by_keyword(d1_items, "主星"), + "av_sentence": self._extract_by_keyword(d1_items + d2_items, "Ashtakavarga"), + "arista_detail": self._extract_by_keyword(d1_items, "Arista"), + "positive_offset": self._extract_positive(d1_items), + "recommendation_preview": self._recommendation_preview(strength), + "jupiter_detail": self._extract_by_keyword(d1_items, "Jupiter"), + "twelfth_detail": self._extract_by_keyword(d1_items, "12th"), + "karakamsha_detail": self._extract_by_keyword(d1_items, "Karakamsha"), + } + return ctx + + @staticmethod + def _summarize_group(items: List[TechniqueResult], chart_name: str) -> str: + if not items: + return f"{chart_name} 未提供显著信息" + pos = [t for t in items if t.sentiment == "positive"] + neg = [t for t in items if t.sentiment == "negative"] + if len(pos) > len(neg): + return f"{chart_name} 呈现积极信号" + elif len(neg) > len(pos): + return f"{chart_name} 存在挑战因素" + return f"{chart_name} 呈现混合格局" + + @staticmethod + def _detail_group(items: List[TechniqueResult]) -> str: + if not items: + return "信息有限" + return ";".join(t.conclusion for t in items[:2]) + + @staticmethod + def _tone_description(strength: StrengthLevel) -> str: + mapping = { + StrengthLevel.STRONG: "整体积极的", + StrengthLevel.MODERATE: "机遇与挑战并存的", + StrengthLevel.WEAK: "需要谨慎经营的", + } + return mapping.get(strength, "复杂的") + + @staticmethod + def _cross_verification_sentence(resolutions: List[ConflictResolution]) -> str: + if not resolutions: + return "各技法结论相互印证,未检测到显著矛盾" + main = resolutions[0] + return ( + f"交叉验证发现 '{main.technique_a}' 与 '{main.technique_b}' 存在分歧," + f"经裁决:{main.reasoning},{main.resolution}" + ) + + @staticmethod + def _timing_sentence(timing: Optional[TimingAnchor]) -> str: + if not timing: + return "当前大运周期未提供明确激活信号。" + period = f"{timing.start_year}-{timing.end_year}年" + return f"在{period}的{timing.dasha_period}大运期间,{timing.activation_description}" + + @staticmethod + def _conclusion_sentence(strength: StrengthLevel, resolutions: List[ConflictResolution]) -> str: + if strength == StrengthLevel.STRONG: + return "综合来看,格局支持力度较强" + elif strength == StrengthLevel.MODERATE: + return "综合来看,需要把握关键窗口期" + return "综合来看,建议采取审慎策略并积极化解" + + @staticmethod + def _extract_by_keyword(items: List[TechniqueResult], keyword: str) -> str: + for t in items: + if keyword in t.technique or keyword in t.conclusion: + return t.conclusion + return "相关信息待补充" + + @staticmethod + def _extract_positive(items: List[TechniqueResult]) -> str: + for t in items: + if t.sentiment == "positive": + return t.conclusion + return "整体格局仍有调和空间" + + @staticmethod + def _recommendation_preview(strength: StrengthLevel) -> str: + if strength == StrengthLevel.WEAK: + return "建议关注预防性措施" + return "维持良好状态即可" + + def _fallback_narrative(self, ctx: Dict[str, str], strength: StrengthLevel) -> str: + """模板渲染失败时的降级叙事""" + parts = [ + f"你的{self.theme_zh}格局呈现{ctx.get('overall_tone', '复杂')}态势。", + ctx.get("d1_summary", ""), + ctx.get("cross_verification", ""), + ctx.get("timing_sentence", ""), + ] + return " ".join(p for p in parts if p) + + +# ═══════════════════════════════════════════════════════════════ +# 时间锚定器 +# ═══════════════════════════════════════════════════════════════ + +class TimingAnchorBuilder: + """ + 时间锚定器 + + 将主题相关的 Yoga / 宫位激活与当前及未来 Dasha 周期关联。 + """ + + def __init__(self, chart_data: BirthChartData): + self.chart = chart_data + + def build_for_theme(self, theme: ThemeName) -> Optional[TimingAnchor]: + """ + 为主题构建时间锚定。 + 实际项目中应由 Dasha 计算引擎精确推算,此处提供模拟逻辑。 + """ + if not self.chart.dasha_timeline: + return None + + # 找到当前或即将开始的大运周期 + current = None + now = datetime.now().year + for period in self.chart.dasha_timeline: + if period.get("start", 0) <= now <= period.get("end", 0): + current = period + break + if not current: + current = self.chart.dasha_timeline[0] + + # 根据主题生成激活描述 + activation = self._activation_text(theme, current) + + return TimingAnchor( + dasha_period=current.get("mahadasha", "Unknown") + "-" + current.get("antardasha", "Unknown"), + start_year=current.get("start", now), + end_year=current.get("end", now + 3), + activation_description=activation, + is_current=True, + ) + + def _activation_text(self, theme: ThemeName, period: Dict[str, Any]) -> str: + md = period.get("mahadasha", "") + ad = period.get("antardasha", "") + theme_desc = { + ThemeName.MARRIAGE: "婚姻相关宫位与 karaka 将被激活,是缔结或调整伴侣关系的关键期", + ThemeName.CAREER: "事业宫位能量被引动,可能出现职位变动、项目突破或行业转换的契机", + ThemeName.WEALTH: "财富宫位与 Dhana Yoga 被触发,收入结构可能发生变化", + ThemeName.HEALTH: "健康宫位能量显现,是关注身体信号、调整作息的重要时期", + ThemeName.SPIRITUALITY: "灵性宫位被激活,内省、修行或精神探索将获得深层进展", + } + return f"{md} 主运配合 {ad} 副运,{theme_desc.get(theme, '相关能量将被激活')}" + + +# ═══════════════════════════════════════════════════════════════ +# 主题化解盘编排器(主控类) +# ═══════════════════════════════════════════════════════════════ + +class ThematicReportOrchestrator: + """ + 主题化解盘编排器 —— 主控类 + + 使用流程: + 1. 初始化 orchestrator = ThematicReportOrchestrator(chart_data) + 2. 添加技法结果 orchestrator.add_technique(theme, TechniqueResult(...)) + 3. 生成报告 report = orchestrator.generate_report(theme) + 4. 或批量生成 all_reports = orchestrator.generate_all_reports() + """ + + def __init__(self, chart_data: BirthChartData): + self.chart = chart_data + self._data: Dict[ThemeName, List[TechniqueResult]] = { + t: [] for t in ThemeName + } + self._validators: Dict[ThemeName, CrossValidationEngine] = { + t: CrossValidationEngine(t) for t in ThemeName + } + self._narrators: Dict[ThemeName, NarrativeGenerator] = { + t: NarrativeGenerator(t) for t in ThemeName + } + self._timing_builder = TimingAnchorBuilder(chart_data) + + def add_technique(self, theme: ThemeName, result: TechniqueResult) -> None: + """向指定主题添加一个技法分析结果""" + self._data[theme].append(result) + self._validators[theme].add_technique(result) + + def add_techniques(self, theme: ThemeName, results: List[TechniqueResult]) -> None: + """批量添加技法结果""" + for r in results: + self.add_technique(theme, r) + + def generate_report(self, theme: ThemeName) -> ThemeReport: + """生成单一主题完整报告""" + validator = self._validators[theme] + techniques = self._data[theme] + + # 1. 交叉验证 + conflicts = validator.detect_conflicts() + strength = validator.compute_overall_strength() + dominant = validator.get_dominant_sentiment() + + # 2. 时间锚定 + timing = self._timing_builder.build_for_theme(theme) + + # 3. 叙事生成 + narrator = self._narrators[theme] + narrative = narrator.generate(techniques, conflicts, timing, strength) + + # 4. 一句话总结 + summary = self._one_liner_summary(theme, dominant, strength, timing) + + # 5. 证据列表 + evidence = [self._technique_to_dict(t) for t in techniques] + + # 6. 建议生成 + recommendations = self._generate_recommendations(theme, strength, conflicts) + + return ThemeReport( + theme=theme, + summary=summary, + narrative=narrative, + evidence=evidence, + strength=strength, + timing=timing, + conflicts=conflicts, + recommendations=recommendations, + ) + + def generate_all_reports(self) -> Dict[ThemeName, ThemeReport]: + """批量生成全部五大主题报告""" + return {theme: self.generate_report(theme) for theme in ThemeName} + + @staticmethod + def _technique_to_dict(t: TechniqueResult) -> Dict[str, Any]: + return { + "technique": t.technique, + "chart": t.chart, + "conclusion": t.conclusion, + "sentiment": t.sentiment, + "strength": t.strength.value, + "details": t.details, + } + + def _one_liner_summary( + self, + theme: ThemeName, + dominant: str, + strength: StrengthLevel, + timing: Optional[TimingAnchor], + ) -> str: + zh = THEME_TECHNIQUE_MAP[theme]["name_zh"] + sentiment_word = { + "positive": "积极向好", + "negative": "面临挑战", + "neutral": "趋于平稳", + }.get(dominant, "格局复杂") + + strength_word = { + StrengthLevel.STRONG: "且信号明确", + StrengthLevel.MODERATE: "但需把握时机", + StrengthLevel.WEAK: "需谨慎应对", + }.get(strength, "") + + timing_hint = "" + if timing: + timing_hint = f",关键窗口在 {timing.start_year}-{timing.end_year} 年" + + return f"{zh}格局整体{sentiment_word}{strength_word}{timing_hint}。" + + @staticmethod + def _generate_recommendations( + theme: ThemeName, + strength: StrengthLevel, + conflicts: List[ConflictResolution], + ) -> List[str]: + """基于主题、强度和矛盾生成建议""" + recs = [] + zh = THEME_TECHNIQUE_MAP[theme]["name_zh"] + + if strength == StrengthLevel.WEAK: + recs.append(f"{zh}领域存在较多阻碍信号,建议采取保守策略,优先化解不利因素。") + elif strength == StrengthLevel.MODERATE: + recs.append(f"{zh}领域机遇与风险并存,建议聚焦关键窗口期主动出击。") + else: + recs.append(f"{zh}领域格局有利,建议顺势而为,巩固已有优势。") + + if conflicts: + recs.append( + f"检测到技法分歧:{conflicts[0].reasoning}。" + f"建议以 {conflicts[0].winner} 的视角为主要参考。" + ) + + # 主题专属建议 + extra = { + ThemeName.MARRIAGE: [ + "在做出重大婚姻决策前,建议结合 D9 分盘确认伴侣匹配度。", + "Venus 行运期间是推进关系的适宜时机。", + ], + ThemeName.CAREER: [ + "关注 Amatyakaraka 所在宫位的行运触发。", + "D10 分盘中的 Raja Yoga 行运期是事业突破的关键。", + ], + ThemeName.WEALTH: [ + "Dhana Yoga 激活期间可考虑资产配置,但需避开 Arista 叠加期。", + "Ashtakavarga 高分宫位对应的行运期是财富增长的助力。", + ], + ThemeName.HEALTH: [ + "6/8/12 宫主星受克期间需加强体检与预防。", + "D30 分盘中的凶星配置建议通过生活方式调整来调和。", + ], + ThemeName.SPIRITUALITY: [ + "Jupiter 大运期间适合深入修习与导师结缘。", + "Karakamsha 所在宫位提示了最适合的灵性实践方向。", + ], + } + recs.extend(extra.get(theme, [])) + return recs + + +# ═══════════════════════════════════════════════════════════════ +# 模拟数据工厂(用于测试与示例) +# ═══════════════════════════════════════════════════════════════ + +class MockDataFactory: + """生成模拟本命盘数据与技法结果,用于演示和单元测试。""" + + @staticmethod + def create_sample_chart() -> BirthChartData: + """创建一个丰富的模拟本命盘""" + return BirthChartData( + d1_houses={ + 1: {"sign": "Cancer", "lord": "Moon", "planets": ["Moon", "Jupiter"]}, + 7: {"sign": "Capricorn", "lord": "Saturn", "planets": ["Venus"]}, + 10: {"sign": "Aries", "lord": "Mars", "planets": ["Sun", "Mercury"]}, + 2: {"sign": "Leo", "lord": "Sun", "planets": []}, + 11: {"sign": "Taurus", "lord": "Venus", "planets": ["Rahu"]}, + 6: {"sign": "Sagittarius", "lord": "Jupiter", "planets": ["Saturn"]}, + 8: {"sign": "Aquarius", "lord": "Saturn", "planets": ["Ketu"]}, + 12: {"sign": "Gemini", "lord": "Mercury", "planets": ["Mars"]}, + }, + d9_houses={ + 1: {"sign": "Pisces", "lord": "Jupiter", "planets": ["Jupiter"]}, + 7: {"sign": "Virgo", "lord": "Mercury", "planets": ["Saturn", "Mars"]}, + }, + d10_houses={ + 1: {"sign": "Libra", "lord": "Venus", "planets": ["Venus", "Mercury"]}, + 10: {"sign": "Cancer", "lord": "Moon", "planets": ["Sun"]}, + }, + planets={ + "Jupiter": {"sign": "Cancer", "house": 1, "dignity": "exalted", "degree": 5.2}, + "Venus": {"sign": "Capricorn", "house": 7, "dignity": "neutral", "degree": 18.5}, + "Saturn": {"sign": "Sagittarius", "house": 6, "dignity": "neutral", "degree": 22.1}, + "Moon": {"sign": "Cancer", "house": 1, "dignity": "own", "degree": 12.0}, + "Sun": {"sign": "Aries", "house": 10, "dignity": "exalted", "degree": 8.7}, + "Mars": {"sign": "Gemini", "house": 12, "dignity": "neutral", "degree": 15.3}, + "Mercury": {"sign": "Aries", "house": 10, "dignity": "neutral", "degree": 3.1}, + "Rahu": {"sign": "Taurus", "house": 11, "dignity": "neutral", "degree": 28.0}, + "Ketu": {"sign": "Scorpio", "house": 5, "dignity": "neutral", "degree": 28.0}, + }, + current_dasha="Jupiter-Mercury", + dasha_timeline=[ + {"mahadasha": "Jupiter", "antardasha": "Mercury", "start": 2025, "end": 2028}, + {"mahadasha": "Jupiter", "antardasha": "Ketu", "start": 2028, "end": 2029}, + {"mahadasha": "Jupiter", "antardasha": "Venus", "start": 2029, "end": 2032}, + ], + yogas=[ + {"name": "Gajakesari Yoga", "planets": ["Moon", "Jupiter"], "house": 1, "type": "benefic"}, + {"name": "Viparita Raja Yoga", "planets": ["Saturn", "Ketu"], "house": 6, "type": "mixed"}, + ], + ashtakavarga={ + "total_points": 128, + "2nd_house": 28, + "11th_house": 32, + "bindus": {"Jupiter": 5, "Venus": 4, "Saturn": 3, "Sun": 5, "Moon": 5}, + }, + ) + + @classmethod + def create_marriage_techniques(cls) -> List[TechniqueResult]: + """生成婚姻主题模拟技法结果(含矛盾以演示裁决)""" + return [ + TechniqueResult( + technique="D1-7th-house", + chart="D1", + conclusion="第7宫主 Saturn 落入第6宫,与疾病宫关联,婚姻宫位受克", + sentiment="negative", + strength=StrengthLevel.MODERATE, + details={"lord": "Saturn", "house": 6, "aspect": "Mars aspects 7th"}, + ), + TechniqueResult( + technique="D9-lagna", + chart="D9", + conclusion="Navamsa 上升落入双鱼座,主星 Jupiter 庙旺,婚姻基础稳固", + sentiment="positive", + strength=StrengthLevel.STRONG, + details={"navamsa_lagna": "Pisces", "lagna_lord": "Jupiter", "dignity": "exalted"}, + ), + TechniqueResult( + technique="DK-position", + chart="D1", + conclusion="Dara Karaka Venus 落入第7宫本宫,配偶特质明显", + sentiment="positive", + strength=StrengthLevel.MODERATE, + details={"dk_planet": "Venus", "house": 7}, + ), + TechniqueResult( + technique="Venus-strength", + chart="D1", + conclusion="Venus 落入摩羯座,处于中性状态,无庙旺也无落陷", + sentiment="neutral", + strength=StrengthLevel.WEAK, + details={"sign": "Capricorn", "dignity": "neutral"}, + ), + TechniqueResult( + technique="Upapada-lagna", + chart="D1", + conclusion="Upapada 落入狮子座,主星 Sun 入庙于第10宫,配偶社会地位良好", + sentiment="positive", + strength=StrengthLevel.MODERATE, + details={"upapada_sign": "Leo", "lord": "Sun", "lord_house": 10}, + ), + ] + + @classmethod + def create_career_techniques(cls) -> List[TechniqueResult]: + return [ + TechniqueResult( + technique="D1-10th-house", + chart="D1", + conclusion="第10宫主 Mars 落入第12宫,事业能量外泄,可能涉及海外或幕后工作", + sentiment="negative", + strength=StrengthLevel.MODERATE, + details={"lord": "Mars", "house": 12}, + ), + TechniqueResult( + technique="D10-lagna", + chart="D10", + conclusion="Dashamsha 上升天秤,主星 Venus 与 Mercury 合相,事业偏向沟通协调", + sentiment="positive", + strength=StrengthLevel.STRONG, + details={"d10_lagna": "Libra", "planets": ["Venus", "Mercury"]}, + ), + TechniqueResult( + technique="Amatyakaraka-dignity", + chart="D1", + conclusion="Amatyakaraka Mercury 落入第10宫白羊,与 Sun 同宫,具备管理才能", + sentiment="positive", + strength=StrengthLevel.MODERATE, + details={"amk": "Mercury", "house": 10, "conjunct": ["Sun"]}, + ), + TechniqueResult( + technique="Raja-Yoga-10th", + chart="D1", + conclusion="第10宫形成 Raja Yoga,Sun 与 Mercury 合相(Budha-Aditya Yoga)", + sentiment="positive", + strength=StrengthLevel.STRONG, + details={"yoga": "Budha-Aditya", "house": 10}, + ), + ] + + @classmethod + def create_wealth_techniques(cls) -> List[TechniqueResult]: + return [ + TechniqueResult( + technique="Dhana-Yoga", + chart="D1", + conclusion="第2宫主 Sun 与第11宫主 Venus 无直接关联,Dhana Yoga 基础较弱", + sentiment="negative", + strength=StrengthLevel.WEAK, + details={"2nd_lord": "Sun", "11th_lord": "Venus", "aspect": "none"}, + ), + TechniqueResult( + technique="Ashtakavarga-wealth", + chart="AV", + conclusion="第11宫 Ashtakavarga 分值高达32,远超平均值,财富获取渠道多元", + sentiment="positive", + strength=StrengthLevel.STRONG, + details={"11th_house_points": 32, "average": 25}, + ), + TechniqueResult( + technique="2nd-lord", + chart="D1", + conclusion="第2宫主 Sun 入庙第10宫,正财与事业声誉挂钩", + sentiment="positive", + strength=StrengthLevel.MODERATE, + details={"lord": "Sun", "dignity": "exalted", "house": 10}, + ), + ] + + @classmethod + def create_health_techniques(cls) -> List[TechniqueResult]: + return [ + TechniqueResult( + technique="D1-6th-house", + chart="D1", + conclusion="第6宫主 Jupiter 入庙第1宫,疾病抵抗力强", + sentiment="positive", + strength=StrengthLevel.STRONG, + details={"lord": "Jupiter", "dignity": "exalted", "house": 1}, + ), + TechniqueResult( + technique="D1-8th-house", + chart="D1", + conclusion="第8宫主 Saturn 落入第6宫,形成 Viparita Raja Yoga,化险为夷", + sentiment="positive", + strength=StrengthLevel.MODERATE, + details={"lord": "Saturn", "house": 6, "yoga": "Viparita Raja"}, + ), + TechniqueResult( + technique="Arista-Yoga", + chart="D1", + conclusion="Mars 落入第12宫与第1宫 Moon-Jupiter 无直接相位,Arista 信号较弱", + sentiment="positive", + strength=StrengthLevel.WEAK, + details={"malefic": "Mars", "affected": "none"}, + ), + TechniqueResult( + technique="D30-analysis", + chart="D30", + conclusion="Trimsamsa 第1宫受土星影响,需注意骨骼与关节保养", + sentiment="negative", + strength=StrengthLevel.MODERATE, + details={"d30_lagna_affected": "Saturn", "area": "bones"}, + ), + ] + + @classmethod + def create_spirituality_techniques(cls) -> List[TechniqueResult]: + return [ + TechniqueResult( + technique="Jupiter-dignity", + chart="D1", + conclusion="Jupiter 在第1宫巨蟹座庙旺,灵性根基深厚,天然具备信仰倾向", + sentiment="positive", + strength=StrengthLevel.STRONG, + details={"planet": "Jupiter", "dignity": "exalted", "house": 1}, + ), + TechniqueResult( + technique="12th-house-spirit", + chart="D1", + conclusion="第12宫主 Mercury 落入第10宫,灵性追求与世俗事业产生张力", + sentiment="negative", + strength=StrengthLevel.MODERATE, + details={"lord": "Mercury", "house": 10, "tension": "material vs spiritual"}, + ), + TechniqueResult( + technique="D20-analysis", + chart="D20", + conclusion="Vimsamsa 第5宫有 Jupiter 加持,具备教学与传承灵性知识的禀赋", + sentiment="positive", + strength=StrengthLevel.STRONG, + details={"d20_5th": "Jupiter", "ability": "teaching"}, + ), + TechniqueResult( + technique="Karakamsha", + chart="D1", + conclusion="Karakamsha 落入第9宫,人生深层使命与 Dharma/哲学探索相关", + sentiment="positive", + strength=StrengthLevel.MODERATE, + details={"karakamsha_house": 9}, + ), + ] + + +# ═══════════════════════════════════════════════════════════════ +# 演示与测试入口 +# ═══════════════════════════════════════════════════════════════ + +def demo(): + """ + 完整演示:从模拟数据到主题化报告的端到端流程。 + """ + print("=" * 70) + print("Jyotish 主题化解盘编排器 —— 演示运行") + print("=" * 70) + + # 1. 准备模拟本命盘 + chart = MockDataFactory.create_sample_chart() + print(f"\n[1] 已加载模拟本命盘") + print(f" 当前大运: {chart.current_dasha}") + print(f" 行星数量: {len(chart.planets)}") + + # 2. 初始化编排器 + orchestrator = ThematicReportOrchestrator(chart) + + # 3. 注入各主题技法结果 + factory = MockDataFactory() + orchestrator.add_techniques(ThemeName.MARRIAGE, factory.create_marriage_techniques()) + orchestrator.add_techniques(ThemeName.CAREER, factory.create_career_techniques()) + orchestrator.add_techniques(ThemeName.WEALTH, factory.create_wealth_techniques()) + orchestrator.add_techniques(ThemeName.HEALTH, factory.create_health_techniques()) + orchestrator.add_techniques(ThemeName.SPIRITUALITY, factory.create_spirituality_techniques()) + + print(f"\n[2] 已注入技法结果:") + for theme in ThemeName: + count = len(orchestrator._data[theme]) + print(f" - {theme.value:15s} ({THEME_TECHNIQUE_MAP[theme]['name_zh']}) : {count} 条") + + # 4. 批量生成报告 + print(f"\n[3] 生成分主题报告...") + reports = orchestrator.generate_all_reports() + + # 5. 逐主题展示 + for theme, report in reports.items(): + print(f"\n{'─' * 70}") + print(f"主题: {report.theme.value.upper()} ({THEME_TECHNIQUE_MAP[theme]['name_zh']})") + print(f"{'─' * 70}") + print(f"【一句话总结】\n {report.summary}") + print(f"\n【整体强度】\n {report.strength.value}") + print(f"\n【叙事段落】\n {report.narrative}") + + if report.timing: + t = report.timing + print(f"\n【时间锚定】") + print(f" 大运周期: {t.dasha_period}") + print(f" 时间范围: {t.start_year}-{t.end_year}") + print(f" 激活描述: {t.activation_description}") + + if report.conflicts: + print(f"\n【矛盾裁决】") + for idx, c in enumerate(report.conflicts, 1): + print(f" [{idx}] {c.technique_a} vs {c.technique_b}") + print(f" 裁决: {c.resolution}") + print(f" 依据: {c.reasoning}") + + print(f"\n【建议】") + for idx, rec in enumerate(report.recommendations, 1): + print(f" {idx}. {rec}") + + print(f"\n【证据链 ({len(report.evidence)} 条)】") + for ev in report.evidence: + icon = {"positive": "+", "negative": "-", "neutral": "o"}.get(ev["sentiment"], "?") + print(f" [{icon}] [{ev['chart']:4s}] {ev['technique']:20s} — {ev['conclusion']}") + + # 6. 输出完整 JSON(以婚姻为例) + print(f"\n{'=' * 70}") + print("[4] 结构化 JSON 输出示例(婚姻主题)") + print(f"{'=' * 70}") + marriage_json = reports[ThemeName.MARRIAGE].to_json(indent=2, ensure_ascii=False) + print(marriage_json) + + print(f"\n{'=' * 70}") + print("演示完成。") + print(f"{'=' * 70}") + + return reports + + +def unit_test(): + """ + 简单的断言测试,验证核心引擎逻辑。 + """ + print("\n运行单元测试...") + + # 测试1: 矛盾裁决 —— D9 应优先于 D1 + engine = CrossValidationEngine(ThemeName.MARRIAGE) + engine.add_technique(TechniqueResult( + technique="D1-test", chart="D1", conclusion="D1 says bad", + sentiment="negative", strength=StrengthLevel.STRONG, + )) + engine.add_technique(TechniqueResult( + technique="D9-test", chart="D9", conclusion="D9 says good", + sentiment="positive", strength=StrengthLevel.STRONG, + )) + conflicts = engine.detect_conflicts() + assert len(conflicts) == 1, "应检测到 1 条矛盾" + assert conflicts[0].winner == "D9-test", "D9 应获胜" + print(" [PASS] 矛盾裁决: D9 > D1") + + # 测试2: 强度计算 + engine2 = CrossValidationEngine(ThemeName.CAREER) + engine2.add_technique(TechniqueResult( + technique="t1", chart="D1", conclusion="good", + sentiment="positive", strength=StrengthLevel.STRONG, + )) + engine2.add_technique(TechniqueResult( + technique="t2", chart="D1", conclusion="good2", + sentiment="positive", strength=StrengthLevel.STRONG, + )) + engine2.add_technique(TechniqueResult( + technique="t3", chart="D1", conclusion="bad", + sentiment="negative", strength=StrengthLevel.WEAK, + )) + strength = engine2.compute_overall_strength() + assert strength == StrengthLevel.MODERATE, f"期望 moderate,得到 {strength}" + print(" [PASS] 强度计算: 多数 positive → moderate(因存在 negative)") + + # 测试3: 叙事生成非空 + gen = NarrativeGenerator(ThemeName.MARRIAGE) + narrative = gen.generate( + techniques=[ + TechniqueResult("D1-7th", "D1", "7th lord ok", "neutral", StrengthLevel.MODERATE), + TechniqueResult("D9-lag", "D9", "D9 good", "positive", StrengthLevel.STRONG), + ], + resolutions=[], + timing=TimingAnchor("Jupiter-Venus", 2027, 2030, "test", True), + strength=StrengthLevel.STRONG, + ) + assert len(narrative) > 20, "叙事段落应有一定长度" + print(" [PASS] 叙事生成: 段落非空") + + print("所有单元测试通过。") + + +if __name__ == "__main__": + reports = demo() + unit_test() diff --git a/scripts/yogini_dasha.py b/scripts/yogini_dasha.py new file mode 100644 index 00000000..1429c781 --- /dev/null +++ b/scripts/yogini_dasha.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +yogini_dasha.py — Yogini Dasha System (36-Year Cycle) + +The Yogini Dasha is a Nakshatra-based dasha system with a total cycle of 36 years. +It consists of 8 Yoginis (female planetary deities), each ruling a specific number +of years. The sequence is fixed and cycles continuously. + +The 8 Yoginis, their year allotments, and associated planets: + + Order | Yogini | Years | Planet + ------|-----------|-------|-------- + 1 | Maya | 1 | Sun + 2 | Bhadrika | 2 | Moon + 3 | Jaya | 3 | Mars + 4 | Rati | 4 | Mercury + 5 | Mangala | 5 | Jupiter + 6 | Pingala | 6 | Saturn + 7 | Dhanya | 7 | Venus + 8 | Bhramari | 8 | Rahu + +Total: 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 = 36 years. + +Starting Yogini: + The starting Yogini is determined by the Ascendant (Lagna) Rashi. + Per standard texts: + - Aries / Leo / Sagittarius / Gemini / Libra / Aquarius + → start from Maya (1) + - Taurus / Virgo / Capricorn / Cancer / Scorpio / Pisces + → start from Bhadrika (2) + + Alternative (more common) rule based on Lagna degree modulo 8: + Lagna Nakshatra Pada determines the starting Yogini. + For simplicity, this implementation uses the Ascendant Rashi rule above. + +Reference: Various classical and modern Jyotish texts on Yogini Dasha. +""" + +from datetime import datetime, timedelta + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +# 12 Rashis (Zodiac signs) +RASHIS = [ + "Aries", "Taurus", "Gemini", "Cancer", + "Leo", "Virgo", "Libra", "Scorpio", + "Sagittarius", "Capricorn", "Aquarius", "Pisces" +] + +# Yogini sequence: fixed order, never changes +YOGINI_SEQUENCE = [ + {"name": "Maya", "years": 1, "planet": "Sun"}, + {"name": "Bhadrika", "years": 2, "planet": "Moon"}, + {"name": "Jaya", "years": 3, "planet": "Mars"}, + {"name": "Rati", "years": 4, "planet": "Mercury"}, + {"name": "Mangala", "years": 5, "planet": "Jupiter"}, + {"name": "Pingala", "years": 6, "planet": "Saturn"}, + {"name": "Dhanya", "years": 7, "planet": "Venus"}, + {"name": "Bhramari", "years": 8, "planet": "Rahu"}, +] + +TOTAL_CYCLE = 36 # years + +# Lagna Rashi to starting Yogini index mapping +# Odd Rashis (0,4,8,2,6,10) → start from Maya (0) +# Even Rashis (1,5,9,3,7,11) → start from Bhadrika (1) +def _get_start_yogini_index(lagna_rashi_index: int) -> int: + """Return starting Yogini index based on Ascendant Rashi.""" + if lagna_rashi_index % 2 == 0: + return 0 # Maya + else: + return 1 # Bhadrika + + +# --------------------------------------------------------------------------- +# Core Functions +# --------------------------------------------------------------------------- + +def _build_major_periods(start_idx: int, birth_date: datetime) -> list: + """ + Build the full list of major Yogini periods. + + Returns a list of dicts with keys: yogini, planet, years, start_date, end_date. + """ + periods = [] + seq_len = len(YOGINI_SEQUENCE) + current_date = birth_date + + for i in range(seq_len): + idx = (start_idx + i) % seq_len + yogini = YOGINI_SEQUENCE[idx] + end_date = current_date + timedelta(days=yogini["years"] * 365.25) + periods.append({ + "yogini": yogini["name"], + "planet": yogini["planet"], + "years": yogini["years"], + "start_date": current_date.isoformat(), + "end_date": end_date.isoformat(), + }) + current_date = end_date + + return periods + + +def calculate_yogini_dasha(birth_info: dict) -> dict: + """ + Calculate Yogini Dasha for a native. + + Args: + birth_info: dict containing: + - "lagna_rashi_index": int (0 = Aries, ..., 11 = Pisces) + - "birth_datetime": datetime object or ISO-format string (optional) + + Returns: + dict with keys: + - "major": list of major period dicts + - "current": dict — the currently running major period + - "total_cycle": int — 36 + - "starting_yogini": str — first Yogini name + - "starting_planet": str — planet of first Yogini + """ + lagna_idx = birth_info.get("lagna_rashi_index", 0) + birth_dt = birth_info.get("birth_datetime") + + if isinstance(birth_dt, str): + birth_dt = datetime.fromisoformat(birth_dt) + if birth_dt is None: + birth_dt = datetime.now() + + start_idx = _get_start_yogini_index(lagna_idx) + starting_yogini = YOGINI_SEQUENCE[start_idx]["name"] + starting_planet = YOGINI_SEQUENCE[start_idx]["planet"] + + major_periods = _build_major_periods(start_idx, birth_dt) + + # Determine current period + now = datetime.now() + current_period = None + for p in major_periods: + p_start = datetime.fromisoformat(p["start_date"]) + p_end = datetime.fromisoformat(p["end_date"]) + if p_start <= now < p_end: + current_period = p.copy() + current_period["elapsed_years"] = (now - p_start).days / 365.25 + current_period["remaining_years"] = (p_end - now).days / 365.25 + break + + return { + "major": major_periods, + "current": current_period, + "total_cycle": TOTAL_CYCLE, + "starting_yogini": starting_yogini, + "starting_planet": starting_planet, + } + + +# --------------------------------------------------------------------------- +# Self-test +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + # Example 1: Ascendant in Aries (odd sign) → starts from Maya + test_birth_1 = { + "lagna_rashi_index": 0, # Aries + "birth_datetime": datetime(1990, 6, 15, 10, 30), + } + result_1 = calculate_yogini_dasha(test_birth_1) + print("=" * 60) + print("Test 1: Ascendant in Aries (odd sign)") + print("=" * 60) + print(f"Starting Yogini : {result_1['starting_yogini']} ({result_1['starting_planet']})") + print(f"Total Cycle : {result_1['total_cycle']} years") + print("Major Periods:") + for p in result_1["major"]: + print(f" {p['yogini']:10s} | {p['planet']:8s} | {p['years']} years | {p['start_date'][:10]} → {p['end_date'][:10]}") + print(f"Current Period : {result_1['current']}") + print() + + # Example 2: Ascendant in Taurus (even sign) → starts from Bhadrika + test_birth_2 = { + "lagna_rashi_index": 1, # Taurus + "birth_datetime": datetime(1985, 3, 20, 8, 0), + } + result_2 = calculate_yogini_dasha(test_birth_2) + print("=" * 60) + print("Test 2: Ascendant in Taurus (even sign)") + print("=" * 60) + print(f"Starting Yogini : {result_2['starting_yogini']} ({result_2['starting_planet']})") + print(f"Total Cycle : {result_2['total_cycle']} years") + for p in result_2["major"]: + print(f" {p['yogini']:10s} | {p['planet']:8s} | {p['years']} years | {p['start_date'][:10]} → {p['end_date'][:10]}") + print(f"Current Period : {result_2['current']}") + print() + + # Example 3: Ascendant in Scorpio (even sign) + test_birth_3 = { + "lagna_rashi_index": 7, # Scorpio + "birth_datetime": datetime(2000, 1, 1, 0, 0), + } + result_3 = calculate_yogini_dasha(test_birth_3) + print("=" * 60) + print("Test 3: Ascendant in Scorpio (even sign)") + print("=" * 60) + print(f"Starting Yogini : {result_3['starting_yogini']} ({result_3['starting_planet']})") + for p in result_3["major"]: + print(f" {p['yogini']:10s} | {p['planet']:8s} | {p['years']} years | {p['start_date'][:10]} → {p['end_date'][:10]}")