""" muhurta.py v6.0.21 — Muhurta(择时占星)核心计算模块 Muhurta 是印度占星的择时系统,核心是 Panchanga 五要素: 1. Tithi(月相日) — 月亮与太阳之间的角度 / 12° 2. Vara(周日) — 星期对应的行星守护 3. Nakshatra(星宿)— 月亮所在星宿 4. Yoga(瑜伽) — 太阳 + 月亮黄经之和 / (360/27) 5. Karana(半日) — 每半个 Tithi 为一 Karana 每个元素都有吉(Subha)/ 凶(Asubha)/ 中性(Mixed)属性, 组合评分决定特定时间段是否适合某类活动。 """ from __future__ import annotations from typing import Dict, List, Optional, Tuple from datetime import datetime, timedelta import calendar import math from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, sidereal_flags # ── Vara(周日行星)────────────────────────────────────────────────── VARA_LORDS = { 0: ('Sunday', 'Sun', 'asubha'), # 周日 1: ('Monday', 'Moon', 'subha'), 2: ('Tuesday', 'Mars', 'asubha'), 3: ('Wednesday', 'Mercury', 'mixed'), 4: ('Thursday', 'Jupiter', 'subha'), 5: ('Friday', 'Venus', 'subha'), 6: ('Saturday', 'Saturn', 'asubha'), } # Hora(每小时行星)— 从日出起每小时依次排列 # 顺序: Sun, Venus, Mercury, Moon, Saturn, Jupiter, Mars HORA_ORDER = ['Sun', 'Venus', 'Mercury', 'Moon', 'Saturn', 'Jupiter', 'Mars'] # 每日起始 Hora = Vara Lord 在 HORA_ORDER 中的位置 VARA_START_IDX = { 'Sun': 0, 'Venus': 1, 'Mercury': 2, 'Moon': 3, 'Saturn': 4, 'Jupiter': 5, 'Mars': 6 } # ── Tithi(月相日)──────────────────────────────────────────────────── # 1-15 = Shukla Paksha, 16-30 = Krishna Paksha TITHI_NAMES = [ '', 'Pratipada', 'Dwitiya', 'Tritiya', 'Chaturthi', 'Panchami', 'Shashthi', 'Saptami', 'Ashtami', 'Navami', 'Dashami', 'Ekadashi', 'Dwadashi', 'Trayodashi', 'Chaturdashi', 'Purnima/Amavasya' ] # 吉凶:1=subha, 0=asubha, 0.5=mixed TITHI_QUALITY = { 1: 'subha', 2: 'subha', 3: 'subha', 4: 'asubha', 5: 'subha', 6: 'mixed', 7: 'subha', 8: 'asubha', 9: 'mixed', 10: 'subha', 11: 'subha', 12: 'subha', 13: 'asubha', 14: 'asubha', 15: 'subha', # Purnima = Shukla 15(满月) 16: 'subha', # Pratipada Krishna 17: 'subha', 18: 'subha', 19: 'asubha', 20: 'subha', 21: 'mixed', 22: 'subha', 23: 'asubha', 24: 'mixed', 25: 'subha', 26: 'subha', 27: 'subha', 28: 'asubha', 29: 'asubha', 30: 'asubha' # Amavasya(新月) } # ── Nakshatra(27 星宿)────────────────────────────────────────────── 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', 'Dhanishtha', 'Shatabhisha', 'Purva Bhadrapada', 'Uttara Bhadrapada', 'Revati' ] # Nakshatra 吉凶分类(Muhurta 视角) NAKSHATRA_TYPE = { 'Ashwini': 'laghu', # 轻快 → 手术、旅行 'Bharani': 'ugra', # 凶猛 → 不利开始 'Krittika': 'mixed', # 混合 'Rohini': 'sthira', # 固定/吉 → 种植、建筑 'Mrigashira': 'mridu', # 柔和 → 艺术、爱情 'Ardra': 'tikshna', # 尖锐 → 不宜重要事 'Punarvasu': 'chara', # 动态 → 旅行 'Pushya': 'laghu', # 最吉 → 几乎万能 'Ashlesha': 'tikshna', # 蛇宿 → 不宜 'Magha': 'ugra', # 凶 → 不宜 'Purva Phalguni': 'ugra',# 凶 'Uttara Phalguni': 'sthira', # 吉 'Hasta': 'laghu', # 轻快/吉 'Chitra': 'mridu', # 柔和 'Swati': 'chara', # 动态 'Vishakha': 'mixed', # 混合 'Anuradha': 'mridu', # 柔和 'Jyeshtha': 'tikshna', # 尖锐 'Mula': 'tikshna', # 最凶 → 不宜开始 'Purva Ashadha': 'ugra', # 凶 'Uttara Ashadha': 'sthira', # 吉 'Shravana': 'mridu', # 柔和/吉 'Dhanishtha': 'chara', # 动态 'Shatabhisha': 'chara', # 动态 'Purva Bhadrapada': 'ugra', # 凶 'Uttara Bhadrapada': 'sthira',# 吉 'Revati': 'mridu', # 柔和 } NAKSHATRA_QUALITY = { 'laghu': 'subha', 'sthira': 'subha', 'mridu': 'subha', 'chara': 'mixed', 'mixed': 'mixed', 'ugra': 'asubha', 'tikshna': 'asubha' } # ── Yoga(27 瑜伽)──────────────────────────────────────────────────── YOGA_NAMES = [ 'Vishkambha', 'Priti', 'Ayushman', 'Saubhagya', 'Shobhana', 'Atiganda', 'Sukarma', 'Dhriti', 'Shula', 'Ganda', 'Vriddhi', 'Dhruva', 'Vyaghata', 'Harshana', 'Vajra', 'Siddhi', 'Vyatipata', 'Variyana', 'Parigha', 'Shiva', 'Siddha', 'Sadhya', 'Shubha', 'Shukla', 'Brahma', 'Aindra', 'Vaidhriti' ] YOGA_QUALITY = { 'Vishkambha': 'asubha', 'Priti': 'subha', 'Ayushman': 'subha', 'Saubhagya': 'subha', 'Shobhana': 'subha', 'Atiganda': 'asubha', 'Sukarma': 'subha', 'Dhriti': 'subha', 'Shula': 'asubha', 'Ganda': 'asubha', 'Vriddhi': 'subha', 'Dhruva': 'subha', 'Vyaghata': 'asubha', 'Harshana': 'subha', 'Vajra': 'asubha', 'Siddhi': 'subha', 'Vyatipata': 'asubha', 'Variyana': 'subha', 'Parigha': 'asubha', 'Shiva': 'subha', 'Siddha': 'subha', 'Sadhya': 'subha', 'Shubha': 'subha', 'Shukla': 'subha', 'Brahma': 'subha', 'Aindra': 'subha', 'Vaidhriti': 'asubha' } # ── Karana(11 迦那)──────────────────────────────────────────────── # 7 个 movable + 4 个 fixed KARANA_NAMES = [ 'Bava', 'Balava', 'Kaulava', 'Taitila', 'Garija', 'Vanija', 'Vishti', # 7 movable(循环8次) 'Shakuni', 'Chatushpada', 'Naga', 'Kimstughna' # 4 fixed ] KARANA_QUALITY = { 'Bava': 'subha', 'Balava': 'subha', 'Kaulava': 'subha', 'Taitila': 'subha', 'Garija': 'subha', 'Vanija': 'subha', 'Vishti': 'asubha', # Bhadra(Vishti)最凶 'Shakuni': 'mixed', 'Chatushpada': 'mixed', 'Naga': 'asubha', 'Kimstughna': 'subha' } SIGNS = [ 'Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces', ] PANCHAKA_BAD_REMAINDERS = { 1: 'mrityu', 2: 'agni', 4: 'raja', 6: 'chora', 8: 'roga', } PANCHAKA_GOOD_REMAINDERS = {0, 3, 5, 7} PANCHAKA_ACTIVITY_AVOIDANCE = { 'business': {'raja'}, 'education': {'raja'}, 'travel': {'chora'}, 'marriage': {'roga', 'mrityu'}, 'medical': {'roga', 'mrityu'}, 'house_building': {'raja', 'agni'}, 'finance_lending_borrowing': {'raja'}, 'finance_lending': {'raja'}, 'finance_receiving': set(), } RAMAN_MONEY_LENDING_AVOID_NAKSHATRAS = { 'Krittika', 'Magha', 'Mula', 'Shatabhisha', 'Uttara Phalguni', 'Uttara Ashadha', 'Uttara Bhadrapada', 'Punarvasu', } RAMAN_JOURNEY_GOOD_NAKSHATRAS = {'Anuradha', 'Mrigashira', 'Hasta'} TARA_SEQUENCE = [ 'janma', 'sampat', 'vipat', 'kshema', 'pratyak', 'sadhana', 'naidhana', 'mitra', 'parama_mitra', ] TARA_MEANINGS = { 'janma': 'danger_to_body', 'sampat': 'wealth_and_prosperity', 'vipat': 'danger_loss_accident', 'kshema': 'prosperity', 'pratyak': 'obstacles', 'sadhana': 'realisation_of_ambitions', 'naidhana': 'danger', 'mitra': 'good', 'parama_mitra': 'very_favourable', } TARA_NEGATIVE_GHATIS = { 'janma': 7, 'vipat': 3, 'pratyak': 8, 'naidhana': 6, } TARA_HIGH_STAKES_AVOID = {'vipat', 'naidhana'} JANMA_TARA_FAVOURABLE_ACTIVITIES = { 'nuptials', 'sacrifice', 'first_feeding', 'agriculture', 'upanayanam', 'coronation', 'buying_land', 'education', } JANMA_TARA_UNFAVOURABLE_ACTIVITIES = { 'war', 'sexual_union', 'shaving', 'medical', 'travel', 'marriage', } CHANDRABALA_BAD_HOUSES = {6, 8, 12} TITHI_GROUPS = { 'nanda': {1, 6, 11}, 'bhadra': {2, 7, 12}, 'jaya': {3, 8, 13}, 'riktha': {4, 9, 14}, 'poorna': {5, 10, 15}, } SIDDHA_YOGA_WEEKDAY_RULES = { 0: {'tithis': {1, 4, 6, 7, 12}, 'nakshatras': {'Pushya', 'Hasta', 'Uttara Phalguni', 'Uttara Ashadha', 'Mula', 'Shravana', 'Uttara Bhadrapada'}}, 1: {'tithis': {2, 7, 12}, 'nakshatras': {'Rohini', 'Mrigashira', 'Punarvasu', 'Chitra', 'Shravana', 'Shatabhisha', 'Dhanishtha', 'Purva Bhadrapada'}}, 2: {'tithis': None, 'nakshatras': {'Ashwini', 'Mrigashira', 'Chitra', 'Anuradha', 'Mula', 'Uttara Phalguni', 'Dhanishtha', 'Purva Bhadrapada'}}, 3: {'groups': {'bhadra', 'jaya'}, 'nakshatras': {'Rohini', 'Mrigashira', 'Ardra', 'Uttara Phalguni', 'Uttara Ashadha', 'Anuradha'}}, 4: {'tithis': {4, 5, 7, 9, 13, 14}, 'nakshatras': {'Magha', 'Pushya', 'Punarvasu', 'Swati', 'Purva Ashadha', 'Purva Bhadrapada', 'Revati', 'Ashwini'}}, 5: {'groups': {'nanda', 'bhadra'}, 'nakshatras': {'Ashwini', 'Bharani', 'Ardra', 'Uttara Phalguni', 'Chitra', 'Swati', 'Purva Ashadha', 'Revati'}}, 6: {'groups': {'bhadra', 'riktha'}, 'nakshatras': {'Swati', 'Rohini', 'Vishakha', 'Anuradha', 'Dhanishtha', 'Shatabhisha'}}, } SIDDHA_YOGA_TITHI_GROUP_RULES = { 5: {'weekday': 5, 'group': 'nanda'}, 3: {'weekday': 3, 'group': 'bhadra'}, 2: {'weekday': 2, 'group': 'jaya'}, 6: {'weekday': 6, 'group': 'riktha'}, 4: {'weekday': 4, 'group': 'poorna'}, } AMRITA_SIDDHA_NAKSHATRA_BY_WEEKDAY = { 0: 'Hasta', 1: 'Shravana', 2: 'Ashwini', 3: 'Anuradha', 4: 'Pushya', 5: 'Revati', 6: 'Rohini', } DURMUHURTHA_DAY_NAMES = [ 'Rudra', 'Ahi', 'Mitra', 'Pitri', 'Vasu', 'Vara', 'Vishwedeva', 'Vidhi', 'Sathamukhi', 'Puruhuta', 'Vahni', 'Naktanchara', 'Varuna', 'Aryama', 'Bhaga', ] DURMUHURTHA_NIGHT_NAMES = [ 'Girisa', 'Ajipada', 'Ahirbudhnya', 'Pusha', 'Aswi', 'Yama', 'Agni', 'Vidhatru', 'Chanda', 'Aditi', 'Jeeva', 'Vishnu', 'Yumigadyuti', 'Thyasthur', 'Samdram', ] DURMUHURTHA_GENERAL_BAD_DAY = {1, 2, 4, 10, 11, 12, 15} DURMUHURTHA_GENERAL_BAD_NIGHT = {1, 2, 6, 7} DURMUHURTHA_WEEKDAY_BAD_DAY = { 0: {14}, # Sunday 1: {8, 12}, # Monday 2: {4, 11}, # Tuesday 3: {8}, # Wednesday, Abhijit/Vidhi 4: {12, 13}, # Thursday 5: {4, 8}, # Friday 6: {1, 2}, # Saturday } BENEFIC_PLANETS = {'Jupiter', 'Venus', 'Mercury'} MALEFIC_PLANETS = {'Sun', 'Mars', 'Saturn', 'Rahu', 'Ketu'} GRAHA_DRISHTI_OFFSETS = { 'Sun': {7}, 'Moon': {7}, 'Mercury': {7}, 'Venus': {7}, 'Mars': {4, 7, 8}, 'Jupiter': {5, 7, 9}, 'Saturn': {3, 7, 10}, } EXALTATION_SIGNS = { 'Sun': 'Aries', 'Moon': 'Taurus', 'Mars': 'Capricorn', 'Mercury': 'Virgo', 'Jupiter': 'Cancer', 'Venus': 'Pisces', 'Saturn': 'Libra', } SIGN_LORDS = { 'Aries': 'Mars', 'Taurus': 'Venus', 'Gemini': 'Mercury', 'Cancer': 'Moon', 'Leo': 'Sun', 'Virgo': 'Mercury', 'Libra': 'Venus', 'Scorpio': 'Mars', 'Sagittarius': 'Jupiter', 'Capricorn': 'Saturn', 'Aquarius': 'Saturn', 'Pisces': 'Jupiter', } OWN_SIGNS = { 'Sun': {'Leo'}, 'Moon': {'Cancer'}, 'Mars': {'Aries', 'Scorpio'}, 'Mercury': {'Gemini', 'Virgo'}, 'Jupiter': {'Sagittarius', 'Pisces'}, 'Venus': {'Taurus', 'Libra'}, 'Saturn': {'Capricorn', 'Aquarius'}, } RAHU_KALA_SEGMENTS = { 0: 8, # Sunday 1: 2, # Monday 2: 7, # Tuesday 3: 5, # Wednesday 4: 6, # Thursday 5: 4, # Friday 6: 3, # Saturday } YAMAGANDA_SEGMENTS = { 0: 5, 1: 4, 2: 3, 3: 2, 4: 1, 5: 7, 6: 6, } GULIKA_SEGMENTS = { 0: 7, 1: 6, 2: 5, 3: 4, 4: 3, 5: 2, 6: 1, } TITHI_BOUNDARY_DEGREES = 12.0 NAKSHATRA_BOUNDARY_DEGREES = 360.0 / 27.0 YOGA_BOUNDARY_DEGREES = 360.0 / 27.0 CHOGHADIYA_DAY_SEQUENCE = { 0: ['Udveg', 'Chal', 'Labh', 'Amrit', 'Kal', 'Shubh', 'Rog', 'Udveg'], 1: ['Amrit', 'Kal', 'Shubh', 'Rog', 'Udveg', 'Chal', 'Labh', 'Amrit'], 2: ['Rog', 'Udveg', 'Chal', 'Labh', 'Amrit', 'Kal', 'Shubh', 'Rog'], 3: ['Labh', 'Amrit', 'Kal', 'Shubh', 'Rog', 'Udveg', 'Chal', 'Labh'], 4: ['Shubh', 'Rog', 'Udveg', 'Chal', 'Labh', 'Amrit', 'Kal', 'Shubh'], 5: ['Chal', 'Labh', 'Amrit', 'Kal', 'Shubh', 'Rog', 'Udveg', 'Chal'], 6: ['Kal', 'Shubh', 'Rog', 'Udveg', 'Chal', 'Labh', 'Amrit', 'Kal'], } CHOGHADIYA_NIGHT_SEQUENCE = { 0: ['Shubh', 'Amrit', 'Chal', 'Rog', 'Kal', 'Labh', 'Udveg', 'Shubh'], 1: ['Chal', 'Rog', 'Kal', 'Labh', 'Udveg', 'Shubh', 'Amrit', 'Chal'], 2: ['Kal', 'Labh', 'Udveg', 'Shubh', 'Amrit', 'Chal', 'Rog', 'Kal'], 3: ['Udveg', 'Shubh', 'Amrit', 'Chal', 'Rog', 'Kal', 'Labh', 'Udveg'], 4: ['Amrit', 'Chal', 'Rog', 'Kal', 'Labh', 'Udveg', 'Shubh', 'Amrit'], 5: ['Rog', 'Kal', 'Labh', 'Udveg', 'Shubh', 'Amrit', 'Chal', 'Rog'], 6: ['Labh', 'Udveg', 'Shubh', 'Amrit', 'Chal', 'Rog', 'Kal', 'Labh'], } CHOGHADIYA_QUALITY = { 'Amrit': 'auspicious', 'Shubh': 'auspicious', 'Labh': 'auspicious', 'Chal': 'usable', 'Udveg': 'inauspicious', 'Kal': 'inauspicious', 'Rog': 'inauspicious', } # ── 核心计算函数 ───────────────────────────────────────────────────── def calc_tithi(sun_lon: float, moon_lon: float) -> Dict: """计算 Tithi(月相日)。 sun_lon, moon_lon: 恒星黄经(由调用方 ayanamsa 决定,0-360) 返回: tithi_num(1-30), paksha, name, quality """ diff = (moon_lon - sun_lon) % 360 tithi_num = int(diff / 12) + 1 # 1-30 if tithi_num > 30: tithi_num = 30 paksha = 'Shukla' if tithi_num <= 15 else 'Krishna' tithi_in_paksha = tithi_num if tithi_num <= 15 else tithi_num - 15 name = TITHI_NAMES[min(tithi_in_paksha, 15)] if tithi_num == 15: name = 'Purnima' elif tithi_num == 30: name = 'Amavasya' quality = TITHI_QUALITY.get(tithi_num, 'mixed') return { 'tithi_num': tithi_num, 'paksha': paksha, 'tithi_in_paksha': tithi_in_paksha, 'name': name, 'full_name': f'{paksha} {name}', 'quality': quality, 'moon_sun_diff': round(diff, 2) } def calc_nakshatra_from_lon(lon: float) -> Dict: """从黄经计算星宿。""" lon = lon % 360 idx = int(lon / (360 / 27)) pada = int((lon % (360 / 27)) / (360 / 108)) + 1 name = NAKSHATRAS[idx] ntype = NAKSHATRA_TYPE.get(name, 'mixed') quality = NAKSHATRA_QUALITY.get(ntype, 'mixed') return { 'nakshatra': name, 'nakshatra_idx': idx, 'pada': pada, 'type': ntype, 'quality': quality, 'moon_lon': round(lon, 2) } def calc_yoga(sun_lon: float, moon_lon: float) -> Dict: """计算 Yoga(日月之和的 27 分之一)。""" total = (sun_lon + moon_lon) % 360 idx = int(total / (360 / 27)) if idx >= 27: idx = 26 name = YOGA_NAMES[idx] quality = YOGA_QUALITY.get(name, 'mixed') return { 'yoga': name, 'yoga_idx': idx, 'quality': quality, 'sun_moon_sum': round(total, 2) } def calc_karana(sun_lon: float, moon_lon: float) -> Dict: """计算 Karana(半 Tithi)。 Karana 序列: - Kimstughna(fixed, 只在 Krishna 30 Tithi 前半) - 7 movable karanas × 8 = 56 - Shakuni/Chatushpada/Naga/Kimstughna(fixed, 只在最后) 共 60 个 half-tithis """ diff = (moon_lon - sun_lon) % 360 half_tithi = diff / 6 # 0-60 # Karana 编号(0-59) k_num = int(half_tithi) if k_num == 0: name = 'Kimstughna' # Fixed, first half of Shukla 1 elif 1 <= k_num <= 56: idx = (k_num - 1) % 7 name = KARANA_NAMES[idx] elif k_num == 57: name = 'Shakuni' elif k_num == 58: name = 'Chatushpada' elif k_num == 59: name = 'Naga' else: name = 'Kimstughna' quality = KARANA_QUALITY.get(name, 'mixed') return { 'karana': name, 'karana_num': k_num, 'quality': quality, 'is_vishti': name == 'Vishti' # Vishti = Bhadra,最凶 } def calc_vara(weekday: int) -> Dict: """计算 Vara(weekday: 0=Sun, 1=Mon, ..., 6=Sat)。""" info = VARA_LORDS.get(weekday % 7, ('Unknown', 'Unknown', 'mixed')) return { 'vara': info[0], 'vara_lord': info[1], 'quality': info[2], 'weekday_idx': weekday % 7 } def _coerce_1_based_index(value: object, names: List[str], field_name: str) -> int: if isinstance(value, str): stripped = value.strip() if stripped in names: return names.index(stripped) + 1 try: value = int(stripped) except ValueError as exc: raise ValueError(f'{field_name} must be a known name or 1-based index') from exc if isinstance(value, (int, float)): idx = int(value) if 0 <= idx < len(names): return idx + 1 if 1 <= idx <= len(names): return idx raise ValueError(f'{field_name} must be a known name, 0-based index, or 1-based index') def calc_panchaka( tithi_num: int, weekday: int, nakshatra: object, lagna: object, activity: Optional[str] = None, ) -> Dict: """Calculate Raman-style Panchaka remainder and activity-specific avoidance. Formula source packet: Raman Muhurtha page rule p16-p17. Terms are counted 1-based: tithi, weekday, nakshatra, and lagna; divide by 9. """ tithi_index = int(tithi_num) if not (1 <= tithi_index <= 30): raise ValueError('tithi_num must be 1..30') weekday_index = int(weekday) % 7 + 1 nakshatra_index = _coerce_1_based_index(nakshatra, NAKSHATRAS, 'nakshatra') lagna_index = _coerce_1_based_index(lagna, SIGNS, 'lagna') total = tithi_index + weekday_index + nakshatra_index + lagna_index remainder = total % 9 dosha = PANCHAKA_BAD_REMAINDERS.get(remainder) quality = 'good' if remainder in PANCHAKA_GOOD_REMAINDERS else 'bad' avoided_for_activity = False if activity: avoided_for_activity = bool(dosha and dosha in PANCHAKA_ACTIVITY_AVOIDANCE.get(activity, set())) return { 'formula_profile': 'raman_muhurtha_p16_p17', 'terms': { 'tithi_number': tithi_index, 'weekday_number': weekday_index, 'nakshatra_number': nakshatra_index, 'lagna_number': lagna_index, }, 'sum': total, 'divisor': 9, 'remainder': remainder, 'dosha': dosha, 'quality': quality, 'activity': activity, 'avoided_for_activity': avoided_for_activity, 'source_rule_ids': [ 'raman_muhurtha_panchaka_formula', 'raman_muhurtha_activity_panchaka_exceptions', ], } def build_tarabala_chandrabala_input_contract( *, birth_nakshatra: Optional[object] = None, current_nakshatra: Optional[object] = None, birth_moon_sign: Optional[object] = None, current_moon_sign: Optional[object] = None, ) -> Dict: """Validate native-dependent inputs needed by Tarabala and Chandrabala.""" required = { 'birth_nakshatra': birth_nakshatra, 'current_nakshatra': current_nakshatra, 'birth_moon_sign': birth_moon_sign, 'current_moon_sign': current_moon_sign, } missing = [key for key, value in required.items() if value is None or value == ''] status = 'ready' if not missing else 'input_required' return { 'status': status, 'required_fields': list(required), 'missing_fields': missing, 'inputs': required, 'source_rule_ids': [ 'raman_muhurtha_tarabala_chandrabala_panchaka_gate', 'raman_muhurtha_vipat_naidhana_avoidance', ], 'boundary': 'Tarabala/Chandrabala cannot be scored without native birth Nakshatra and Moon sign.', } def calc_tarabala( birth_nakshatra: object, current_nakshatra: object, activity: Optional[str] = None, ) -> Dict: """Calculate ninefold Tara relationship from birth Nakshatra to current Nakshatra.""" birth_index = _coerce_1_based_index(birth_nakshatra, NAKSHATRAS, 'birth_nakshatra') current_index = _coerce_1_based_index(current_nakshatra, NAKSHATRAS, 'current_nakshatra') distance = (current_index - birth_index) % 27 + 1 tara = TARA_SEQUENCE[(distance - 1) % 9] cycle = ((distance - 1) // 9) + 1 activity = activity or None janma_exception = None if tara == 'janma' and activity in JANMA_TARA_FAVOURABLE_ACTIVITIES: janma_exception = 'favourable_for_activity' elif tara == 'janma' and activity in JANMA_TARA_UNFAVOURABLE_ACTIVITIES: janma_exception = 'unfavourable_for_activity' generally_unfavourable = tara in {'janma', 'vipat', 'pratyak', 'naidhana'} high_stakes_avoid = tara in TARA_HIGH_STAKES_AVOID and activity in {'marriage', 'travel', 'business'} negative_ghatis = TARA_NEGATIVE_GHATIS.get(tara, 0) if janma_exception == 'favourable_for_activity': quality = 'activity_exception_usable' elif tara in {'sampat', 'kshema', 'sadhana', 'mitra', 'parama_mitra'}: quality = 'good' elif cycle == 3 and tara not in TARA_HIGH_STAKES_AVOID: quality = 'mildly_unfavourable' else: quality = 'bad' return { 'formula_profile': 'raman_muhurtha_p14_p17', 'birth_nakshatra_number': birth_index, 'current_nakshatra_number': current_index, 'distance_inclusive': distance, 'cycle': cycle, 'tara': tara, 'meaning': TARA_MEANINGS[tara], 'quality': quality, 'activity': activity, 'generally_unfavourable': generally_unfavourable, 'avoided_for_high_stakes_activity': high_stakes_avoid or janma_exception == 'unfavourable_for_activity', 'negative_part_ghatis': negative_ghatis, 'negative_part_minutes': negative_ghatis * 24, 'negative_part_avoidance': f'avoid_first_{negative_ghatis}_ghatis' if negative_ghatis else None, 'third_cycle_evil_reduced': cycle == 3 and generally_unfavourable, 'janma_activity_exception': janma_exception, 'source_rule_ids': [ 'raman_muhurtha_tarabala_ninefold_remainders', 'raman_muhurtha_tarabala_cycle_weighting', 'raman_muhurtha_tarabala_negative_ghatis', 'raman_muhurtha_janma_tara_activity_exceptions', ], } def calc_chandrabala( birth_moon_sign: object, current_moon_sign: object, ) -> Dict: """Calculate Raman Chandrabala avoidance by Moon-sign distance.""" birth_index = _coerce_1_based_index(birth_moon_sign, SIGNS, 'birth_moon_sign') current_index = _coerce_1_based_index(current_moon_sign, SIGNS, 'current_moon_sign') distance = (current_index - birth_index) % 12 + 1 avoided = distance in CHANDRABALA_BAD_HOUSES return { 'formula_profile': 'raman_muhurtha_p15_chandrabala', 'birth_moon_sign_number': birth_index, 'current_moon_sign_number': current_index, 'distance_inclusive': distance, 'bad_houses_from_birth_moon': sorted(CHANDRABALA_BAD_HOUSES), 'status': 'evaluated', 'quality': 'bad' if avoided else 'usable', 'avoided': avoided, 'source_rule_ids': ['raman_muhurtha_chandrabala_6_8_12_avoidance'], } def _tithi_in_paksha_number(tithi_num: int) -> int: tithi = int(tithi_num) if not (1 <= tithi <= 30): raise ValueError('tithi_num must be 1..30') return ((tithi - 1) % 15) + 1 def _tithi_group(tithi_num: int) -> str: paksha_tithi = _tithi_in_paksha_number(tithi_num) for group, values in TITHI_GROUPS.items(): if paksha_tithi in values: return group raise ValueError('unable to classify tithi group') def calc_siddha_yoga( weekday: int, tithi_num: int, nakshatra: object, ) -> Dict: """Detect Raman p25-p26 Siddha and Amrita Siddha Yoga combinations.""" weekday_idx = int(weekday) % 7 paksha_tithi = _tithi_in_paksha_number(tithi_num) group = _tithi_group(tithi_num) nakshatra_index = _coerce_1_based_index(nakshatra, NAKSHATRAS, 'nakshatra') nakshatra_name = NAKSHATRAS[nakshatra_index - 1] hits = [] weekday_rule = SIDDHA_YOGA_WEEKDAY_RULES.get(weekday_idx, {}) tithi_match = True if weekday_rule.get('tithis') is not None: tithi_match = paksha_tithi in weekday_rule.get('tithis', set()) if weekday_rule.get('groups') is not None: tithi_match = group in weekday_rule.get('groups', set()) if tithi_match and nakshatra_name in weekday_rule.get('nakshatras', set()): hits.append({'rule_id': 'weekday_tithi_nakshatra_siddha_yoga', 'quality': 'siddha', 'source_pages': [25, 26]}) tithi_group_rule = SIDDHA_YOGA_TITHI_GROUP_RULES.get(weekday_idx) if tithi_group_rule and group == tithi_group_rule['group']: hits.append({'rule_id': 'weekday_tithi_group_siddha_yoga', 'quality': 'siddha', 'source_pages': [26]}) if AMRITA_SIDDHA_NAKSHATRA_BY_WEEKDAY.get(weekday_idx) == nakshatra_name: hits.append({'rule_id': 'amrita_siddha_yoga', 'quality': 'amrita_siddha', 'source_pages': [26]}) return { 'formula_profile': 'raman_muhurtha_p25_p26_siddha_yoga', 'weekday_number': weekday_idx, 'weekday_name': VARA_LORDS[weekday_idx][0], 'tithi_number': int(tithi_num), 'tithi_in_paksha': paksha_tithi, 'tithi_group': group, 'nakshatra_number': nakshatra_index, 'nakshatra': nakshatra_name, 'status': 'siddha_yoga_present' if hits else 'not_present', 'hits': hits, 'quality': 'amrita_siddha' if any(hit['quality'] == 'amrita_siddha' for hit in hits) else 'siddha' if hits else 'none', 'source_rule_ids': [ 'raman_muhurtha_siddha_yoga_weekday_tithi_nakshatra', 'raman_muhurtha_siddha_yoga_weekday_tithi_group', 'raman_muhurtha_amrita_siddha_yoga_weekday_nakshatra', ], 'boundary': 'Special yoga support only; Lagna and broader Muhurta gates still apply.', } def calc_hora(weekday: int, hour_from_sunrise: float) -> Dict: """计算当前 Hora(日出后的小时序号)。 weekday: 0=Sun, ..., 6=Sat hour_from_sunrise: 从日出起算的小时数(浮点) """ vara_lord = VARA_LORDS[weekday % 7][1] start_idx = VARA_START_IDX.get(vara_lord, 0) hora_offset = int(hour_from_sunrise) % 24 hora_lord = HORA_ORDER[(start_idx + hora_offset) % 7] hora_quality = 'subha' if hora_lord in ('Jupiter', 'Venus', 'Mercury') else \ 'mixed' if hora_lord == 'Moon' else 'asubha' return { 'hora_lord': hora_lord, 'hora_num': hora_offset + 1, 'quality': hora_quality, 'hora_from_sunrise': round(hour_from_sunrise, 2) } def calc_abhijit_muhurta(sunrise_ut: Optional[float] = None, sunset_ut: Optional[float] = None) -> Dict: """ 计算 Abhijit Muhurta(最吉祥的时刻,正午±24分钟)。 Abhijit = 8/15 * daytime(从日出到日落的 8/15 处),持续约 48 分钟。 注意:周三(Wednesday)Abhijit 不吉,应避免使用。 参数为 JD UT(可选),缺省时给出相对说明。 """ result = { 'description': 'Abhijit Muhurta 是一天中最吉祥的时段(正午前后各24分钟)', 'rule': '日升到日落共15个 muhurta,第8个(中间)即 Abhijit', 'duration_minutes': 48, 'warning': '周三(Wednesday/Budha Vara)不宜使用 Abhijit', } if sunrise_ut is not None and sunset_ut is not None: day_dur = sunset_ut - sunrise_ut # in JD (days) abhijit_start_jd = sunrise_ut + day_dur * (7 / 15) abhijit_end_jd = sunrise_ut + day_dur * (8 / 15) result['abhijit_start_jd'] = round(abhijit_start_jd, 6) result['abhijit_end_jd'] = round(abhijit_end_jd, 6) result['abhijit_start_offset_min'] = round(day_dur * (7 / 15) * 24 * 60, 1) result['abhijit_end_offset_min'] = round(day_dur * (8 / 15) * 24 * 60, 1) return result def _parse_hhmm(value: str, default: str) -> Tuple[int, int]: if not value: value = default try: hour_str, minute_str = value.split(':', 1) hour = int(hour_str) minute = int(minute_str) except (ValueError, AttributeError) as exc: raise ValueError('time must be HH:MM') from exc if not (0 <= hour <= 23 and 0 <= minute <= 59): raise ValueError('time must be HH:MM') return hour, minute def _minutes_to_hhmm(minutes: float) -> str: total = int(round(minutes)) % (24 * 60) return f'{total // 60:02d}:{total % 60:02d}' def _segment_window(segment: int, sunrise_min: int, sunset_min: int) -> Dict: day_duration = sunset_min - sunrise_min if day_duration <= 0: day_duration += 24 * 60 segment_len = day_duration / 8.0 start = sunrise_min + (segment - 1) * segment_len end = start + segment_len return { 'segment': segment, 'start': _minutes_to_hhmm(start), 'end': _minutes_to_hhmm(end), 'duration_minutes': round(segment_len, 1), } def _clock_minutes(value: str, default: str) -> int: hour, minute = _parse_hhmm(value, default) return hour * 60 + minute def _window_from_minutes(start_min: float, end_min: float) -> Dict: return { 'start': _minutes_to_hhmm(start_min), 'end': _minutes_to_hhmm(end_min), 'duration_minutes': round(end_min - start_min, 1), } def _split_window(start_min: float, end_min: float, parts: int) -> List[Tuple[float, float]]: duration = end_min - start_min if duration <= 0: duration += 24 * 60 step = duration / parts return [(start_min + idx * step, start_min + (idx + 1) * step) for idx in range(parts)] def _solar_event_utc_hours(year: int, month: int, day: int, lat: float, lon: float, event: str) -> float: """Approximate sunrise/sunset UTC decimal hours using the Jaimini helper model.""" jd = 367 * year - int(7 * (year + int((month + 9) / 12)) / 4) + int(275 * month / 9) + day + 1721013.5 d = jd - 2451545.0 mean_anomaly = (357.5291 + 0.98560028 * d) % 360 center = ( 1.9148 * math.sin(math.radians(mean_anomaly)) + 0.0200 * math.sin(math.radians(2 * mean_anomaly)) + 0.0003 * math.sin(math.radians(3 * mean_anomaly)) ) sun_lon = (mean_anomaly + center + 180.10248 + 0.000048 * d * 360) % 360 t = d / 36525.0 eps0 = 84381.448 - 46.8150 * t - 0.00059 * t * t + 0.001813 * t * t * t eps = eps0 / 3600.0 declination = math.degrees( math.asin(math.sin(math.radians(sun_lon)) * math.sin(math.radians(eps))) ) lat_rad = math.radians(lat) dec_rad = math.radians(declination) denom = math.cos(lat_rad) * math.cos(dec_rad) if abs(denom) < 1e-9: raise ValueError('sunrise/sunset undefined near pole for this date') cos_ha = ( math.cos(math.radians(90.833)) - math.sin(lat_rad) * math.sin(dec_rad) ) / denom cos_ha = max(-1.0, min(1.0, cos_ha)) hour_angle = math.degrees(math.acos(cos_ha)) b = math.radians(360.0 * (d - 0.5) / 365.25) eq_time = 229.18 * ( 0.000075 + 0.001868 * math.cos(b) - 0.032077 * math.sin(b) - 0.014615 * math.cos(2 * b) - 0.040849 * math.sin(2 * b) ) solar_noon = (720.0 - 4.0 * lon - eq_time) / 60.0 if event == 'sunrise': return (solar_noon - hour_angle / 15.0) % 24.0 if event == 'sunset': return (solar_noon + hour_angle / 15.0) % 24.0 raise ValueError('event must be sunrise or sunset') def _local_hours_from_jd(event_jd: float, local_midnight_jd: float) -> float: return (event_jd - local_midnight_jd) * 24.0 def _swisseph_solar_times_local( year: int, month: int, day: int, lat: float, lon: float, tz: float, ) -> Optional[Dict]: """Return precise local sunrise/sunset via pyswisseph when available.""" try: import swisseph as swe except Exception: return None try: local_midnight_jd = swe.julday(year, month, day, -tz) geopos = (lon, lat, 0.0) flags = getattr(swe, 'FLG_SWIEPH', 2) rise_res, rise_tret = swe.rise_trans(local_midnight_jd, swe.SUN, swe.CALC_RISE, geopos, flags=flags) set_res, set_tret = swe.rise_trans(local_midnight_jd, swe.SUN, swe.CALC_SET, geopos, flags=flags) except Exception: return None if rise_res != 0 or set_res != 0: return None sunrise_local = _local_hours_from_jd(rise_tret[0], local_midnight_jd) sunset_local = _local_hours_from_jd(set_tret[0], local_midnight_jd) if not (0.0 <= sunrise_local < 24.0 and 0.0 <= sunset_local < 24.0): return None sunrise_utc = (sunrise_local - tz) % 24.0 sunset_utc = (sunset_local - tz) % 24.0 return { 'policy': 'swisseph_rise_trans', 'lat': round(lat, 5), 'lon': round(lon, 5), 'tz': tz, 'sunrise': _minutes_to_hhmm(sunrise_local * 60), 'sunset': _minutes_to_hhmm(sunset_local * 60), 'sunrise_local_hours': round(sunrise_local, 4), 'sunset_local_hours': round(sunset_local, 4), 'sunrise_utc_hours': round(sunrise_utc, 4), 'sunset_utc_hours': round(sunset_utc, 4), } def calc_sunrise_sunset_local( year: int, month: int, day: int, lat: float, lon: float, tz: float, ) -> Dict: """Local sunrise/sunset HH:MM for a date and location, precise when SwissEph is available.""" swisseph_result = _swisseph_solar_times_local(year, month, day, lat, lon, tz) if swisseph_result: return swisseph_result sunrise_utc = _solar_event_utc_hours(year, month, day, lat, lon, 'sunrise') sunset_utc = _solar_event_utc_hours(year, month, day, lat, lon, 'sunset') sunrise_local = (sunrise_utc + tz) % 24.0 sunset_local = (sunset_utc + tz) % 24.0 return { 'policy': 'location_aware_solar_event_approximation', 'lat': round(lat, 5), 'lon': round(lon, 5), 'tz': tz, 'sunrise': _minutes_to_hhmm(sunrise_local * 60), 'sunset': _minutes_to_hhmm(sunset_local * 60), 'sunrise_utc_hours': round(sunrise_utc, 4), 'sunset_utc_hours': round(sunset_utc, 4), } def calc_choghadiya_windows( weekday: int, sunrise: str = '06:00', sunset: str = '18:00', ) -> Dict: """Calculate day and night Choghadiya windows from sunrise/sunset.""" sunrise_min = _clock_minutes(sunrise, '06:00') sunset_min = _clock_minutes(sunset, '18:00') next_sunrise_min = sunrise_min + 24 * 60 day_sequence = CHOGHADIYA_DAY_SEQUENCE[weekday % 7] night_sequence = CHOGHADIYA_NIGHT_SEQUENCE[weekday % 7] def build_rows(sequence: List[str], windows: List[Tuple[float, float]]) -> List[Dict]: return [ { 'index': idx + 1, 'name': name, 'quality': CHOGHADIYA_QUALITY.get(name, 'mixed'), **_window_from_minutes(start, end), } for idx, (name, (start, end)) in enumerate(zip(sequence, windows)) ] return { 'policy': 'traditional_day_night_8_choghadiya_segments', 'day': build_rows(day_sequence, _split_window(sunrise_min, sunset_min, 8)), 'night': build_rows(night_sequence, _split_window(sunset_min, next_sunrise_min, 8)), } def calc_durmuhurtha_windows( weekday: int, sunrise: str = '06:00', sunset: str = '18:00', activity: Optional[str] = None, ) -> Dict: """Calculate Raman p22 Durmuhurtha day/night segments from local sunrise/sunset.""" sunrise_min = _clock_minutes(sunrise, '06:00') sunset_min = _clock_minutes(sunset, '18:00') next_sunrise_min = sunrise_min + 24 * 60 weekday = weekday % 7 def build_rows(names: List[str], windows: List[Tuple[float, float]], general_bad: set[int], phase: str) -> List[Dict]: rows = [] weekday_bad = DURMUHURTHA_WEEKDAY_BAD_DAY.get(weekday, set()) if phase == 'day' else set() for idx, (name, (start, end)) in enumerate(zip(names, windows), start=1): reasons = [] if idx in general_bad: reasons.append('general_bad') if idx in weekday_bad: reasons.append('weekday_bad') activity_reasons = [] if activity == 'marriage' and idx in weekday_bad: activity_reasons.append('marriage_weekday_durmuhurtha_avoid') rows.append({ 'index': idx, 'name': name, 'phase': phase, 'quality': 'inauspicious' if reasons or activity_reasons else 'usable', 'bad_reasons': reasons, 'activity_reasons': activity_reasons, **_window_from_minutes(start, end), }) return rows day_rows = build_rows( DURMUHURTHA_DAY_NAMES, _split_window(sunrise_min, sunset_min, 15), DURMUHURTHA_GENERAL_BAD_DAY, 'day', ) night_rows = build_rows( DURMUHURTHA_NIGHT_NAMES, _split_window(sunset_min, next_sunrise_min, 15), DURMUHURTHA_GENERAL_BAD_NIGHT, 'night', ) return { 'policy': 'raman_muhurtha_p22_day_night_15_segments', 'activity': activity, 'source_rule_ids': [ 'raman_muhurtha_durmuhurtha_unit', 'raman_muhurtha_durmuhurtha_day_night_named_segments', 'raman_muhurtha_marriage_weekday_durmuhurtha_page_22', ], 'weekday': weekday, 'sunrise': _minutes_to_hhmm(sunrise_min), 'sunset': _minutes_to_hhmm(sunset_min), 'day': day_rows, 'night': night_rows, 'blocked_windows': [row for row in day_rows + night_rows if row['quality'] == 'inauspicious'], 'activity_blocked_windows': [ row for row in day_rows + night_rows if row.get('activity_reasons') ], } def calc_hora_windows( weekday: int, sunrise: str = '06:00', sunset: str = '18:00', ) -> Dict: """Calculate planetary Hora windows for daytime and nighttime.""" sunrise_min = _clock_minutes(sunrise, '06:00') sunset_min = _clock_minutes(sunset, '18:00') next_sunrise_min = sunrise_min + 24 * 60 vara_lord = VARA_LORDS[weekday % 7][1] start_idx = VARA_START_IDX.get(vara_lord, 0) def lord_for(offset: int) -> str: return HORA_ORDER[(start_idx + offset) % len(HORA_ORDER)] return { 'policy': 'planetary_hora_day_night_12_segments', 'day': [ {'index': idx + 1, 'lord': lord_for(idx), **_window_from_minutes(start, end)} for idx, (start, end) in enumerate(_split_window(sunrise_min, sunset_min, 12)) ], 'night': [ {'index': idx + 13, 'lord': lord_for(idx + 12), **_window_from_minutes(start, end)} for idx, (start, end) in enumerate(_split_window(sunset_min, next_sunrise_min, 12)) ], } def _planet_payload(chart: Dict, planet: str) -> Dict: value = (chart.get('planets') or {}).get(planet) or {} return value if isinstance(value, dict) else {} def _chart_lagna(chart: Dict) -> Optional[str]: value = chart.get('lagna') or chart.get('ascendant') or chart.get('Ascendant') if isinstance(value, dict): value = value.get('sign') or value.get('rashi') or value.get('name') return str(value) if value in SIGNS else None def normalize_muhurta_election_chart(chart: Dict) -> Dict: """Normalize the minimal election-chart shape consumed by Raman Muhurta gates.""" planets = chart.get('planets') or chart.get('bodies') or {} if not isinstance(planets, dict): planets = {} navamsa = ( chart.get('navamsa') or chart.get('D9') or chart.get('D9_Navamsa') or (chart.get('varga_full') or {}).get('D9_Navamsa') or {} ) if not isinstance(navamsa, dict): navamsa = {} normalized = { 'lagna': _chart_lagna(chart), 'planets': planets, 'navamsa': { 'lagna': _chart_lagna(navamsa), 'planets': navamsa.get('planets') if isinstance(navamsa.get('planets'), dict) else { key: value for key, value in navamsa.items() if isinstance(value, dict) }, }, 'input_contract': { 'schema': 'muhurta.election_chart_input.v1', 'required_for_basic_dosha': ['lagna', 'planets.*.house', 'planets.*.sign'], 'required_for_dignity': ['planets.*.sign or planets.*.dignity'], 'required_for_navamsa_strength': ['navamsa.lagna', 'navamsa.planets.*.house/sign'], }, } missing = [] if not normalized['lagna']: missing.append('lagna') if not normalized['planets']: missing.append('planets') if not normalized['navamsa']['lagna']: missing.append('navamsa.lagna') if not normalized['navamsa']['planets']: missing.append('navamsa.planets') normalized['input_contract']['missing_fields'] = missing normalized['input_contract']['status'] = 'ready' if not missing else 'partial' return normalized def _planet_house(chart: Dict, planet: str) -> Optional[int]: value = _planet_payload(chart, planet).get('house') if value is None: return None try: house = int(value) except (TypeError, ValueError): return None return house if 1 <= house <= 12 else None def _planet_sign(chart: Dict, planet: str) -> Optional[str]: sign = _planet_payload(chart, planet).get('sign') return str(sign) if sign in SIGNS else None def _planet_dignity(chart: Dict, planet: str) -> Optional[str]: dignity = _planet_payload(chart, planet).get('dignity') if dignity: return str(dignity) sign = _planet_sign(chart, planet) if sign and EXALTATION_SIGNS.get(planet) == sign: return 'exalted' return None def _house_from_lagna(lagna: str, sign: str) -> Optional[int]: if lagna not in SIGNS or sign not in SIGNS: return None return (SIGNS.index(sign) - SIGNS.index(lagna)) % 12 + 1 def _as_sign(value: object) -> Optional[str]: if isinstance(value, dict): value = value.get('sign') or value.get('rashi') or value.get('name') return str(value) if value in SIGNS else None def _planet_aspects_house(chart: Dict, planet: str, target_house: int) -> bool: source_house = _planet_house_or_sign_house(chart, planet) if source_house is None: return False distance = (target_house - source_house) % 12 + 1 return distance in GRAHA_DRISHTI_OFFSETS.get(planet, {7}) def _planet_house_or_sign_house(chart: Dict, planet: str) -> Optional[int]: house = _planet_house(chart, planet) if house is not None: return house lagna = _chart_lagna(chart) sign = _planet_sign(chart, planet) if lagna and sign: return _house_from_lagna(lagna, sign) return None def _planets_in_house(chart: Dict, house: int) -> List[str]: return [ planet for planet in (chart.get('planets') or {}) if _planet_house_or_sign_house(chart, planet) == house ] def build_muhurta_shadvarga_strength_contract(election_chart: Dict) -> Dict: """Gate Raman's shadvarga-strength clause without inventing a new strength engine.""" strength = ( election_chart.get('shadvarga_strength') or election_chart.get('varga_strength') or election_chart.get('vimsopaka') or {} ) missing = [] if not isinstance(strength, dict) or not strength: missing.append('shadvarga_strength_or_varga_strength') return { 'schema': 'muhurta.shadvarga_strength_contract.v1', 'status': 'ready' if not missing else 'blocked', 'missing_fields': missing, 'strength_payload': strength if isinstance(strength, dict) else {}, 'source_rule_ids': ['avoid_strong_malefics_in_shadvarga'], 'boundary': 'Strength values must come from the project strength/varga layer; Muhurta does not synthesize them ad hoc.', } def _extract_strength_score(payload: object) -> Optional[float]: if isinstance(payload, (int, float)): return float(payload) if not isinstance(payload, dict): return None for key in ('score', 'total', 'value', 'strength', 'vimsopaka_score'): value = payload.get(key) if isinstance(value, (int, float)): return float(value) return None def evaluate_muhurta_shadvarga_malefic_strength( election_chart: Dict, *, threshold: float = 6.0, ) -> Dict: """Replay Raman's strong-malefic-in-shadvarga warning from an existing strength payload.""" contract = build_muhurta_shadvarga_strength_contract(election_chart) if contract.get('status') == 'blocked': return { 'schema': 'muhurta.shadvarga_malefic_strength_replay.v1', 'status': 'blocked', 'threshold': threshold, 'strong_malefics': [], 'blocked': [{'rule_id': 'avoid_strong_malefics_in_shadvarga', 'missing_fields': contract.get('missing_fields')}], 'source_rule_ids': ['avoid_strong_malefics_in_shadvarga'], 'boundary': contract['boundary'], } strength = contract.get('strength_payload') or {} strong_malefics = [] blocked = [] for planet in sorted(MALEFIC_PLANETS): score = _extract_strength_score(strength.get(planet)) if score is None: blocked.append({'rule_id': 'malefic_shadvarga_strength_score', 'planet': planet, 'missing_fields': [f'shadvarga_strength.{planet}.score']}) continue if score >= threshold: strong_malefics.append({ 'rule_id': 'strong_malefic_in_shadvarga', 'planet': planet, 'score': score, 'threshold': threshold, 'severity': 10, }) return { 'schema': 'muhurta.shadvarga_malefic_strength_replay.v1', 'status': 'evaluated_with_blocks' if blocked else 'evaluated', 'threshold': threshold, 'strong_malefics': strong_malefics, 'blocked': blocked, 'source_rule_ids': ['avoid_strong_malefics_in_shadvarga'], 'boundary': 'This replays the Raman warning from supplied strength values; the strength engine remains upstream.', } def evaluate_muhurta_strength_supported_cancellations( election_chart: Dict, *, weekday: Optional[object] = None, threshold: float = 6.0, ) -> Dict: """Replay strength-dependent Raman cancellation clauses from supplied strength payload.""" normalized = normalize_muhurta_election_chart(election_chart) contract = build_muhurta_shadvarga_strength_contract(election_chart) if contract.get('status') == 'blocked': return { 'schema': 'muhurta.strength_supported_cancellation_replay.v1', 'status': 'blocked', 'threshold': threshold, 'hits': [], 'blocked': [{'rule_id': 'strength_supported_cancellations', 'missing_fields': contract.get('missing_fields')}], 'source_rule_ids': ['fortified_angles', 'strong_weekday_lord'], 'boundary': contract['boundary'], } strength = contract.get('strength_payload') or {} chart_view = {'lagna': normalized.get('lagna'), 'planets': normalized.get('planets') or {}} hits = [] blocked = [] for planet in sorted(chart_view['planets']): house = _planet_house_or_sign_house(chart_view, planet) score = _extract_strength_score(strength.get(planet)) if house in {1, 4, 7, 10}: if score is None: blocked.append({'rule_id': 'fortified_angles', 'planet': planet, 'missing_fields': [f'shadvarga_strength.{planet}.score']}) elif score >= threshold: hits.append({'rule_id': 'fortified_angles', 'planet': planet, 'house': house, 'score': score, 'priority': 7}) if weekday is None: blocked.append({'rule_id': 'strong_weekday_lord', 'missing_fields': ['weekday']}) else: try: weekday_idx = int(weekday) % 7 weekday_lord = VARA_LORDS[weekday_idx][1] weekday_score = _extract_strength_score(strength.get(weekday_lord)) if weekday_score is None: blocked.append({'rule_id': 'strong_weekday_lord', 'planet': weekday_lord, 'missing_fields': [f'shadvarga_strength.{weekday_lord}.score']}) elif weekday_score >= threshold: hits.append({'rule_id': 'strong_weekday_lord', 'planet': weekday_lord, 'weekday': VARA_LORDS[weekday_idx][0], 'score': weekday_score, 'priority': 6}) except (TypeError, ValueError): blocked.append({'rule_id': 'strong_weekday_lord', 'missing_fields': ['valid_weekday']}) return { 'schema': 'muhurta.strength_supported_cancellation_replay.v1', 'status': 'evaluated_with_blocks' if blocked else 'evaluated', 'threshold': threshold, 'hits': sorted(hits, key=lambda row: row.get('priority', 0), reverse=True), 'blocked': blocked, 'source_rule_ids': ['fortified_angles', 'strong_weekday_lord'], 'boundary': 'This replays strength-supported cancellation candidates only; it is not a final Muhurta verdict.', } def evaluate_muhurta_lagna_navamsa_aspect_exchange(election_chart: Dict) -> Dict: """Evaluate the Raman Lagna/Navamsa lord aspect-exchange clause with whole-sign graha drishti.""" normalized = normalize_muhurta_election_chart(election_chart) lagna = normalized['lagna'] navamsa = normalized['navamsa'] checks = [] blocked = [] if not lagna: blocked.append({'rule_id': 'lagna_aspect_exchange', 'missing_fields': ['lagna']}) if not navamsa.get('lagna'): blocked.append({'rule_id': 'navamsa_aspect_exchange', 'missing_fields': ['navamsa.lagna']}) if lagna: lord = SIGN_LORDS[lagna] chart_view = {'lagna': lagna, 'planets': normalized['planets']} checks.append({ 'rule_id': 'lagna_lord_aspects_lagna', 'lord': lord, 'target_house': 1, 'status': 'supportive' if _planet_aspects_house(chart_view, lord, 1) else 'not_confirmed', }) if navamsa.get('lagna'): nav_lagna = navamsa['lagna'] nav_lord = SIGN_LORDS[nav_lagna] nav_view = {'lagna': nav_lagna, 'planets': navamsa.get('planets') or {}} checks.append({ 'rule_id': 'navamsa_lord_aspects_navamsa_lagna', 'lord': nav_lord, 'target_house': 1, 'status': 'supportive' if _planet_aspects_house(nav_view, nav_lord, 1) else 'not_confirmed', }) return { 'status': 'evaluated_with_blocks' if blocked else 'evaluated', 'aspect_policy': 'whole_sign_graha_drishti', 'source_rule_ids': ['raman_muhurtha_lagna_navamsa_strength'], 'checks': checks, 'blocked': blocked, 'supportive_count': sum(1 for row in checks if row['status'] == 'supportive'), 'boundary': 'Only the lord-to-lagna aspect clause is replayed here; malefic aspect cancellation is reported separately.', } def evaluate_muhurta_jupiter_navamsa_aspect_protection(election_chart: Dict) -> Dict: """Replay the Jupiter Lagna/Navamsa/aspect protection clause as separate auditable hits.""" normalized = normalize_muhurta_election_chart(election_chart) chart_view = {'lagna': normalized.get('lagna'), 'planets': normalized.get('planets') or {}} navamsa = normalized.get('navamsa') or {} nav_view = {'lagna': navamsa.get('lagna'), 'planets': navamsa.get('planets') or {}} hits = [] blocked = [] jupiter_house = _planet_house_or_sign_house(chart_view, 'Jupiter') if jupiter_house is None: blocked.append({'rule_id': 'jupiter_position', 'missing_fields': ['planets.Jupiter.house_or_sign']}) else: if jupiter_house == 1: hits.append({'rule_id': 'jupiter_dispels_lagna_evil', 'scope': 'lagna', 'priority': 9}) if _planet_aspects_house(chart_view, 'Jupiter', 1): hits.append({'rule_id': 'jupiter_aspects_lagna', 'scope': 'lagna_aspect', 'priority': 8}) nav_jupiter_house = _planet_house_or_sign_house(nav_view, 'Jupiter') if nav_jupiter_house is None: blocked.append({'rule_id': 'jupiter_navamsa_position', 'missing_fields': ['navamsa.planets.Jupiter.house_or_sign']}) else: if nav_jupiter_house == 1: hits.append({'rule_id': 'jupiter_dispels_navamsa_evil', 'scope': 'navamsa', 'priority': 9}) if _planet_aspects_house(nav_view, 'Jupiter', 1): hits.append({'rule_id': 'jupiter_aspects_navamsa_lagna', 'scope': 'navamsa_aspect', 'priority': 8}) malefic_aspect_hits = [] for planet in MALEFIC_PLANETS: if _planet_aspects_house(chart_view, planet, 1): malefic_aspect_hits.append({'planet': planet, 'target': 'lagna'}) if malefic_aspect_hits and (jupiter_house in {1, 4, 7, 10} or any(hit['scope'].startswith('lagna') for hit in hits)): hits.append({'rule_id': 'jupiter_offsets_malefic_lagna_aspects', 'scope': 'malefic_aspects', 'priority': 7, 'malefic_aspects': malefic_aspect_hits}) return { 'status': 'evaluated_with_blocks' if blocked else 'evaluated', 'aspect_policy': 'whole_sign_graha_drishti', 'source_rule_ids': ['jupiter_lagna_navamsa_malefic_aspect_protection'], 'hits': sorted(hits, key=lambda row: row.get('priority', 0), reverse=True), 'blocked': blocked, 'quality': 'protective' if hits else 'unconfirmed', 'boundary': 'This is a clause replay packet, not a final cancellation verdict.', } def evaluate_muhurta_lagna_navamsa_strength(election_chart: Dict) -> Dict: """Evaluate Raman p21-p22 Lagna/Navamsa strength clauses as a guarded packet.""" normalized = normalize_muhurta_election_chart(election_chart) lagna = normalized['lagna'] navamsa = normalized['navamsa'] checks = [] blocked = [] aspect_exchange = evaluate_muhurta_lagna_navamsa_aspect_exchange(election_chart) if not lagna: blocked.append({'rule_id': 'lagna_strength', 'missing_fields': ['lagna']}) else: lord = SIGN_LORDS[lagna] lord_house = _planet_house_or_sign_house({'lagna': lagna, 'planets': normalized['planets']}, lord) checks.append({ 'rule_id': 'lagna_occupied_or_supported_by_lagna_lord', 'source_pages': [21, 22], 'lagna': lagna, 'lord': lord, 'lord_house': lord_house, 'status': 'supportive' if lord_house == 1 else 'not_confirmed' if lord_house else 'blocked', }) if lord_house is None: blocked.append({'rule_id': 'lagna_lord_house', 'missing_fields': [f'planets.{lord}.house_or_sign']}) nav_lagna = navamsa.get('lagna') nav_planets = navamsa.get('planets') or {} if not nav_lagna: blocked.append({'rule_id': 'navamsa_lagna_strength', 'missing_fields': ['navamsa.lagna']}) else: nav_lord = SIGN_LORDS[nav_lagna] nav_lord_house = _planet_house_or_sign_house({'lagna': nav_lagna, 'planets': nav_planets}, nav_lord) checks.append({ 'rule_id': 'navamsa_lagna_occupied_or_supported_by_navamsa_lord', 'source_pages': [21, 22], 'navamsa_lagna': nav_lagna, 'lord': nav_lord, 'lord_house': nav_lord_house, 'status': 'supportive' if nav_lord_house == 1 else 'not_confirmed' if nav_lord_house else 'blocked', }) if nav_lord_house is None: blocked.append({'rule_id': 'navamsa_lagna_lord_house', 'missing_fields': [f'navamsa.planets.{nav_lord}.house_or_sign']}) supportive = sum(1 for row in checks if row['status'] == 'supportive') return { 'status': 'evaluated_with_blocks' if blocked else 'evaluated', 'source_rule_ids': ['raman_muhurtha_lagna_navamsa_strength'], 'checks': checks, 'blocked': blocked, 'supportive_count': supportive, 'quality': 'strong' if supportive >= 2 and not blocked else 'partial' if supportive else 'unconfirmed', 'aspect_exchange': aspect_exchange, 'boundary': 'Aspect-exchange clauses use whole-sign graha drishti and remain a supporting clause, not final score.', } def evaluate_muhurta_election_chart_dosha( election_chart: Dict, *, activity: str = 'business', native_lagna: Optional[object] = None, weekday: Optional[object] = None, ) -> Dict: """Evaluate Raman p23-p25 election-chart dosha/cancellation hooks. This is a structural gate, not a final Muhurta verdict. """ doshas = [] cancellations = [] blocked = [] normalized = normalize_muhurta_election_chart(election_chart) planets = normalized.get('planets') or {} if not isinstance(planets, dict) or not planets: return { 'status': 'blocked', 'missing_fields': ['election_chart.planets'], 'doshas': [], 'cancellations': [], 'normalized_chart': normalized, 'source_rule_ids': [ 'raman_muhurtha_malefic_shadvarga_bhrigu_kuja_ashtama', 'raman_muhurtha_neutralization_rules', ], } chart_view = {'lagna': normalized.get('lagna'), 'planets': planets} navamsa_strength = evaluate_muhurta_lagna_navamsa_strength(election_chart) shadvarga_contract = build_muhurta_shadvarga_strength_contract(election_chart) shadvarga_malefic_strength = evaluate_muhurta_shadvarga_malefic_strength(election_chart) strength_cancellations = evaluate_muhurta_strength_supported_cancellations(election_chart, weekday=weekday) jupiter_protection = evaluate_muhurta_jupiter_navamsa_aspect_protection(election_chart) venus_house = _planet_house_or_sign_house(chart_view, 'Venus') mars_house = _planet_house_or_sign_house(chart_view, 'Mars') if activity == 'marriage' and venus_house == 6: doshas.append({'rule_id': 'venus_6th_marriage_bhrigu', 'severity': 9, 'planet': 'Venus', 'house': 6}) if activity == 'marriage' and mars_house == 8: doshas.append({'rule_id': 'mars_8th_marriage_kuja', 'severity': 9, 'planet': 'Mars', 'house': 8}) if activity == 'marriage': houses_by_malefic = { planet: _planet_house_or_sign_house(chart_view, planet) for planet in MALEFIC_PLANETS } karthari_malefics = [ {'planet': planet, 'house': house} for planet, house in houses_by_malefic.items() if house in {2, 12} ] if any(row['house'] == 2 for row in karthari_malefics) and any(row['house'] == 12 for row in karthari_malefics): doshas.append({ 'rule_id': 'raman_p21_marriage_karthari_dosha', 'severity': 10, 'malefics': karthari_malefics, 'source_pages': [21], }) moon_house = _planet_house_or_sign_house(chart_view, 'Moon') if moon_house in {6, 8, 12}: doshas.append({ 'rule_id': 'raman_p21_moon_6_8_12_from_lagna', 'severity': 9, 'planet': 'Moon', 'house': moon_house, 'source_pages': [21], }) elif moon_house is None: blocked.append({'rule_id': 'raman_p21_moon_6_8_12_from_lagna', 'missing_fields': ['planets.Moon.house_or_sign']}) moon_house_occupants = [ planet for planet in _planets_in_house(chart_view, moon_house or 0) if planet != 'Moon' ] if moon_house is not None and moon_house_occupants: doshas.append({ 'rule_id': 'raman_p21_marriage_sagraha_chandra_dosha', 'severity': 9, 'planet': 'Moon', 'house': moon_house, 'co_occupants': moon_house_occupants, 'source_pages': [21], }) if native_lagna is not None: election_lagna = normalized.get('lagna') try: native_idx = _coerce_1_based_index(native_lagna, SIGNS, 'native_lagna') election_idx = _coerce_1_based_index(election_lagna, SIGNS, 'election_lagna') if (election_idx - native_idx) % 12 + 1 == 8: doshas.append({'rule_id': 'ashtama_lagna_dosha', 'severity': 8, 'native_lagna': native_lagna, 'election_lagna': election_lagna}) except ValueError: blocked.append({'rule_id': 'ashtama_lagna_dosha', 'missing_fields': ['valid_native_lagna', 'valid_election_lagna']}) else: blocked.append({'rule_id': 'ashtama_lagna_dosha', 'missing_fields': ['native_lagna']}) for row in shadvarga_malefic_strength.get('strong_malefics') or []: doshas.append(dict(row)) for planet in BENEFIC_PLANETS: if _planet_house_or_sign_house(chart_view, planet) == 1: cancellations.append({'rule_id': 'benefic_in_lagna', 'priority': 8, 'planet': planet}) for planet, exalt_sign in EXALTATION_SIGNS.items(): if _planet_house_or_sign_house(chart_view, planet) == 1 and _planet_sign(chart_view, planet) == exalt_sign: cancellations.append({'rule_id': 'exalted_planet_in_lagna', 'priority': 10, 'planet': planet}) jupiter_house = _planet_house_or_sign_house(chart_view, 'Jupiter') if jupiter_house == 1: cancellations.append({'rule_id': 'jupiter_in_lagna_dispels_lagna_evil', 'priority': 9, 'planet': 'Jupiter'}) if jupiter_house in {1, 4, 7, 10}: cancellations.append({'rule_id': 'jupiter_kendra_protection', 'priority': 8, 'planet': 'Jupiter', 'house': jupiter_house}) for hit in jupiter_protection.get('hits') or []: cancellations.append({'rule_id': hit['rule_id'], 'priority': hit.get('priority', 0), 'source': 'jupiter_navamsa_aspect_protection'}) for hit in strength_cancellations.get('hits') or []: cancellations.append({**hit, 'source': 'strength_supported_cancellations'}) if any(row['status'] == 'supportive' for row in navamsa_strength.get('checks', []) if 'navamsa' in row['rule_id']): cancellations.append({'rule_id': 'navamsa_lagna_fortification', 'priority': 7}) if _planet_house_or_sign_house(chart_view, 'Sun') == 11 or _planet_house_or_sign_house(chart_view, 'Moon') == 11: cancellations.append({'rule_id': 'moon_or_sun_11th', 'priority': 6}) if jupiter_house in {1, 4, 7, 10} or _planet_house_or_sign_house(chart_view, 'Venus') in {1, 4, 7, 10}: malefics_placed = [ planet for planet in MALEFIC_PLANETS if _planet_house_or_sign_house(chart_view, planet) in {3, 6, 11} ] if malefics_placed: cancellations.append({'rule_id': 'jupiter_or_venus_kendra_malefics_3_6_11', 'priority': 8, 'malefics': malefics_placed}) if navamsa_strength.get('quality') == 'strong': cancellations.append({'rule_id': 'lagna_navamsa_fortification', 'priority': 7, 'supportive_count': navamsa_strength.get('supportive_count')}) for planet in planets: if planet in EXALTATION_SIGNS and _planet_sign(chart_view, planet) is None and _planet_payload(chart_view, planet).get('dignity') is None: blocked.append({'rule_id': 'planet_dignity', 'planet': planet, 'missing_fields': ['sign_or_dignity']}) blocked.extend(navamsa_strength.get('blocked') or []) if shadvarga_contract.get('status') == 'blocked': blocked.append({'rule_id': 'avoid_strong_malefics_in_shadvarga', 'missing_fields': shadvarga_contract.get('missing_fields')}) blocked.extend(shadvarga_malefic_strength.get('blocked') or []) blocked.extend(jupiter_protection.get('blocked') or []) return { 'status': 'evaluated_with_blocks' if blocked else 'evaluated', 'activity': activity, 'normalized_chart': normalized, 'lagna_navamsa_strength': navamsa_strength, 'shadvarga_strength_contract': shadvarga_contract, 'shadvarga_malefic_strength': shadvarga_malefic_strength, 'strength_supported_cancellations': strength_cancellations, 'jupiter_navamsa_aspect_protection': jupiter_protection, 'doshas': doshas, 'cancellations': sorted(cancellations, key=lambda row: row.get('priority', 0), reverse=True), 'blocked': blocked, 'net_status': 'negative_needs_human_review' if doshas and not cancellations else 'mitigated_or_clear', 'source_rule_ids': [ 'raman_muhurtha_malefic_shadvarga_bhrigu_kuja_ashtama', 'raman_muhurtha_neutralization_rules', 'raman_muhurtha_marriage_adverse_yogas_page_21', ], } def evaluate_muhurta_cancellation_weight_packet( election_chart: Dict, *, activity: str = 'business', native_lagna: Optional[object] = None, weekday: Optional[object] = None, ) -> Dict: """Build an auditable dosha/cancellation weight packet without issuing a final score.""" dosha_packet = evaluate_muhurta_election_chart_dosha( election_chart, activity=activity, native_lagna=native_lagna, weekday=weekday, ) severity_total = sum(float(row.get('severity', 0)) for row in dosha_packet.get('doshas') or []) cancellation_total = sum(float(row.get('priority', 0)) for row in dosha_packet.get('cancellations') or []) applied_cancellation_weight = min(severity_total, cancellation_total) if severity_total == 0: net_weight_status = 'no_structural_dosha_detected' elif applied_cancellation_weight >= severity_total: net_weight_status = 'fully_mitigated_candidate' elif applied_cancellation_weight > 0: net_weight_status = 'partially_mitigated_candidate' else: net_weight_status = 'unmitigated_candidate' return { 'schema': 'muhurta.cancellation_weight_packet.v1', 'status': dosha_packet.get('status'), 'activity': activity, 'severity_total': severity_total, 'cancellation_priority_total': cancellation_total, 'applied_cancellation_weight': applied_cancellation_weight, 'net_weight_status': net_weight_status, 'final_activity_score_allowed': False, 'doshas': dosha_packet.get('doshas') or [], 'cancellations': dosha_packet.get('cancellations') or [], 'blocked': dosha_packet.get('blocked') or [], 'source_rule_ids': [ 'raman_muhurtha_malefic_shadvarga_bhrigu_kuja_ashtama', 'raman_muhurtha_neutralization_rules', ], 'boundary': 'Weights expose replay priority only; final Muhurta activity scoring still needs broader Raman table closure.', } RAMAN_ACTIVITY_SCORE_WEIGHTS = { 'panchanga_subha': 4, 'panchanga_mixed': 1, 'panchanga_asubha': -4, 'panchaka_good': 8, 'panchaka_bad': -6, 'panchaka_activity_avoid': -18, 'tarabala_good': 8, 'tarabala_mild': -3, 'tarabala_bad': -12, 'chandrabala_good': 6, 'chandrabala_bad': -12, 'siddha': 8, 'amrita_siddha': 12, 'durmuhurtha': -18, 'structural_dosha': -2, 'structural_cancellation': 1, 'money_lending_avoid_nakshatra': -14, 'money_lending_avoid_weekday': -10, 'money_lending_saturday_new_moon': -18, 'money_receiving_counter_rule': 8, 'money_lagna_lords_harmonious': 6, 'money_lagna_lords_inharmonious': -8, 'money_moon_scorpio_lender': -8, 'journey_good_nakshatra': 8, 'journey_janma_nakshatra_avoid': -12, 'journey_ninth_tithi_avoid': -14, 'journey_urgent_exception': 7, 'journey_fixed_moon_lagna_gain': 6, 'journey_jupiter_venus_support': 5, 'land_purchase_good_nakshatra': 8, 'land_purchase_avoid_riktha_tithi': -14, 'land_purchase_good_weekday': 6, 'land_purchase_reject_tuesday': -12, 'land_purchase_fixed_lagna': 8, 'land_purchase_common_lagna_ordinary': -3, 'land_purchase_movable_lagna_taurus_navamsa_exception': 7, 'land_purchase_eighth_house_vacant': 8, 'land_purchase_eighth_house_occupied': -14, 'land_purchase_malefics_in_upachayas': 6, 'land_purchase_malefics_not_in_upachayas': -8, 'land_purchase_benefics_in_kendras': 6, 'land_purchase_benefics_not_in_kendras': -6, 'land_purchase_moon_strong': 6, 'land_purchase_moon_weak': -8, 'land_purchase_weekday_lord_in_lagna': 4, } FIXED_SIGNS = {'Taurus', 'Leo', 'Scorpio', 'Aquarius'} MOVABLE_SIGNS = {'Aries', 'Cancer', 'Libra', 'Capricorn'} COMMON_SIGNS = {'Gemini', 'Virgo', 'Sagittarius', 'Pisces'} RAMAN_LAND_PURCHASE_ACTIVITIES = {'business_land_purchase', 'buying_land', 'land_purchase', 'house_building'} RAMAN_LAND_PURCHASE_GOOD_NAKSHATRAS = { 'Ashwini', 'Rohini', 'Mrigashira', 'Punarvasu', 'Pushya', 'Uttara Phalguni', 'Hasta', 'Swati', 'Anuradha', 'Uttara Ashadha', 'Shravana', 'Dhanishtha', 'Shatabhisha', 'Uttara Bhadrapada', } def _score_quality(quality: Optional[str]) -> int: if quality == 'subha': return RAMAN_ACTIVITY_SCORE_WEIGHTS['panchanga_subha'] if quality == 'mixed': return RAMAN_ACTIVITY_SCORE_WEIGHTS['panchanga_mixed'] if quality == 'asubha': return RAMAN_ACTIVITY_SCORE_WEIGHTS['panchanga_asubha'] return 0 def _score_band(score: int, missing_count: int) -> str: if missing_count >= 3: return 'insufficient_inputs' if score >= 75: return 'strong_candidate' if score >= 60: return 'usable_candidate' if score >= 45: return 'mixed_candidate' return 'avoid_candidate' def _score_raman_activity_specific_modifier( *, activity: str, lagna: Optional[object], tithi_num: Optional[int], weekday_value: Optional[object], current_nakshatra: Optional[str], birth_nakshatra: Optional[object], transaction_type: Optional[str], urgent_journey: bool, lagna_lord_seventh_lord_relationship: Optional[str], current_moon_sign: Optional[object], jupiter_or_venus_support: Optional[bool], navamsa_lagna: Optional[object], eighth_house_vacant: Optional[bool], malefics_in_upachayas: Optional[bool], benefics_in_kendras: Optional[bool], moon_strong: Optional[bool], weekday_lord_in_lagna: Optional[bool], ) -> Dict: """Evaluate Raman page-backed activity-specific modifiers.""" delta = 0 hits = [] missing = [] normalized_transaction = transaction_type or ( 'lending' if activity in {'finance_lending', 'finance_lending_borrowing'} else 'receiving' if activity == 'finance_receiving' else None ) try: weekday_idx = None if weekday_value is None else int(weekday_value) % 7 except (TypeError, ValueError): weekday_idx = None if activity in {'finance_lending_borrowing', 'finance_lending', 'finance_receiving'}: if normalized_transaction not in {'lending', 'receiving'}: missing.append('transaction_type_lending_or_receiving') if current_nakshatra is None: missing.append('current_nakshatra') if weekday_idx is None: missing.append('weekday') if tithi_num is None: missing.append('tithi_num') normalized_moon_sign = _as_sign(current_moon_sign) normalized_relationship = ( lagna_lord_seventh_lord_relationship.lower() if isinstance(lagna_lord_seventh_lord_relationship, str) else None ) if normalized_relationship is None: missing.append('lagna_lord_seventh_lord_relationship') elif normalized_relationship in {'harmonious', 'friendly', 'well_disposed', 'mutual_aspect_benefic'}: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['money_lagna_lords_harmonious'] delta += value hits.append({'rule_id': 'raman_p70_lagna_lord_seventh_lord_harmonious', 'delta': value, 'source_pages': [70]}) elif normalized_relationship in {'inharmonious', 'hostile', 'ill_disposed', 'afflicted'}: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['money_lagna_lords_inharmonious'] delta += value hits.append({'rule_id': 'raman_p70_lagna_lord_seventh_lord_inharmonious', 'delta': value, 'source_pages': [70]}) is_janma = False if birth_nakshatra is not None and current_nakshatra is not None: try: is_janma = _coerce_1_based_index(birth_nakshatra, NAKSHATRAS, 'birth_nakshatra') == _coerce_1_based_index(current_nakshatra, NAKSHATRAS, 'current_nakshatra') except ValueError: missing.append('valid_birth_or_current_nakshatra') elif current_nakshatra is not None: missing.append('birth_nakshatra_for_janma_lending_rule') avoid_nakshatra = current_nakshatra in RAMAN_MONEY_LENDING_AVOID_NAKSHATRAS or is_janma if normalized_transaction == 'lending': if avoid_nakshatra: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['money_lending_avoid_nakshatra'] delta += value hits.append({'rule_id': 'raman_p70_money_lending_avoid_nakshatra', 'delta': value, 'source_pages': [70]}) if weekday_idx in {2, 5}: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['money_lending_avoid_weekday'] delta += value hits.append({'rule_id': 'raman_p70_money_lending_avoid_tuesday_friday', 'delta': value, 'source_pages': [70]}) if weekday_idx == 6 and tithi_num == 30: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['money_lending_saturday_new_moon'] delta += value hits.append({'rule_id': 'raman_p70_money_lending_avoid_saturday_new_moon', 'delta': value, 'source_pages': [70]}) if normalized_moon_sign == 'Scorpio': value = RAMAN_ACTIVITY_SCORE_WEIGHTS['money_moon_scorpio_lender'] delta += value hits.append({'rule_id': 'raman_p70_moon_in_scorpio_bad_for_lender', 'delta': value, 'source_pages': [70]}) elif normalized_transaction == 'receiving' and avoid_nakshatra: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['money_receiving_counter_rule'] delta += value hits.append({'rule_id': 'raman_p70_receive_money_on_lending_avoid_days', 'delta': value, 'source_pages': [70]}) if activity in {'travel', 'journey'}: if current_nakshatra is None: missing.append('current_nakshatra') if tithi_num is None: missing.append('tithi_num') normalized_moon_sign = _as_sign(current_moon_sign) normalized_lagna = _as_sign(lagna) if normalized_moon_sign is None: missing.append('current_moon_sign_for_fixed_sign_rule') if normalized_lagna is None: missing.append('lagna_for_fixed_sign_rule') if normalized_moon_sign in FIXED_SIGNS and normalized_lagna in FIXED_SIGNS: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['journey_fixed_moon_lagna_gain'] delta += value hits.append({'rule_id': 'raman_p71_fixed_moon_and_lagna_for_pecuniary_gain', 'delta': value, 'source_pages': [71]}) if jupiter_or_venus_support is None: missing.append('jupiter_or_venus_support') elif jupiter_or_venus_support: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['journey_jupiter_venus_support'] delta += value hits.append({'rule_id': 'raman_p71_jupiter_or_venus_support', 'delta': value, 'source_pages': [71]}) is_janma = False if birth_nakshatra is not None and current_nakshatra is not None: try: is_janma = _coerce_1_based_index(birth_nakshatra, NAKSHATRAS, 'birth_nakshatra') == _coerce_1_based_index(current_nakshatra, NAKSHATRAS, 'current_nakshatra') except ValueError: missing.append('valid_birth_or_current_nakshatra') elif current_nakshatra is not None: missing.append('birth_nakshatra_for_journey_janma_rule') if current_nakshatra in RAMAN_JOURNEY_GOOD_NAKSHATRAS: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['journey_good_nakshatra'] delta += value hits.append({'rule_id': 'raman_p71_journey_good_nakshatra', 'delta': value, 'source_pages': [71]}) if is_janma: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['journey_janma_nakshatra_avoid'] delta += value hits.append({'rule_id': 'raman_p71_journey_avoid_janma_nakshatra', 'delta': value, 'source_pages': [71]}) if _tithi_in_paksha_number(tithi_num) == 9 if tithi_num is not None else False: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['journey_ninth_tithi_avoid'] delta += value hits.append({'rule_id': 'raman_p71_journey_avoid_ninth_lunar_day', 'delta': value, 'source_pages': [71]}) if urgent_journey and any(hit['delta'] < 0 for hit in hits): value = RAMAN_ACTIVITY_SCORE_WEIGHTS['journey_urgent_exception'] delta += value hits.append({'rule_id': 'raman_p71_urgent_journey_exception', 'delta': value, 'source_pages': [71]}) if activity in RAMAN_LAND_PURCHASE_ACTIVITIES: normalized_lagna = _as_sign(lagna) normalized_navamsa_lagna = _as_sign(navamsa_lagna) if current_nakshatra is None: missing.append('current_nakshatra') elif current_nakshatra in RAMAN_LAND_PURCHASE_GOOD_NAKSHATRAS: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_good_nakshatra'] delta += value hits.append({'rule_id': 'raman_p82_land_purchase_good_nakshatra', 'delta': value, 'source_pages': [82]}) if tithi_num is None: missing.append('tithi_num') elif _tithi_in_paksha_number(tithi_num) in {4, 9, 14}: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_avoid_riktha_tithi'] delta += value hits.append({'rule_id': 'raman_p82_land_purchase_avoid_riktha_tithi', 'delta': value, 'source_pages': [82]}) if weekday_idx is None: missing.append('weekday') elif weekday_idx in {1, 3, 4, 6}: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_good_weekday'] delta += value hits.append({'rule_id': 'raman_p82_land_purchase_good_weekday', 'delta': value, 'source_pages': [82]}) elif weekday_idx == 2: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_reject_tuesday'] delta += value hits.append({'rule_id': 'raman_p82_land_purchase_reject_tuesday', 'delta': value, 'source_pages': [82]}) if normalized_lagna is None: missing.append('lagna_for_land_purchase') elif normalized_lagna in FIXED_SIGNS: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_fixed_lagna'] delta += value hits.append({'rule_id': 'raman_p82_land_purchase_fixed_lagna', 'delta': value, 'source_pages': [82]}) elif normalized_lagna in COMMON_SIGNS: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_common_lagna_ordinary'] delta += value hits.append({'rule_id': 'raman_p82_land_purchase_common_lagna_ordinary', 'delta': value, 'source_pages': [82]}) elif normalized_lagna in MOVABLE_SIGNS: if normalized_navamsa_lagna == 'Taurus': value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_movable_lagna_taurus_navamsa_exception'] delta += value hits.append({'rule_id': 'raman_p82_land_purchase_movable_lagna_taurus_navamsa_exception', 'delta': value, 'source_pages': [82]}) else: missing.append('navamsa_lagna_taurus_exception_or_fixed_lagna') boolean_checks = [ (eighth_house_vacant, 'eighth_house_vacant', 'land_purchase_eighth_house_vacant', 'land_purchase_eighth_house_occupied', 'raman_p82_land_purchase_eighth_house_vacant', 'raman_p82_land_purchase_eighth_house_occupied'), (malefics_in_upachayas, 'malefics_in_upachayas', 'land_purchase_malefics_in_upachayas', 'land_purchase_malefics_not_in_upachayas', 'raman_p82_land_purchase_malefics_in_upachayas', 'raman_p82_land_purchase_malefics_not_in_upachayas'), (benefics_in_kendras, 'benefics_in_kendras', 'land_purchase_benefics_in_kendras', 'land_purchase_benefics_not_in_kendras', 'raman_p82_land_purchase_benefics_in_kendras', 'raman_p82_land_purchase_benefics_not_in_kendras'), (moon_strong, 'moon_strong', 'land_purchase_moon_strong', 'land_purchase_moon_weak', 'raman_p82_land_purchase_moon_strong', 'raman_p82_land_purchase_moon_weak'), ] for value_input, missing_name, positive_weight, negative_weight, positive_rule, negative_rule in boolean_checks: if value_input is None: missing.append(missing_name) elif value_input: value = RAMAN_ACTIVITY_SCORE_WEIGHTS[positive_weight] delta += value hits.append({'rule_id': positive_rule, 'delta': value, 'source_pages': [82]}) else: value = RAMAN_ACTIVITY_SCORE_WEIGHTS[negative_weight] delta += value hits.append({'rule_id': negative_rule, 'delta': value, 'source_pages': [82]}) if weekday_lord_in_lagna is None: missing.append('weekday_lord_in_lagna') elif weekday_lord_in_lagna: value = RAMAN_ACTIVITY_SCORE_WEIGHTS['land_purchase_weekday_lord_in_lagna'] delta += value hits.append({'rule_id': 'raman_p82_land_purchase_weekday_lord_in_lagna', 'delta': value, 'source_pages': [82]}) return { 'factor_id': 'raman_activity_specific_modifier', 'activity': activity, 'transaction_type': normalized_transaction, 'urgent_journey': urgent_journey, 'delta': delta, 'hits': hits, 'missing_inputs': missing, 'source_rule_ids': [ 'raman_muhurtha_money_lending_page_70', 'raman_muhurtha_journey_page_71', 'raman_muhurtha_land_purchase_page_82', ], 'status': 'evaluated_with_input_gaps' if missing else 'evaluated', } def score_raman_muhurta_activity_beta( panchanga: Dict, activity: str, *, lagna: Optional[object] = None, birth_nakshatra: Optional[object] = None, birth_moon_sign: Optional[object] = None, current_moon_sign: Optional[object] = None, in_durmuhurtha: bool = False, election_chart: Optional[Dict] = None, native_lagna: Optional[object] = None, weekday: Optional[object] = None, transaction_type: Optional[str] = None, urgent_journey: bool = False, lagna_lord_seventh_lord_relationship: Optional[str] = None, jupiter_or_venus_support: Optional[bool] = None, navamsa_lagna: Optional[object] = None, eighth_house_vacant: Optional[bool] = None, malefics_in_upachayas: Optional[bool] = None, benefics_in_kendras: Optional[bool] = None, moon_strong: Optional[bool] = None, weekday_lord_in_lagna: Optional[bool] = None, ) -> Dict: """Product beta score for Raman-backed Muhurta activity ranking. This is a calibrated product ranking score, not a classical truth verdict. Missing optional native/chart inputs become explicit evidence gaps instead of hard blockers. """ score = 50 factors = [] missing = [] for limb in ('tithi', 'nakshatra', 'yoga', 'karana', 'vara'): quality = (panchanga.get(limb) or {}).get('quality') delta = _score_quality(quality) score += delta factors.append({'factor_id': f'{limb}_quality', 'quality': quality, 'delta': delta}) tithi_num = (panchanga.get('tithi') or {}).get('tithi_num') current_nakshatra = (panchanga.get('nakshatra') or {}).get('nakshatra') weekday_value = weekday if weekday is not None else (panchanga.get('vara') or {}).get('weekday_idx') if lagna is None or tithi_num is None or current_nakshatra is None or weekday_value is None: missing.append('panchaka_terms') factors.append({'factor_id': 'panchaka_activity_gate', 'status': 'input_required', 'delta': 0}) else: panchaka = calc_panchaka(tithi_num, int(weekday_value), current_nakshatra, lagna, activity=activity) if panchaka['quality'] == 'good': delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['panchaka_good'] elif panchaka['avoided_for_activity']: delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['panchaka_activity_avoid'] else: delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['panchaka_bad'] score += delta factors.append({'factor_id': 'panchaka_activity_gate', 'actual': panchaka, 'delta': delta}) if birth_nakshatra is None or current_nakshatra is None: missing.append('tarabala_native_birth_nakshatra') factors.append({'factor_id': 'tarabala_gate', 'status': 'input_required', 'delta': 0}) else: tara = calc_tarabala(birth_nakshatra, current_nakshatra, activity=activity) if tara['quality'] in {'good', 'activity_exception_usable'}: delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['tarabala_good'] elif tara['quality'] == 'mildly_unfavourable': delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['tarabala_mild'] else: delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['tarabala_bad'] score += delta factors.append({'factor_id': 'tarabala_gate', 'actual': tara, 'delta': delta}) if birth_moon_sign is None or current_moon_sign is None: missing.append('chandrabala_native_moon_sign') factors.append({'factor_id': 'chandrabala_gate', 'status': 'input_required', 'delta': 0}) else: chandra = calc_chandrabala(birth_moon_sign, current_moon_sign) delta = ( RAMAN_ACTIVITY_SCORE_WEIGHTS['chandrabala_bad'] if chandra['avoided'] else RAMAN_ACTIVITY_SCORE_WEIGHTS['chandrabala_good'] ) score += delta factors.append({'factor_id': 'chandrabala_gate', 'actual': chandra, 'delta': delta}) if weekday_value is not None and tithi_num is not None and current_nakshatra is not None: siddha = calc_siddha_yoga(int(weekday_value), int(tithi_num), current_nakshatra) delta = 0 if siddha['quality'] == 'amrita_siddha': delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['amrita_siddha'] elif siddha['quality'] == 'siddha': delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['siddha'] score += delta factors.append({'factor_id': 'siddha_yoga', 'actual': siddha, 'delta': delta}) else: missing.append('siddha_yoga_terms') if in_durmuhurtha: delta = RAMAN_ACTIVITY_SCORE_WEIGHTS['durmuhurtha'] score += delta factors.append({'factor_id': 'durmuhurtha_avoidance', 'status': 'inside_durmuhurtha', 'delta': delta}) else: factors.append({'factor_id': 'durmuhurtha_avoidance', 'status': 'not_flagged', 'delta': 0}) activity_modifier = _score_raman_activity_specific_modifier( activity=activity, lagna=lagna, tithi_num=tithi_num, weekday_value=weekday_value, current_nakshatra=current_nakshatra, birth_nakshatra=birth_nakshatra, transaction_type=transaction_type, urgent_journey=urgent_journey, lagna_lord_seventh_lord_relationship=lagna_lord_seventh_lord_relationship, current_moon_sign=current_moon_sign, jupiter_or_venus_support=jupiter_or_venus_support, navamsa_lagna=navamsa_lagna, eighth_house_vacant=eighth_house_vacant, malefics_in_upachayas=malefics_in_upachayas, benefics_in_kendras=benefics_in_kendras, moon_strong=moon_strong, weekday_lord_in_lagna=weekday_lord_in_lagna, ) score += activity_modifier['delta'] factors.append(activity_modifier) missing.extend(activity_modifier['missing_inputs']) if election_chart is None: missing.append('election_chart_for_structural_dosha') factors.append({'factor_id': 'election_chart_dosha', 'status': 'input_required', 'delta': 0}) else: weight_packet = evaluate_muhurta_cancellation_weight_packet( election_chart, activity=activity, native_lagna=native_lagna, weekday=weekday_value, ) delta = int( weight_packet['severity_total'] * RAMAN_ACTIVITY_SCORE_WEIGHTS['structural_dosha'] + weight_packet['applied_cancellation_weight'] * RAMAN_ACTIVITY_SCORE_WEIGHTS['structural_cancellation'] ) delta = max(-20, min(20, delta)) score += delta factors.append({'factor_id': 'election_chart_dosha_cancellation', 'actual': weight_packet, 'delta': delta}) if weight_packet.get('blocked'): missing.append('partial_election_chart_structural_fields') bounded_score = max(0, min(100, int(round(score)))) return { 'schema': 'muhurta.raman_activity_beta_score.v1', 'status': 'beta_score_ready' if not missing else 'beta_score_with_input_gaps', 'activity': activity, 'score': bounded_score, 'band': _score_band(bounded_score, len(missing)), 'weights_profile': 'raman_product_beta_v1', 'weights': dict(RAMAN_ACTIVITY_SCORE_WEIGHTS), 'factors': factors, 'missing_inputs': missing, 'product_gate': { 'beta_activity_score_allowed': True, 'final_truth_claim_allowed': False, 'worked_example_calibration_status': 'internal_replay_calibrated_external_final_verdict_examples_pending', }, 'boundary': 'Raman-backed product beta score for ranking activity windows; not a final electional certainty claim.', } def _jd_for_local_time(year: int, month: int, day: int, local_hours: float, tz: float) -> Optional[float]: try: import swisseph as swe return swe.julday(year, month, day, local_hours - tz) except Exception: return None def _local_time_from_jd(jd: float, tz: float) -> str: local_dt = datetime(2000, 1, 1) + timedelta(days=jd - 2451544.5, hours=tz) return local_dt.strftime('%Y-%m-%d %H:%M') def _swisseph_sun_moon_lon(jd: float, ayanamsa_name: str = DEFAULT_AYANAMSA_NAME) -> Optional[Tuple[float, float]]: try: import swisseph as swe flags = sidereal_flags(swe, ayanamsa_name) sun = swe.calc_ut(jd, swe.SUN, flags)[0][0] % 360 moon = swe.calc_ut(jd, swe.MOON, flags)[0][0] % 360 return sun, moon except Exception: return None def _find_next_boundary( jd_start: float, value_func, boundary_size: float, max_days: float, samples: int = 96, ) -> Optional[float]: start_value = value_func(jd_start) if start_value is None: return None current_slot = int(start_value / boundary_size) previous_jd = jd_start previous_value = start_value step = max_days / samples for idx in range(1, samples + 1): candidate_jd = jd_start + step * idx candidate_value = value_func(candidate_jd) if candidate_value is None: return None if int(candidate_value / boundary_size) != current_slot or candidate_value < previous_value: low = previous_jd high = candidate_jd for _ in range(32): mid = (low + high) / 2 mid_value = value_func(mid) if mid_value is None: return None if int(mid_value / boundary_size) == current_slot and mid_value >= start_value: low = mid else: high = mid return high previous_jd = candidate_jd previous_value = candidate_value return None def calc_panchanga_end_times( year: int, month: int, day: int, tz: float, local_hours: float, ayanamsa_name: str = DEFAULT_AYANAMSA_NAME, ) -> Optional[Dict]: """Calculate current Tithi/Nakshatra/Yoga end times from SwissEph positions.""" jd_start = _jd_for_local_time(year, month, day, local_hours, tz) if jd_start is None: return None def tithi_value(jd: float) -> Optional[float]: positions = _swisseph_sun_moon_lon(jd, ayanamsa_name=ayanamsa_name) if not positions: return None sun, moon = positions return (moon - sun) % 360 def nakshatra_value(jd: float) -> Optional[float]: positions = _swisseph_sun_moon_lon(jd, ayanamsa_name=ayanamsa_name) if not positions: return None return positions[1] % 360 def yoga_value(jd: float) -> Optional[float]: positions = _swisseph_sun_moon_lon(jd, ayanamsa_name=ayanamsa_name) if not positions: return None sun, moon = positions return (sun + moon) % 360 boundaries = { 'tithi': _find_next_boundary(jd_start, tithi_value, TITHI_BOUNDARY_DEGREES, 1.6), 'nakshatra': _find_next_boundary(jd_start, nakshatra_value, NAKSHATRA_BOUNDARY_DEGREES, 1.4), 'yoga': _find_next_boundary(jd_start, yoga_value, YOGA_BOUNDARY_DEGREES, 1.4), } result = {'policy': 'swisseph_boundary_bisection', 'reference_local_time': _local_time_from_jd(jd_start, tz)} for key, boundary_jd in boundaries.items(): if boundary_jd is None: continue result[key] = { 'ends_at': _local_time_from_jd(boundary_jd, tz), 'hours_from_reference': round((boundary_jd - jd_start) * 24.0, 2), } return result def _normalize_name(value: object) -> str: return str(value or '').strip() def _push_tag(tags: List[Dict], key: str, label: str, tag_type: str, guidance: str) -> None: if any(tag.get('key') == key for tag in tags): return tags.append({ 'key': key, 'label': label, 'type': tag_type, 'guidance': guidance, }) def classify_vrata_tags( tithi: Dict, nakshatra: Optional[Dict] = None, vara: Optional[Dict] = None, ) -> List[Dict]: """Classify conservative vrata/festival observance tags from Panchanga limbs. Precise named festivals often require lunar month/masa and local sunrise rules. Tags that cannot be confirmed from the current data are explicitly marked as candidates so the UI can guide users without overstating precision. """ tithi_num = int(tithi.get('tithi_num') or 0) paksha = _normalize_name(tithi.get('paksha')) name = _normalize_name(tithi.get('name')) nak_name = _normalize_name((nakshatra or {}).get('nakshatra') or (nakshatra or {}).get('name')) vara_name = _normalize_name((vara or {}).get('vara') or (vara or {}).get('name')) tags = [] if tithi_num in (11, 26): _push_tag( tags, 'ekadashi', f'{paksha} Ekadashi'.strip(), 'fasting', 'Commonly observed as a fasting and spiritual practice day.', ) if tithi_num in (13, 28): pradosham_label = f'{paksha} Pradosham'.strip() if vara_name == 'Monday': pradosham_label = f'Soma {pradosham_label}' elif vara_name == 'Saturday': pradosham_label = f'Shani {pradosham_label}' _push_tag( tags, 'pradosham', pradosham_label, 'observance', 'Pradosham is traditionally associated with Shiva worship near dusk.', ) if tithi_num == 15 or name == 'Purnima': _push_tag( tags, 'purnima', 'Purnima', 'lunar', 'Full moon observance; often used for vrata, puja and spiritual practices.', ) if tithi_num == 30 or name == 'Amavasya': _push_tag( tags, 'amavasya', 'Amavasya', 'lunar', 'New moon observance; often used for ancestral rites and inward practice.', ) if tithi_num in (4, 19): _push_tag( tags, 'chaturthi_vrata', f'{paksha} Chaturthi'.strip(), 'vrata', 'Chaturthi is commonly associated with Ganapati observances; confirm local sunrise rule for exact vrata.', ) if tithi_num in (6, 21): _push_tag( tags, 'shashthi_vrata', f'{paksha} Shashthi'.strip(), 'vrata', 'Shashthi is used for Skanda/Subrahmanya observances in many Panchanga traditions.', ) if tithi_num in (8, 23): _push_tag( tags, 'ashtami_vrata', f'{paksha} Ashtami'.strip(), 'vrata', 'Ashtami is a common vrata and Devi/Krishna observance marker; festival name depends on lunar month.', ) if tithi_num in (9, 24): _push_tag( tags, 'navami_observance', f'{paksha} Navami'.strip(), 'observance', 'Navami can become a major festival marker in the right lunar month; treat as a condition for deeper lookup.', ) if tithi_num == 3 and paksha == 'Shukla': _push_tag( tags, 'akshaya_tritiya_candidate', 'Shukla Tritiya festival candidate', 'festival_candidate', 'May indicate Akshaya Tritiya only when the lunar month is Vaishakha; confirm masa before final reporting.', ) if tithi_num == 29 and paksha == 'Krishna': _push_tag( tags, 'shivaratri_candidate', 'Krishna Chaturdashi/Shivaratri candidate', 'festival_candidate', 'Monthly Shivaratri marker; Maha Shivaratri requires Phalguna/Magha month confirmation by tradition.', ) if nak_name == 'Pushya': _push_tag( tags, 'pushya_nakshatra', 'Pushya Nakshatra', 'nakshatra', 'Pushya is widely treated as a strong auspicious nakshatra for learning, purchases and counsel.', ) if vara_name == 'Thursday': _push_tag( tags, 'guru_pushya', 'Guru Pushya Yoga', 'festival_window', 'Thursday plus Pushya is treated as a highly auspicious purchase/initiation window.', ) if vara_name == 'Sunday': _push_tag( tags, 'ravi_pushya', 'Ravi Pushya Yoga', 'festival_window', 'Sunday plus Pushya is a favored window for acquisition and auspicious beginnings.', ) if nak_name == 'Rohini': _push_tag( tags, 'rohini_nakshatra', 'Rohini Nakshatra', 'nakshatra', 'Rohini is a fixed, growth-oriented nakshatra often used for stable starts.', ) return tags def classify_panchanga_condition_tags(report: Dict, activity: Optional[str] = None) -> List[Dict]: """Return searchable product tags for Panchanga calendar rows.""" panchanga = report.get('panchanga') or {} summary = report.get('summary') or {} warnings = summary.get('warnings') or panchanga.get('warnings') or [] vrata_tags = report.get('vrata_tags') or [] checks = report.get('activity_checks') or {} choghadiya = report.get('choghadiya') or {} tags: List[Dict] = [] def add(key: str, label: str, guidance: str = '') -> None: _push_tag(tags, key, label, 'condition', guidance) if vrata_tags: add('has_vrata', 'Vrata/observance day', 'At least one vrata, lunar observance or festival-candidate tag is present.') if any(tag.get('type') == 'festival_candidate' for tag in vrata_tags): add('festival_candidate', 'Festival candidate', 'Needs lunar month or tradition-specific confirmation before final naming.') if any(tag.get('type') in {'fasting', 'observance', 'lunar', 'vrata'} for tag in vrata_tags): add('spiritual_practice', 'Spiritual practice day', 'Useful for fasting, puja, mantra, vrata or inward observance.') if summary.get('best_activities'): add('auspicious_activity', 'Auspicious for selected/common activity', 'At least one activity check is favorable.') if summary.get('avoid_activities') or warnings: add('avoid_new_start', 'Avoid important new starts', 'One or more Panchanga factors or activity checks advise caution.') if any(item.get('quality') == 'auspicious' for segment in ('day', 'night') for item in choghadiya.get(segment, []) or []): add('good_choghadiya', 'Has auspicious Choghadiya window', 'At least one Amrit/Shubh/Labh Choghadiya window is available.') if activity and activity in checks: verdict = checks[activity].get('verdict', '') if '不宜' in verdict or 'Avoid' in verdict: add('selected_activity_avoid', 'Selected activity should be avoided', 'The chosen activity has a negative Panchanga verdict.') elif '吉' in verdict or 'Good' in verdict or 'Excellent' in verdict: add('selected_activity_good', 'Selected activity is favorable', 'The chosen activity has a favorable Panchanga verdict.') return tags def build_festival_details(report: Dict) -> List[Dict]: """Build user-facing explanations for vrata and festival candidate tags.""" panchanga = report.get('panchanga') or {} tithi = panchanga.get('tithi') or {} nakshatra = panchanga.get('nakshatra') or {} vara = panchanga.get('vara') or {} details = [] for tag in report.get('vrata_tags') or []: detail_type = tag.get('type') or 'observance' requires_confirmation = detail_type == 'festival_candidate' details.append({ 'key': tag.get('key'), 'label': tag.get('label') or tag.get('key'), 'type': detail_type, 'guidance': tag.get('guidance') or '', 'basis': [ f"Tithi: {tithi.get('full_name') or tithi.get('name') or '-'}", f"Nakshatra: {nakshatra.get('nakshatra') or nakshatra.get('name') or '-'}", f"Vara: {vara.get('vara') or vara.get('name') or '-'}", ], 'requires_confirmation': requires_confirmation, 'confirmation_note': ( 'Needs lunar masa, local sunrise observance rule and tradition-specific calendar confirmation.' if requires_confirmation else 'Conservative Panchanga tag derived from tithi/nakshatra/vara; confirm local tradition for formal observance.' ), }) return details def build_panchanga_search_summary(rows: List[Dict], activity: Optional[str] = None) -> Dict: """Summarize searchable Panchanga conditions for same-category calendar UX.""" condition_counts: Dict[str, int] = {} festival_candidates = [] spiritual_days = [] good_activity_days = [] caution_days = [] for row in rows: date = row.get('query_date') for tag in row.get('condition_tags') or []: key = tag.get('key') if key: condition_counts[key] = condition_counts.get(key, 0) + 1 if any(tag.get('type') == 'festival_candidate' for tag in row.get('vrata_tags') or []): festival_candidates.append(date) if any(tag.get('key') == 'spiritual_practice' for tag in row.get('condition_tags') or []): spiritual_days.append(date) if any(tag.get('key') in {'auspicious_activity', 'selected_activity_good'} for tag in row.get('condition_tags') or []): good_activity_days.append(date) if any(tag.get('key') in {'avoid_new_start', 'selected_activity_avoid'} for tag in row.get('condition_tags') or []): caution_days.append(date) return { 'mode': 'range_condition_index', 'activity': activity or 'all', 'condition_counts': condition_counts, 'festival_candidate_dates': festival_candidates, 'spiritual_practice_dates': spiritual_days, 'auspicious_activity_dates': good_activity_days, 'avoid_new_start_dates': caution_days, 'query_examples': [ 'festival_candidate + spiritual_practice', 'auspicious_activity + good_choghadiya', 'avoid_new_start', ], } def build_panchanga_month_grid(rows: List[Dict], start_dt: datetime, end_dt: datetime) -> List[List[Optional[Dict]]]: """Build calendar-week rows for single-month ranges, leaving blanks outside the selected month.""" if start_dt.year != end_dt.year or start_dt.month != end_dt.month: return [] by_date = {row.get('query_date'): row for row in rows} weeks = [] for week in calendar.Calendar(firstweekday=6).monthdatescalendar(start_dt.year, start_dt.month): week_cells = [] for current in week: if current.month != start_dt.month: week_cells.append(None) continue row = by_date.get(current.strftime('%Y-%m-%d')) if not row: week_cells.append(None) continue panchanga = row.get('panchanga') or {} periods = row.get('inauspicious_periods') or {} end_times = row.get('end_times') or {} week_cells.append({ 'date': row.get('query_date'), 'day': current.day, 'quality': (row.get('summary') or {}).get('overall_quality') or panchanga.get('overall_quality'), 'score': (row.get('summary') or {}).get('overall_score', panchanga.get('overall_score')), 'tithi': (panchanga.get('tithi') or {}).get('full_name') or (panchanga.get('tithi') or {}).get('name'), 'nakshatra': (panchanga.get('nakshatra') or {}).get('nakshatra') or (panchanga.get('nakshatra') or {}).get('name'), 'yoga': (panchanga.get('yoga') or {}).get('yoga') or (panchanga.get('yoga') or {}).get('name'), 'best_activities': (row.get('summary') or {}).get('best_activities') or [], 'avoid_activities': (row.get('summary') or {}).get('avoid_activities') or [], 'vrata_tags': row.get('vrata_tags') or [], 'condition_tags': row.get('condition_tags') or [], 'tithi_ends_at': (end_times.get('tithi') or {}).get('ends_at'), 'nakshatra_ends_at': (end_times.get('nakshatra') or {}).get('ends_at'), 'best_choghadiya': [ item for item in ((row.get('choghadiya') or {}).get('day') or []) if item.get('quality') == 'auspicious' ][:2], 'sunrise': periods.get('sunrise'), 'sunset': periods.get('sunset'), }) weeks.append(week_cells) return weeks def calc_daytime_inauspicious_periods( weekday: int, sunrise: str = '06:00', sunset: str = '18:00', ) -> Dict: """ Calculate daytime Rahu Kala, Yamaganda and Gulika using the traditional eight-part daytime division. Times are local clock HH:MM values. """ sunrise_h, sunrise_m = _parse_hhmm(sunrise, '06:00') sunset_h, sunset_m = _parse_hhmm(sunset, '18:00') sunrise_min = sunrise_h * 60 + sunrise_m sunset_min = sunset_h * 60 + sunset_m weekday = weekday % 7 return { 'policy': 'daytime_8_segments_local_clock', 'sunrise': f'{sunrise_h:02d}:{sunrise_m:02d}', 'sunset': f'{sunset_h:02d}:{sunset_m:02d}', 'rahu_kala': { **_segment_window(RAHU_KALA_SEGMENTS[weekday], sunrise_min, sunset_min), 'label': 'Rahu Kala', 'guidance': 'Avoid important beginnings and commitments.', }, 'yamaganda': { **_segment_window(YAMAGANDA_SEGMENTS[weekday], sunrise_min, sunset_min), 'label': 'Yamaganda', 'guidance': 'Avoid travel starts and high-stakes launches.', }, 'gulika': { **_segment_window(GULIKA_SEGMENTS[weekday], sunrise_min, sunset_min), 'label': 'Gulika', 'guidance': 'Use caution; traditionally treated as Saturnine.', }, } def calc_panchanga(sun_lon: float, moon_lon: float, weekday: int, hour_from_sunrise: float = 6.0) -> Dict: """ 计算 Panchanga 五要素(所有输入均为恒星坐标,具体 ayanamsa 由上游决定)。 参数: sun_lon: 太阳恒星黄经 moon_lon: 月亮恒星黄经 weekday: 0=Sun, ..., 6=Sat hour_from_sunrise: 从日出起算的小时数(默认 6h,约正午) """ tithi = calc_tithi(sun_lon, moon_lon) nakshatra = calc_nakshatra_from_lon(moon_lon) yoga = calc_yoga(sun_lon, moon_lon) karana = calc_karana(sun_lon, moon_lon) vara = calc_vara(weekday) hora = calc_hora(weekday, hour_from_sunrise) # 综合吉凶评分 elements = [ ('Tithi', tithi['quality']), ('Vara', vara['quality']), ('Nakshatra', nakshatra['quality']), ('Yoga', yoga['quality']), ('Karana', karana['quality']), ('Hora', hora['quality']), ] score_map = {'subha': 1.0, 'mixed': 0.5, 'asubha': 0.0} total_score = sum(score_map[q] for _, q in elements) max_score = len(elements) score_pct = total_score / max_score if score_pct >= 0.75: overall = '吉(Subha)' elif score_pct >= 0.5: overall = '中(Mixed)' else: overall = '凶(Asubha)' # 特殊凶时段检查 warnings = [] if karana['is_vishti']: warnings.append('⚠️ Vishti(Bhadra)时段——最凶,避免重要开始') if tithi['name'] == 'Amavasya': warnings.append('⚠️ Amavasya(新月)——不宜开始新事') if nakshatra['type'] == 'tikshna': warnings.append(f'⚠️ {nakshatra["nakshatra"]} 为 Tikshna(尖锐)星宿——不宜立约、开业') if yoga['quality'] == 'asubha': warnings.append(f'⚠️ {yoga["yoga"]} Yoga——不利时段') return { 'tithi': tithi, 'nakshatra': nakshatra, 'yoga': yoga, 'karana': karana, 'vara': vara, 'hora': hora, 'overall_score': round(score_pct, 2), 'overall_quality': overall, 'warnings': warnings, 'auspicious_count': sum(1 for _, q in elements if q == 'subha'), 'total_elements': len(elements), } # ── 活动适宜性规则库 ────────────────────────────────────────────────── ACTIVITY_RULES = { 'marriage': { 'name': '婚礼(Vivaha)', 'good_tithis': [2, 3, 5, 7, 10, 11, 12, 13, 15], # 吉 Tithi 'bad_tithis': [4, 8, 9, 14, 29, 30], 'good_nakshatras': ['Rohini', 'Mrigashira', 'Magha', 'Uttara Phalguni', 'Hasta', 'Swati', 'Anuradha', 'Mula', 'Uttara Ashadha', 'Uttara Bhadrapada', 'Revati'], 'bad_nakshatras': ['Bharani', 'Ardra', 'Ashlesha', 'Jyeshtha'], 'good_varas': ['Monday', 'Wednesday', 'Friday', 'Thursday'], 'bad_varas': ['Tuesday', 'Saturday'], }, 'business': { 'name': '开业/签约(Vyapar)', 'good_tithis': [2, 3, 5, 7, 10, 11, 12], 'bad_tithis': [4, 8, 9, 14, 29, 30], 'good_nakshatras': ['Ashwini', 'Rohini', 'Mrigashira', 'Punarvasu', 'Pushya', 'Hasta', 'Chitra', 'Swati', 'Anuradha', 'Shravana', 'Dhanishtha', 'Revati'], 'bad_nakshatras': ['Bharani', 'Ardra', 'Ashlesha', 'Magha', 'Mula', 'Purva Ashadha', 'Purva Phalguni', 'Purva Bhadrapada'], 'good_varas': ['Monday', 'Wednesday', 'Thursday', 'Friday'], 'bad_varas': ['Tuesday', 'Saturday', 'Sunday'], }, 'travel': { 'name': '出行(Yatra)', 'good_tithis': [2, 3, 5, 7, 10, 12], 'bad_tithis': [4, 8, 9, 14, 30], 'good_nakshatras': ['Ashwini', 'Mrigashira', 'Punarvasu', 'Pushya', 'Hasta', 'Chitra', 'Swati', 'Shravana', 'Revati'], 'bad_nakshatras': ['Bharani', 'Ardra', 'Ashlesha', 'Jyeshtha', 'Mula'], 'good_varas': ['Monday', 'Wednesday', 'Thursday', 'Friday'], 'bad_varas': ['Tuesday', 'Saturday'], }, 'medical': { 'name': '手术/医疗(Chikitsa)', 'good_tithis': [1, 2, 3, 5, 6, 7, 10, 11, 12], 'bad_tithis': [8, 9, 13, 14, 30], 'good_nakshatras': ['Ashwini', 'Mrigashira', 'Pushya', 'Hasta', 'Anuradha'], 'bad_nakshatras': ['Ardra', 'Ashlesha', 'Jyeshtha', 'Mula', 'Vishakha'], 'good_varas': ['Monday', 'Wednesday', 'Thursday'], 'bad_varas': ['Tuesday', 'Saturday', 'Sunday'], }, 'education': { 'name': '学习/入学(Vidyarambha)', 'good_tithis': [2, 3, 5, 7, 10, 11, 12], 'bad_tithis': [4, 6, 8, 9, 14, 29, 30], 'good_nakshatras': ['Ashwini', 'Mrigashira', 'Punarvasu', 'Pushya', 'Hasta', 'Chitra', 'Swati', 'Shravana', 'Revati'], 'bad_nakshatras': ['Bharani', 'Ardra', 'Ashlesha', 'Magha', 'Mula'], 'good_varas': ['Monday', 'Wednesday', 'Thursday', 'Friday'], 'bad_varas': ['Tuesday', 'Saturday'], } } def check_activity_muhurta( panchanga: Dict, activity: str, *, lagna: Optional[object] = None, birth_nakshatra: Optional[object] = None, birth_moon_sign: Optional[object] = None, ) -> Dict: """ 检查给定 Panchanga 是否适合特定活动。 activity: 'marriage', 'business', 'travel', 'medical', 'education' """ rules = ACTIVITY_RULES.get(activity) if not rules: return {'error': f'未知活动类型: {activity}。支持: {list(ACTIVITY_RULES.keys())}'} tithi_num = panchanga['tithi']['tithi_num'] nakshatra = panchanga['nakshatra']['nakshatra'] nakshatra_idx = panchanga['nakshatra'].get('nakshatra_idx') vara = panchanga['vara']['vara'] weekday_idx = panchanga['vara'].get('weekday_idx', 0) # Tithi 评估 if tithi_num in rules['good_tithis']: tithi_score = 'good' elif tithi_num in rules['bad_tithis']: tithi_score = 'bad' else: tithi_score = 'neutral' # Nakshatra 评估 if nakshatra in rules['good_nakshatras']: nakshatra_score = 'good' elif nakshatra in rules['bad_nakshatras']: nakshatra_score = 'bad' else: nakshatra_score = 'neutral' # Vara 评估 if vara in rules['good_varas']: vara_score = 'good' elif vara in rules['bad_varas']: vara_score = 'bad' else: vara_score = 'neutral' panchaka = None panchaka_score = 'blocked' if lagna is not None: panchaka = calc_panchaka(tithi_num, weekday_idx, nakshatra_idx, lagna, activity=activity) if panchaka['avoided_for_activity']: panchaka_score = 'bad' elif panchaka['quality'] == 'good': panchaka_score = 'good' else: panchaka_score = 'neutral' else: panchaka = { 'status': 'input_required', 'missing_fields': ['lagna'], 'source_rule_ids': [ 'raman_muhurtha_panchaka_formula', 'raman_muhurtha_activity_panchaka_exceptions', ], } native_contract = build_tarabala_chandrabala_input_contract( birth_nakshatra=birth_nakshatra, current_nakshatra=nakshatra_idx, birth_moon_sign=birth_moon_sign, current_moon_sign=int((panchanga['nakshatra']['moon_lon'] % 360) // 30), ) tarabala = None chandrabala = None if birth_nakshatra is not None: tarabala = calc_tarabala(birth_nakshatra, nakshatra_idx, activity=activity) if birth_moon_sign is not None: chandrabala = calc_chandrabala(birth_moon_sign, int((panchanga['nakshatra']['moon_lon'] % 360) // 30)) scores = [tithi_score, nakshatra_score, vara_score] if panchaka_score != 'blocked': scores.append(panchaka_score) if tarabala and tarabala.get('avoided_for_high_stakes_activity'): scores.append('bad') good_count = scores.count('good') bad_count = scores.count('bad') if bad_count >= 2: verdict = '不宜(Avoid)' elif good_count >= 2 and bad_count == 0: verdict = '大吉(Excellent)' elif good_count >= 1 and bad_count == 0: verdict = '吉(Good)' elif bad_count == 1 and good_count >= 1: verdict = '一般(Fair)' else: verdict = '中(Neutral)' return { 'activity': rules['name'], 'tithi_eval': tithi_score, 'nakshatra_eval': nakshatra_score, 'vara_eval': vara_score, 'panchaka_eval': panchaka_score, 'panchaka': panchaka, 'native_input_contract': native_contract, 'tarabala': tarabala, 'chandrabala': chandrabala, 'verdict': verdict, 'good_count': good_count, 'bad_count': bad_count, 'notes': _get_activity_notes(rules, tithi_num, nakshatra, vara, panchaka, tarabala, native_contract) } def _get_activity_notes(rules: Dict, tithi_num: int, nakshatra: str, vara: str, panchaka: Optional[Dict] = None, tarabala: Optional[Dict] = None, native_contract: Optional[Dict] = None) -> List[str]: notes = [] if tithi_num in rules['bad_tithis']: notes.append(f'Tithi {tithi_num} 不宜此类活动') if nakshatra in rules['bad_nakshatras']: notes.append(f'{nakshatra} 星宿不利此类活动') if vara in rules['bad_varas']: notes.append(f'{vara} 不利此类活动') if tithi_num in rules['good_tithis']: notes.append(f'Tithi {tithi_num} 有利此类活动') if nakshatra in rules['good_nakshatras']: notes.append(f'{nakshatra} 星宿适合此类活动') if vara in rules['good_varas']: notes.append(f'{vara} 有利此类活动') if panchaka: if panchaka.get('status') == 'input_required': notes.append('Panchaka 需要 Lagna 才能按 Raman 公式判定') elif panchaka.get('avoided_for_activity'): notes.append(f"Panchaka {panchaka.get('dosha')} 按 Raman 活动避忌不利") elif panchaka.get('quality') == 'good': notes.append('Panchaka 余数属于 Raman 可用组') if tarabala and tarabala.get('avoided_for_high_stakes_activity'): notes.append(f"Tarabala {tarabala.get('tara')} 不利重要活动") if native_contract and native_contract.get('status') == 'input_required': notes.append('Tarabala/Chandrabala 需要本命星宿与本命月亮星座后才能完整评分') return notes # ── 完整报告函数 ────────────────────────────────────────────────────── def muhurta_full_report( sun_lon: float, moon_lon: float, weekday: int, hour_from_sunrise: float = 6.0, query_date_str: Optional[str] = None, activities: Optional[List[str]] = None, ) -> Dict: """ 生成完整 Muhurta 报告。 参数: sun_lon: 太阳恒星黄经(0-360,具体 ayanamsa 由上游决定) moon_lon: 月亮恒星黄经(0-360,具体 ayanamsa 由上游决定) weekday: 0=Sun, ..., 6=Sat hour_from_sunrise: 从日出起算的小时(默认 6h = 约正午) query_date_str: 查询日期字符串(用于展示) activities: 要检查的活动列表(默认检查所有) """ panchanga = calc_panchanga(sun_lon, moon_lon, weekday, hour_from_sunrise) abhijit = calc_abhijit_muhurta() if activities is None: activities = list(ACTIVITY_RULES.keys()) activity_checks = {} for act in activities: activity_checks[act] = check_activity_muhurta(panchanga, act) return { 'query_date': query_date_str or 'unknown', 'panchanga': panchanga, 'abhijit_muhurta': abhijit, 'activity_checks': activity_checks, 'summary': { 'overall_quality': panchanga['overall_quality'], 'overall_score': panchanga['overall_score'], 'auspicious_elements': panchanga['auspicious_count'], 'warnings': panchanga['warnings'], 'best_activities': [ act for act, chk in activity_checks.items() if '吉' in chk.get('verdict', '') or 'Good' in chk.get('verdict', '') ], 'avoid_activities': [ act for act, chk in activity_checks.items() if '不宜' in chk.get('verdict', '') or 'Avoid' in chk.get('verdict', '') ] } } def panchanga_range_report( start_date: str, end_date: str, hour_from_sunrise: float = 6.0, sunrise: str = '06:00', sunset: str = '18:00', activity: Optional[str] = None, lat: Optional[float] = None, lon: Optional[float] = None, tz: Optional[float] = None, ayanamsa_name: str = DEFAULT_AYANAMSA_NAME, ) -> Dict: """Build a date-range Panchanga calendar with daytime inauspicious windows.""" start_dt = datetime.strptime(start_date[:10], '%Y-%m-%d') end_dt = datetime.strptime(end_date[:10], '%Y-%m-%d') if end_dt < start_dt: raise ValueError('end_date must be on or after start_date') days = (end_dt - start_dt).days + 1 rows = [] solar_policies = set() panchanga_policies = set() for offset in range(days): current = start_dt + timedelta(days=offset) date_str = current.strftime('%Y-%m-%d') weekday = (current.weekday() + 1) % 7 sun_lon, moon_lon = _approx_sun_moon_lon(current.year, current.month, current.day) panchanga_policy = 'approximate Sun/Moon sidereal longitudes from local muhurta kernel' solar_times = None end_times = None day_sunrise = sunrise day_sunset = sunset if lat is not None and lon is not None and tz is not None: solar_times = calc_sunrise_sunset_local(current.year, current.month, current.day, lat, lon, tz) solar_policies.add(solar_times.get('policy', 'unknown')) day_sunrise = solar_times['sunrise'] day_sunset = solar_times['sunset'] sunrise_local_hours = solar_times.get('sunrise_local_hours') if sunrise_local_hours is None: sunrise_h, sunrise_m = _parse_hhmm(day_sunrise, '06:00') sunrise_local_hours = sunrise_h + sunrise_m / 60.0 reference_local_hours = float(sunrise_local_hours) + hour_from_sunrise reference_jd = _jd_for_local_time(current.year, current.month, current.day, reference_local_hours, tz) positions = _swisseph_sun_moon_lon(reference_jd, ayanamsa_name=ayanamsa_name) if reference_jd is not None else None if positions: sun_lon, moon_lon = positions panchanga_policy = 'SwissEph Lahiri at sunrise-relative reference time' end_times = calc_panchanga_end_times( current.year, current.month, current.day, tz, reference_local_hours, ayanamsa_name=ayanamsa_name, ) panchanga_policies.add(panchanga_policy) report = muhurta_full_report( sun_lon, moon_lon, weekday, hour_from_sunrise=hour_from_sunrise, query_date_str=date_str, activities=[activity] if activity else None, ) report['inauspicious_periods'] = calc_daytime_inauspicious_periods(weekday, day_sunrise, day_sunset) report['choghadiya'] = calc_choghadiya_windows(weekday, day_sunrise, day_sunset) report['durmuhurtha'] = calc_durmuhurtha_windows(weekday, day_sunrise, day_sunset) report['hora_windows'] = calc_hora_windows(weekday, day_sunrise, day_sunset) panchanga = report['panchanga'] report['vrata_tags'] = classify_vrata_tags( panchanga['tithi'], panchanga.get('nakshatra'), panchanga.get('vara'), ) report['condition_tags'] = classify_panchanga_condition_tags(report, activity=activity) report['festival_details'] = build_festival_details(report) if solar_times: report['solar_times'] = solar_times if end_times: report['end_times'] = end_times rows.append(report) location_supplied = lat is not None and lon is not None and tz is not None sunrise_sunset_policy = 'manual fixed sunrise/sunset inputs' next_precision_step = 'allow city lookup and daylight-saving timezone resolution' if location_supplied: if solar_policies == {'swisseph_rise_trans'}: sunrise_sunset_policy = 'SwissEph rise_trans' next_precision_step = 'add Tithi/Nakshatra end times and festival/vrata rules' elif 'swisseph_rise_trans' in solar_policies: sunrise_sunset_policy = 'mixed SwissEph rise_trans with approximation fallback' next_precision_step = 'investigate dates/locations that required approximation fallback' else: sunrise_sunset_policy = 'location-aware solar approximation' next_precision_step = 'replace approximation with SwissEph rise_trans or vetted Panchanga library' return { 'start_date': start_dt.strftime('%Y-%m-%d'), 'end_date': end_dt.strftime('%Y-%m-%d'), 'day_count': len(rows), 'sunrise': sunrise, 'sunset': sunset, 'location': ( {'lat': round(lat, 5), 'lon': round(lon, 5), 'tz': tz} if lat is not None and lon is not None and tz is not None else None ), 'hour_from_sunrise': hour_from_sunrise, 'calculation_policy': { 'panchanga': ( 'SwissEph Lahiri at sunrise-relative reference time' if panchanga_policies == {'SwissEph Lahiri at sunrise-relative reference time'} else 'mixed SwissEph/approximate Panchanga positions' if 'SwissEph Lahiri at sunrise-relative reference time' in panchanga_policies else 'approximate Sun/Moon sidereal longitudes from local muhurta kernel' ), 'inauspicious_periods': 'traditional daytime eight-segment rule; local clock times', 'sunrise_sunset': sunrise_sunset_policy, 'end_times': 'SwissEph boundary bisection when location/timezone are available', 'sub_day_windows': 'traditional Choghadiya and planetary Hora segmented by local sunrise/sunset', 'durmuhurtha': 'Raman p22 day/night 15-segment table, marked as avoidance context until activity scoring replay is complete', 'festival_rules': 'conservative tithi/nakshatra/vara vrata rules; masa-dependent festivals marked as candidates', 'condition_tags': 'searchable row tags derived from vrata, activity verdicts, warnings and sub-day windows', 'next_precision_step': next_precision_step, }, 'search_summary': build_panchanga_search_summary(rows, activity=activity), 'month_grid': build_panchanga_month_grid(rows, start_dt, end_dt), 'days': rows, } def muhurta_range_search( start_date: str, end_date: str, activity: str = 'business', limit: int = 5, hour_from_sunrise: float = 6.0, sunrise: str = '06:00', sunset: str = '18:00', lat: Optional[float] = None, lon: Optional[float] = None, tz: Optional[float] = None, avoid_inauspicious_periods: bool = True, ayanamsa_name: str = DEFAULT_AYANAMSA_NAME, ) -> Dict: """Search a date range for ranked Muhurta candidates for a selected activity.""" limit = max(1, min(int(limit or 5), 20)) activity = activity if activity in ACTIVITY_RULES else 'business' calendar_report = panchanga_range_report( start_date, end_date, hour_from_sunrise=hour_from_sunrise, sunrise=sunrise, sunset=sunset, activity=activity, lat=lat, lon=lon, tz=tz, ayanamsa_name=ayanamsa_name, ) candidates = [] rejected_dates = [] for row in calendar_report.get('days', []): candidate = _build_muhurta_candidate(row, activity, avoid_inauspicious_periods) if candidate['score'] >= 0.45 and candidate['recommended_windows']: candidates.append(candidate) else: rejected_dates.append({ 'date': candidate['date'], 'score': candidate['score'], 'reason': candidate['rejection_reason'], 'activity_verdict': candidate['activity_verdict'], }) candidates.sort(key=lambda item: (item['score'], len(item['recommended_windows'])), reverse=True) for item in candidates[limit:]: rejected_dates.append({ 'date': item['date'], 'score': item['score'], 'reason': 'ranked below selected candidate limit', 'activity_verdict': item['activity_verdict'], }) best_windows = candidates[:limit] return { 'mode': 'muhurta_date_range_solver', 'activity': activity, 'activity_label': ACTIVITY_RULES[activity]['name'], 'date_range': { 'start': calendar_report['start_date'], 'end': calendar_report['end_date'], }, 'candidate_count': len(best_windows), 'scanned_days': calendar_report['day_count'], 'constraints': { 'limit': limit, 'hour_from_sunrise': hour_from_sunrise, 'avoid_inauspicious_periods': bool(avoid_inauspicious_periods), 'sunrise': sunrise, 'sunset': sunset, 'location': calendar_report.get('location'), }, 'best_windows': best_windows, 'rejected_dates': rejected_dates, 'calculation_policy': calendar_report.get('calculation_policy', {}), 'next_action': '把候选日期作为初筛;婚礼、手术、签约等高风险活动仍需结合本命盘、Dasha/Transit 和当地传统规则复核。', } def build_muhurta_sidecar( *, date_str: str, activity: str = 'business', lat: Optional[float] = None, lon: Optional[float] = None, tz: Optional[float] = None, ayanamsa_name: str = DEFAULT_AYANAMSA_NAME, ) -> Dict: """Compact Muhurta/Panchanga packet for unified workflow consumers.""" activity = activity if activity in ACTIVITY_RULES else 'business' search = muhurta_range_search( date_str, date_str, activity=activity, limit=3, lat=lat, lon=lon, tz=tz, ayanamsa_name=ayanamsa_name, ) calendar = panchanga_range_report( date_str, date_str, activity=activity, lat=lat, lon=lon, tz=tz, ayanamsa_name=ayanamsa_name, ) days = calendar.get('days') or [] first_day = days[0] if days else {} return { 'status': 'ok', 'source': 'local_muhurta.py', 'date': date_str, 'activity': activity, 'activity_label': ACTIVITY_RULES[activity]['name'], 'report_mode': search.get('mode', 'muhurta_date_range_solver'), 'panchanga': { 'query_date': first_day.get('query_date', date_str), 'summary': first_day.get('summary') or {}, 'panchanga': first_day.get('panchanga') or {}, 'inauspicious_periods': first_day.get('inauspicious_periods') or [], 'choghadiya': first_day.get('choghadiya') or [], 'hora_windows': first_day.get('hora_windows') or [], 'condition_tags': first_day.get('condition_tags') or [], }, 'best_windows': search.get('best_windows') or [], 'calculation_policy': calendar.get('calculation_policy') or search.get('calculation_policy') or {}, 'next_action': search.get('next_action') or 'Use as timing sidecar only; final judgement still needs chart-based workflow.', } def _build_muhurta_candidate(row: Dict, activity: str, avoid_inauspicious_periods: bool) -> Dict: summary = row.get('summary') or {} panchanga = row.get('panchanga') or {} activity_check = (row.get('activity_checks') or {}).get(activity, {}) recommended_windows = _select_recommended_windows(row, avoid_inauspicious_periods) score = _score_muhurta_candidate(summary, activity_check, recommended_windows) quality = _candidate_quality(score, activity_check) rejection_reason = _candidate_rejection_reason(row, activity_check, recommended_windows) return { 'date': row.get('query_date'), 'score': round(score, 3), 'quality': quality, 'activity_verdict': activity_check.get('verdict', ''), 'recommended_windows': recommended_windows, 'avoid_flags': _build_muhurta_avoid_flags(row, activity_check), 'rejection_reason': rejection_reason, 'evidence': { 'panchanga': { 'tithi': (panchanga.get('tithi') or {}).get('full_name') or (panchanga.get('tithi') or {}).get('name'), 'nakshatra': (panchanga.get('nakshatra') or {}).get('nakshatra'), 'yoga': (panchanga.get('yoga') or {}).get('yoga'), 'vara': (panchanga.get('vara') or {}).get('vara'), 'overall_quality': summary.get('overall_quality') or panchanga.get('overall_quality'), 'overall_score': summary.get('overall_score', panchanga.get('overall_score')), }, 'activity_notes': activity_check.get('notes', []), 'condition_tags': row.get('condition_tags') or [], }, } def _score_muhurta_candidate(summary: Dict, activity_check: Dict, windows: List[Dict]) -> float: score = float(summary.get('overall_score') or 0) verdict = activity_check.get('verdict', '') if 'Excellent' in verdict or '大吉' in verdict: score += 0.22 elif 'Good' in verdict or '吉' in verdict: score += 0.16 elif 'Fair' in verdict or '一般' in verdict: score += 0.06 elif 'Avoid' in verdict or '不宜' in verdict: score -= 0.28 score += min(len(windows), 3) * 0.04 if summary.get('warnings'): score -= min(len(summary.get('warnings') or []), 3) * 0.04 return max(0.0, min(1.0, score)) def _candidate_quality(score: float, activity_check: Dict) -> str: verdict = activity_check.get('verdict', '') if 'Avoid' in verdict or '不宜' in verdict: return 'avoid' if score >= 0.78: return 'excellent' if score >= 0.62: return 'strong' if score >= 0.45: return 'usable' return 'weak' def _select_recommended_windows(row: Dict, avoid_inauspicious_periods: bool) -> List[Dict]: blocked = _blocked_windows(row) if avoid_inauspicious_periods else [] selected = [] for item in (row.get('choghadiya') or {}).get('day') or []: if item.get('quality') not in {'auspicious', 'usable'}: continue if _window_overlaps_any(item, blocked): continue selected.append({ 'type': 'choghadiya', 'name': item.get('name'), 'quality': item.get('quality'), 'start': item.get('start'), 'end': item.get('end'), 'guidance': '优先选择 Amrit/Shubh/Labh;Chal 可作为普通事务备用。', }) if len(selected) >= 3: break if len(selected) < 3: for item in (row.get('hora_windows') or {}).get('day') or []: if item.get('lord') not in {'Jupiter', 'Venus', 'Mercury', 'Moon'}: continue if _window_overlaps_any(item, blocked): continue selected.append({ 'type': 'hora', 'name': f"{item.get('lord')} Hora", 'quality': 'supportive', 'start': item.get('start'), 'end': item.get('end'), 'guidance': '吉星 Hora 可作为活动执行窗口的辅助条件。', }) if len(selected) >= 3: break return selected def _blocked_windows(row: Dict) -> List[Dict]: periods = row.get('inauspicious_periods') or {} return [ periods[key] for key in ('rahu_kala', 'yamaganda', 'gulika') if isinstance(periods.get(key), dict) ] def _build_muhurta_avoid_flags(row: Dict, activity_check: Dict) -> List[str]: flags = [] if 'Avoid' in activity_check.get('verdict', '') or '不宜' in activity_check.get('verdict', ''): flags.append('selected_activity_avoid') if (row.get('summary') or {}).get('warnings'): flags.append('panchanga_warning') if not _select_recommended_windows(row, True): flags.append('no_clean_daytime_window') return flags def _candidate_rejection_reason(row: Dict, activity_check: Dict, windows: List[Dict]) -> str: if 'Avoid' in activity_check.get('verdict', '') or '不宜' in activity_check.get('verdict', ''): return 'selected activity verdict is avoid' if not windows: return 'no clean daytime Choghadiya/Hora window outside Rahu Kala/Yamaganda/Gulika' if (row.get('summary') or {}).get('warnings'): return 'panchanga warnings reduce confidence' return 'score below solver threshold' def _window_overlaps_any(window: Dict, blocked: List[Dict]) -> bool: start = _clock_minutes(str(window.get('start') or '00:00'), '00:00') end = _clock_minutes(str(window.get('end') or '00:00'), '00:00') for item in blocked: blocked_start = _clock_minutes(str(item.get('start') or '00:00'), '00:00') blocked_end = _clock_minutes(str(item.get('end') or '00:00'), '00:00') if start < blocked_end and blocked_start < end: return True return False # ── 近似测试函数 ────────────────────────────────────────────────────── def _approx_sun_moon_lon(year: int, month: int, day: int) -> Tuple[float, float]: """ 近似计算太阳/月亮恒星黄经(无 swisseph,精度约 ±2°)。 仅用于测试和展示,不用于精确解盘。 该近似回退公式内部仍使用固定 Lahiri 常数 ≈ 23.85°(2026年),仅用于无 swisseph 的低精度展示。 """ # J2000.0 起的天数 import math jd = 367 * year - int(7 * (year + int((month + 9) / 12)) / 4) + int(275 * month / 9) + day + 1721013.5 d = jd - 2451545.0 # days since J2000.0 # 太阳黄经(热带) M_sun = math.radians(357.5291 + 0.98560028 * d) L_sun = 280.4665 + 0.98564736 * d + 1.9146 * math.sin(M_sun) L_sun = L_sun % 360 # 月亮黄经(热带,简化) L_moon = (218.3165 + 13.175396 * d) % 360 M_moon = math.radians(134.9634 + 13.064993 * d) L_moon = (L_moon + 6.2886 * math.sin(M_moon)) % 360 # 转为恒星(该近似回退路径减去固定 Lahiri 常数 ≈ 23.85°,2026年) ayanamsa = 23.85 sun_sid = (L_sun - ayanamsa) % 360 moon_sid = (L_moon - ayanamsa) % 360 return sun_sid, moon_sid if __name__ == '__main__': import json # 测试:2026-06-04 (Wednesday) y, m, d = 2026, 6, 4 sun_lon, moon_lon = _approx_sun_moon_lon(y, m, d) wd = datetime(y, m, d).weekday() # 0=Mon in Python → convert # Python weekday: 0=Mon, but our VARA_LORDS: 0=Sun # June 4 2026 = Wednesday = Python 2 vara_idx = (wd + 1) % 7 # convert Python weekday to Vara (Sun=0) print(f"=== Muhurta 测试 {y}-{m:02d}-{d:02d} ===") print(f"Sun lon (approx): {sun_lon:.2f}° Moon lon (approx): {moon_lon:.2f}°") print(f"Vara index: {vara_idx} ({VARA_LORDS[vara_idx][0]})") print() result = muhurta_full_report( sun_lon, moon_lon, vara_idx, hour_from_sunrise=6.0, query_date_str=f'{y}-{m:02d}-{d:02d}', ) p = result['panchanga'] print(f"Tithi: {p['tithi']['full_name']} ({p['tithi']['quality']})") print(f"Nakshatra: {p['nakshatra']['nakshatra']} ({p['nakshatra']['quality']})") print(f"Yoga: {p['yoga']['yoga']} ({p['yoga']['quality']})") print(f"Karana: {p['karana']['karana']} ({p['karana']['quality']})") print(f"Vara: {p['vara']['vara']} ({p['vara']['quality']})") print(f"Hora: {p['hora']['hora_lord']} ({p['hora']['quality']})") print() print(f"综合评分: {result['summary']['overall_quality']} ({result['summary']['overall_score']:.0%})") print(f"警告: {result['summary']['warnings']}") print() print("活动适宜性:") for act, chk in result['activity_checks'].items(): print(f" {act}: {chk['verdict']}")