test_python_hygiene_budget forbids new root-level scripts/*.py; the brief named scripts/muhurta_search_api.py. Same code at scripts/ondemand_calc/muhurta_search.py; route, CLI import and tests follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
309 lines
13 KiB
Python
309 lines
13 KiB
Python
"""
|
||
cmd_muhurta.py v6.0.21 — muhurta 子命令实现
|
||
|
||
用法:
|
||
python jyotish_engine.py muhurta --date 2026-06-15 --activity marriage
|
||
python jyotish_engine.py muhurta --date 2026-06-15 (全部活动)
|
||
python jyotish_engine.py muhurta --scan-days 7 --activity business (扫描7天)
|
||
|
||
对于完整解盘(full-reading),也会附带当日 Muhurta 基本信息。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
from typing import Dict, Optional, List
|
||
from datetime import datetime, timedelta
|
||
import sys
|
||
import os
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, sidereal_flags
|
||
|
||
|
||
def _weekday_to_vara(py_weekday: int) -> int:
|
||
"""Python weekday (0=Mon) → Vara index (0=Sun)"""
|
||
return (py_weekday + 1) % 7
|
||
|
||
|
||
def _get_sun_moon_lons(year: int, month: int, day: int,
|
||
hour: int = 12, ayanamsa_name: str = DEFAULT_AYANAMSA_NAME) -> tuple:
|
||
"""获取太阳/月亮恒星黄经。优先 swisseph,退化为近似算法。"""
|
||
def _primary_longitude(value):
|
||
if isinstance(value, (list, tuple)):
|
||
return float(value[0])
|
||
return float(value)
|
||
|
||
try:
|
||
import swisseph as swe
|
||
jd_ut = swe.julday(year, month, day, hour)
|
||
flags = sidereal_flags(swe, ayanamsa_name)
|
||
sun_res = swe.calc_ut(jd_ut, swe.SUN, flags)
|
||
moon_res = swe.calc_ut(jd_ut, swe.MOON, flags)
|
||
return _primary_longitude(sun_res[0]), _primary_longitude(moon_res[0]), True
|
||
except Exception as exc:
|
||
logger.warning("cmd_muhurta._get_sun_moon_lons degraded: %s", type(exc).__name__)
|
||
logger.debug("cmd_muhurta._get_sun_moon_lons degraded", exc_info=True)
|
||
# 近似算法
|
||
from muhurta import _approx_sun_moon_lon
|
||
sun_lon, moon_lon = _approx_sun_moon_lon(year, month, day)
|
||
return sun_lon, moon_lon, False
|
||
|
||
|
||
def _format_panchanga_summary(panchanga: Dict) -> List[str]:
|
||
lines = []
|
||
p = panchanga
|
||
lines.append(f" Tithi: {p['tithi']['full_name']} ({p['tithi']['quality']})")
|
||
lines.append(f" Nakshatra: {p['nakshatra']['nakshatra']} [{p['nakshatra']['type']}] ({p['nakshatra']['quality']})")
|
||
lines.append(f" Yoga: {p['yoga']['yoga']} ({p['yoga']['quality']})")
|
||
lines.append(f" Karana: {p['karana']['karana']} ({p['karana']['quality']})")
|
||
lines.append(f" Vara: {p['vara']['vara']} / {p['vara']['vara_lord']} ({p['vara']['quality']})")
|
||
lines.append(f" Hora: {p['hora']['hora_lord']} ({p['hora']['quality']})")
|
||
lines.append(f" 综合评分: {p['overall_quality']} ({p['overall_score']:.0%},吉 {p['auspicious_count']}/{p['total_elements']})")
|
||
if p['warnings']:
|
||
for w in p['warnings']:
|
||
lines.append(f" {w}")
|
||
return lines
|
||
|
||
|
||
def _format_activity_checks(activity_checks: Dict, target_activity: Optional[str] = None) -> List[str]:
|
||
lines = []
|
||
ACTIVITY_NAMES_ZH = {
|
||
'marriage': '婚礼/伴侣(Vivaha)',
|
||
'business': '开业/签约(Vyapar)',
|
||
'travel': '出行(Yatra)',
|
||
'medical': '手术/医疗(Chikitsa)',
|
||
'education': '学习/入学(Vidyarambha)',
|
||
}
|
||
for act, chk in activity_checks.items():
|
||
if target_activity and act != target_activity:
|
||
continue
|
||
zh_name = ACTIVITY_NAMES_ZH.get(act, act)
|
||
verdict = chk.get('verdict', '未知')
|
||
icon = '✅' if '大吉' in verdict or ('吉' in verdict and '不宜' not in verdict) else \
|
||
'⚠️' if '一般' in verdict or '中' in verdict else '❌'
|
||
lines.append(f" {icon} {zh_name}: {verdict}")
|
||
if chk.get('notes'):
|
||
for note in chk['notes'][:3]:
|
||
lines.append(f" → {note}")
|
||
return lines
|
||
|
||
|
||
def _print_location_exact(
|
||
date_str: str,
|
||
activity: Optional[str],
|
||
lat: float,
|
||
lon: float,
|
||
tz: Optional[float],
|
||
timezone_id: Optional[str],
|
||
ayanamsa: Optional[str],
|
||
node_mode: Optional[str],
|
||
time_window: Optional[Dict],
|
||
limit: int,
|
||
) -> int:
|
||
"""给了地点:走 muhurta_search_api,按实际日出与逐时段五要素输出。"""
|
||
from ondemand_calc.muhurta_search import muhurta_search_api, MuhurtaSearchInputError
|
||
|
||
if not ayanamsa or not node_mode:
|
||
print("[ERROR] 给了地点就必须显式给 --ayanamsa 与 --node-mode(不要让默认值替用户选择)")
|
||
return 1
|
||
body = {
|
||
"activity": activity or "business",
|
||
"start_date": date_str,
|
||
"end_date": date_str,
|
||
"lat": lat,
|
||
"lon": lon,
|
||
"ayanamsa": ayanamsa,
|
||
"node_mode": node_mode,
|
||
"limit": limit,
|
||
}
|
||
if tz is not None:
|
||
body["tz"] = tz
|
||
if timezone_id:
|
||
body["timezone_id"] = timezone_id
|
||
if time_window:
|
||
body["time_window"] = time_window
|
||
try:
|
||
result = muhurta_search_api(body)
|
||
except MuhurtaSearchInputError as exc:
|
||
print(f"[ERROR] {exc}")
|
||
return 1
|
||
|
||
print("=" * 64)
|
||
print("🕐 Muhurta 择时分析(地点精确,实际日出与日落)")
|
||
print("=" * 64)
|
||
profile = result.get("profile") or {}
|
||
offset_text = profile.get('first_day_offset')
|
||
policy = profile.get('tz_policy') or 'fixed'
|
||
print(f"地点 {lat}, {lon}|时区策略 {policy}(首日偏移 {offset_text})"
|
||
f"|岁差 {profile.get('ayanamsa')}|交点 {profile.get('node_mode')}")
|
||
print(f"状态 {result.get('calculation_status')}|指纹 {result.get('input_fingerprint', '')[:16]}")
|
||
for day in (result.get("raw") or {}).get("days") or []:
|
||
print()
|
||
if day.get("calculation_status") != "executed":
|
||
print(f"📅 {day.get('date')}:阻塞({day.get('reason')}),没有可用日出,不出结论")
|
||
continue
|
||
print(f"📅 {day.get('date')} 日出 {day.get('sunrise_local')} 日落 {day.get('sunset_local')}")
|
||
windows = day.get("windows") or []
|
||
if not windows:
|
||
print(" 这一天没有候选时段")
|
||
for window in windows:
|
||
if window.get("calculation_status") != "executed":
|
||
print(f" · {window.get('name')} {window.get('start_local')}–{window.get('end_local')}:阻塞")
|
||
continue
|
||
start = window.get("elements_at_start") or {}
|
||
end = window.get("elements_at_end") or {}
|
||
print(f" · {window.get('name')} {window.get('start_local')}–{window.get('end_local')}"
|
||
f"(UTC {window.get('start_utc')}–{window.get('end_utc')})")
|
||
print(f" 起:Tithi {start.get('tithi_full_name')}|Nakshatra {start.get('nakshatra')}"
|
||
f" pada {start.get('nakshatra_pada')}|Yoga {start.get('yoga')}|Karana {start.get('karana')}"
|
||
f"|Hora {start.get('hora')}")
|
||
print(f" 止:Tithi {end.get('tithi_full_name')}|Nakshatra {end.get('nakshatra')}"
|
||
f" pada {end.get('nakshatra_pada')}|Yoga {end.get('yoga')}|Karana {end.get('karana')}"
|
||
f"|Hora {end.get('hora')}")
|
||
for field, change in (window.get("element_changes_within_window") or {}).items():
|
||
print(f" ⚠️ 时段内 {field} 变化:{change.get('from')} → {change.get('to')}(约 {change.get('at_local')})")
|
||
print(f" 判定:起 {window.get('verdict_at_start')}|止 {window.get('verdict_at_end')}"
|
||
f"|本时段 {window.get('window_verdict')}")
|
||
legacy = day.get("legacy_reference") or {}
|
||
if legacy:
|
||
print(f" 旧单点参考(不作最终判定):{legacy.get('quality')} {legacy.get('score')}")
|
||
gaps = result.get("gaps") or []
|
||
if gaps:
|
||
print()
|
||
print("缺口:")
|
||
for gap in gaps:
|
||
print(f" · {gap.get('field')}:{gap.get('reason')}")
|
||
print()
|
||
print("━" * 64)
|
||
print("口径:地点、时区、岁差、交点都由调用方显式给出;五要素在时段起止两端各取一次。")
|
||
print("━" * 64)
|
||
return 0
|
||
|
||
|
||
def cmd_muhurta(args, chart_data: Optional[Dict] = None) -> int:
|
||
"""
|
||
Muhurta 子命令主入口。
|
||
|
||
args 属性:
|
||
args.date: '2026-06-15'(查询日期,默认今天)
|
||
args.activity: 活动类型(可选,默认全部)
|
||
args.scan_days: 扫描天数(可选,>1 时扫描多天)
|
||
args.hour_from_sunrise: 从日出起算的小时(只用于无地点的 legacy 概览)
|
||
args.lat / args.lon / args.tz / args.timezone_id: 地点与时区(给了就做地点精确择时)
|
||
args.ayanamsa / args.node_mode: 口径(给了地点时必填)
|
||
"""
|
||
from muhurta import muhurta_full_report, ACTIVITY_RULES
|
||
|
||
if hasattr(args, 'date') and args.date:
|
||
try:
|
||
query_dt = datetime.strptime(args.date, '%Y-%m-%d')
|
||
except ValueError:
|
||
print(f"[ERROR] 日期格式错误: {args.date},请用 YYYY-MM-DD")
|
||
return 1
|
||
else:
|
||
query_dt = datetime.now()
|
||
|
||
hour_from_sunrise = getattr(args, 'hour_from_sunrise', 6.0)
|
||
target_activity = getattr(args, 'activity', None)
|
||
scan_days = getattr(args, 'scan_days', 1)
|
||
lat = getattr(args, 'lat', None)
|
||
lon = getattr(args, 'lon', None)
|
||
tz = getattr(args, 'tz', None)
|
||
timezone_id = getattr(args, 'timezone_id', None)
|
||
ayanamsa = getattr(args, 'ayanamsa', None)
|
||
node_mode = getattr(args, 'node_mode', None)
|
||
ayanamsa_name = ayanamsa or DEFAULT_AYANAMSA_NAME
|
||
|
||
activities = list(ACTIVITY_RULES.keys())
|
||
if target_activity and target_activity not in activities:
|
||
print(f"[ERROR] 未知活动: {target_activity}。支持: {', '.join(activities)}")
|
||
return 1
|
||
|
||
# 占星师 Q9 / 第五轮 9:有地点才做地点精确择时;没地点只给近似概览,不给择时结论。
|
||
if lat is not None and lon is not None and (tz is not None or timezone_id):
|
||
return _print_location_exact(
|
||
query_dt.strftime('%Y-%m-%d'),
|
||
target_activity,
|
||
float(lat),
|
||
float(lon),
|
||
None if tz is None else float(tz),
|
||
timezone_id,
|
||
ayanamsa,
|
||
node_mode,
|
||
None,
|
||
5,
|
||
)
|
||
|
||
print("⚠️ 未提供地点:结果为近似值,不能作为择时结论")
|
||
print("=" * 64)
|
||
print("🕐 Muhurta 五要素概览(无地点,近似)")
|
||
print("=" * 64)
|
||
|
||
dates_to_check = [query_dt + timedelta(days=i) for i in range(max(1, int(scan_days)))]
|
||
has_swe = True
|
||
for dt in dates_to_check:
|
||
# 原值:swe.julday(year, month, day, int(hour_from_sunrise + 6)),被当成 UT,等于 UT 12:00
|
||
# 新值:显式按 UT 12:00 取概览并写明是 UT,不再假装它是当地时间
|
||
# 原因:占星师 Q9 / 第五轮 9:择时须用地点、实际日出、时区
|
||
sun_lon, moon_lon, has_swe = _get_sun_moon_lons(
|
||
dt.year, dt.month, dt.day, 12.0,
|
||
ayanamsa_name=ayanamsa_name,
|
||
)
|
||
vara_idx = _weekday_to_vara(dt.weekday())
|
||
date_str = dt.strftime('%Y-%m-%d')
|
||
|
||
report = muhurta_full_report(
|
||
sun_lon=sun_lon,
|
||
moon_lon=moon_lon,
|
||
weekday=vara_idx,
|
||
hour_from_sunrise=hour_from_sunrise,
|
||
query_date_str=date_str,
|
||
activities=activities,
|
||
)
|
||
|
||
precision_note = '' if has_swe else '(近似值,±2°精度)'
|
||
print(f"\n📅 {date_str} UT 12:00 概览 {precision_note}")
|
||
print(f" Sun: {sun_lon:.1f}° Moon: {moon_lon:.1f}°")
|
||
print()
|
||
|
||
print("── Panchanga 五要素 ──")
|
||
for line in _format_panchanga_summary(report['panchanga']):
|
||
print(line)
|
||
print()
|
||
|
||
print("── Abhijit Muhurta ──")
|
||
abh = report['abhijit_muhurta']
|
||
print(f" {abh['description']}")
|
||
print(f" 持续: {abh['duration_minutes']} 分钟 | {abh['warning']}")
|
||
print()
|
||
|
||
print("── 活动适宜性 ──")
|
||
for line in _format_activity_checks(report['activity_checks'], target_activity):
|
||
print(line)
|
||
print()
|
||
|
||
summary = report['summary']
|
||
best = summary.get('best_activities', [])
|
||
avoid = summary.get('avoid_activities', [])
|
||
if best:
|
||
print(f" ✨ 适宜: {', '.join(best)}")
|
||
if avoid:
|
||
print(f" 🚫 不宜: {', '.join(avoid)}")
|
||
|
||
if len(dates_to_check) > 1:
|
||
print("-" * 40)
|
||
|
||
print()
|
||
print("━" * 64)
|
||
print("注意事项:")
|
||
print(" 1. 本概览没有地点,日出日落与五要素都不是当地实际值,不能作为择时结论")
|
||
print(" 2. 需要地点精确择时,请加 --lat --lon --tz(或 --timezone-id)--ayanamsa --node-mode")
|
||
print(" 3. 精确 Muhurta 还需考虑出生盘 Lagna 与目标时间的相位关系")
|
||
if not has_swe:
|
||
print(" 4. ⚠️ 当前使用近似算法(swisseph 未安装),精度约 ±2°")
|
||
print("━" * 64)
|
||
return 0
|