1715 lines
67 KiB
Python
1715 lines
67 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'
|
||
}
|
||
|
||
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: 恒星黄经(Lahiri,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 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_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 _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 = 'lahiri',
|
||
) -> 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 五要素(所有输入均为恒星坐标 Lahiri)。
|
||
|
||
参数:
|
||
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) -> 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']
|
||
vara = panchanga['vara']['vara']
|
||
|
||
# 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'
|
||
|
||
scores = [tithi_score, nakshatra_score, vara_score]
|
||
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,
|
||
'verdict': verdict,
|
||
'good_count': good_count,
|
||
'bad_count': bad_count,
|
||
'notes': _get_activity_notes(rules, tithi_num, nakshatra, vara)
|
||
}
|
||
|
||
|
||
def _get_activity_notes(rules: Dict, tithi_num: int,
|
||
nakshatra: str, vara: str) -> 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} 有利此类活动')
|
||
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: 太阳恒星黄经(Lahiri,0-360)
|
||
moon_lon: 月亮恒星黄经(Lahiri,0-360)
|
||
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 = 'lahiri',
|
||
) -> 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['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',
|
||
'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 = 'lahiri',
|
||
) -> 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_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 Ayanamsa ≈ 23.85°(2026年)
|
||
"""
|
||
# 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 Ayanamsa ≈ 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']}")
|