f224146348
Three-way-merge calculation modules and pl9-export into the product fork while keeping commercial API routes, Raman ayanamsa, and the consultation contract as a keypath superset. Co-authored-by: Cursor <cursoragent@cursor.com>
3361 lines
139 KiB
Python
3361 lines
139 KiB
Python
"""
|
||
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 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 = 'lahiri') -> 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 = 'raman',
|
||
) -> 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 = 'raman',
|
||
) -> 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 = 'raman',
|
||
) -> 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 = 'raman',
|
||
) -> 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']}")
|