1986 lines
94 KiB
Python
1986 lines
94 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Yoga 规则引擎 v1.0 (数据驱动架构)
|
||
从 JSON 规则配置中读取 Yoga 定义,通用条件解释器执行检测。
|
||
|
||
设计原则:
|
||
- 规则完全数据驱动,加新 Yoga 只需改 JSON
|
||
- 条件类型可扩展,覆盖 BPHS/PyJHora 主流 Yoga 模式
|
||
- 与原 cmd_yoga() API 兼容
|
||
"""
|
||
|
||
import ast
|
||
import json
|
||
import os
|
||
from typing import Dict, List, Any, Optional
|
||
|
||
# ============================================================================
|
||
# 基础常量(自包含,避免循环导入)
|
||
# ============================================================================
|
||
SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
|
||
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']
|
||
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'
|
||
}
|
||
EXALTATION = {
|
||
'Sun': 'Aries', 'Moon': 'Taurus', 'Mars': 'Capricorn', 'Mercury': 'Virgo',
|
||
'Jupiter': 'Cancer', 'Venus': 'Pisces', 'Saturn': 'Libra'
|
||
}
|
||
DEBILITATION = {
|
||
'Sun': 'Libra', 'Moon': 'Scorpio', 'Mars': 'Cancer', 'Mercury': 'Pisces',
|
||
'Jupiter': 'Capricorn', 'Venus': 'Virgo', 'Saturn': 'Aries'
|
||
}
|
||
MOOLATRIKONA_SIGN = {
|
||
'Sun': 'Leo', 'Moon': 'Taurus', 'Mars': 'Aries', 'Mercury': 'Virgo',
|
||
'Jupiter': 'Sagittarius', 'Venus': 'Libra', 'Saturn': 'Aquarius'
|
||
}
|
||
# PyJHora const.friendly_planets mapped from planet ids to names.
|
||
FRIENDLY_PLANETS = {
|
||
'Sun': ['Moon', 'Mars', 'Jupiter'],
|
||
'Moon': ['Sun', 'Mercury'],
|
||
'Mars': ['Sun', 'Moon', 'Jupiter'],
|
||
'Mercury': ['Sun', 'Venus'],
|
||
'Jupiter': ['Sun', 'Moon', 'Mars'],
|
||
'Venus': ['Mercury', 'Saturn', 'Rahu'],
|
||
'Saturn': ['Mercury', 'Venus', 'Rahu'],
|
||
'Rahu': ['Venus', 'Saturn'],
|
||
'Ketu': ['Sun', 'Mars'],
|
||
}
|
||
PLANET_CN = {
|
||
"Ketu": "南交点Ketu", "Venus": "金星Venus", "Sun": "太阳Sun",
|
||
"Moon": "月亮Moon", "Mars": "火星Mars", "Rahu": "北交点Rahu",
|
||
"Jupiter": "木星Jupiter", "Saturn": "土星Saturn", "Mercury": "水星Mercury"
|
||
}
|
||
BENEFICS = ['Jupiter', 'Venus', 'Mercury', 'Moon']
|
||
MALEFICS = ['Mars', 'Saturn', 'Sun', 'Rahu', 'Ketu']
|
||
MOVABLE_SIGNS = ['Aries', 'Cancer', 'Libra', 'Capricorn']
|
||
FIXED_SIGNS = ['Taurus', 'Leo', 'Scorpio', 'Aquarius']
|
||
DUAL_SIGNS = ['Gemini', 'Virgo', 'Sagittarius', 'Pisces']
|
||
ALL_PLANETS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu']
|
||
PLANET_INDEX = {planet: idx for idx, planet in enumerate(ALL_PLANETS)}
|
||
RAHU_KETU_OWNED_SIGN_INDEX = {'Rahu': 10, 'Ketu': 7}
|
||
# PyJHora const.house_strengths_of_planets, copied to make Yoga rule alignment deterministic.
|
||
# Strength scale: 5 own/lord, 4 exalted, 3 friend, 2 neutral, 1 enemy, 0 debilitated.
|
||
HOUSE_STRENGTHS = {
|
||
'Sun': [4, 1, 2, 2, 5, 2, 0, 3, 3, 1, 1, 3],
|
||
'Moon': [2, 4, 3, 5, 3, 3, 2, 0, 2, 2, 2, 2],
|
||
'Mars': [5, 2, 1, 0, 3, 1, 2, 5, 3, 4, 2, 3],
|
||
'Mercury': [2, 3, 5, 1, 3, 5, 3, 2, 2, 2, 2, 0],
|
||
'Jupiter': [3, 1, 1, 4, 3, 3, 1, 3, 5, 0, 2, 5],
|
||
'Venus': [2, 5, 3, 1, 1, 0, 5, 2, 3, 3, 3, 4],
|
||
'Saturn': [0, 3, 3, 1, 1, 3, 4, 1, 2, 5, 5, 2],
|
||
'Rahu': [1, 4, 4, 1, 1, 3, 3, 0, 0, 3, 1, 3],
|
||
'Ketu': [1, 0, 0, 1, 1, 3, 3, 4, 4, 3, 1, 3],
|
||
}
|
||
|
||
|
||
def _get_dignity_level(planet: str, sign: str) -> str:
|
||
"""判断行星在某星座的尊严等级"""
|
||
if EXALTATION.get(planet) == sign:
|
||
return 'EXALTED'
|
||
if SIGN_LORDS.get(sign) == planet:
|
||
return 'OWN_SIGN'
|
||
if MOOLATRIKONA_SIGN.get(planet) == sign:
|
||
return 'MOOLATRIKONA'
|
||
if DEBILITATION.get(planet) == sign:
|
||
return 'DEBILITATED'
|
||
return 'NEUTRAL'
|
||
|
||
|
||
# ============================================================================
|
||
# YogaContext: 封装星盘数据,提供查询接口
|
||
# ============================================================================
|
||
class YogaContext:
|
||
"""Yoga 检测上下文。封装行星数据并提供各类查询方法。"""
|
||
|
||
def __init__(self, planets: Dict[str, Dict], ascendant: str, context: Optional[Dict] = None):
|
||
self.planets = planets # name -> {sign, house, degree, ...}
|
||
self.ascendant = ascendant
|
||
self.asc_idx = SIGNS.index(ascendant) if ascendant in SIGNS else 0
|
||
self.context = context or {}
|
||
self.d9 = self.context.get("d9", {}) if isinstance(self.context, dict) else {}
|
||
self.d60 = self.context.get("d60", {}) if isinstance(self.context, dict) else {}
|
||
self.panchanga = self.context.get("panchanga", {}) if isinstance(self.context, dict) else {}
|
||
self.upagraha = self.context.get("upagraha", {}) if isinstance(self.context, dict) else {}
|
||
|
||
# 预计算 D1 宫主星。v6.0.39: Scorpio/Aquarius follow PyJHora's dynamic co-lord resolver
|
||
# (Scorpio: stronger of Mars/Ketu; Aquarius: stronger of Saturn/Rahu), while SIGN_LORDS stays
|
||
# as the classical fixed fallback for dignity tables and non-Jaimini contexts.
|
||
self._house_lords: Dict[int, str] = {}
|
||
for h in range(1, 13):
|
||
sign = SIGNS[(self.asc_idx + h - 1) % 12]
|
||
self._house_lords[h] = self._resolve_sign_lord(sign)
|
||
|
||
# 预计算 D9 宫主星(用于依赖 Navamsa 的 B.V. Raman Yoga)
|
||
d9_asc = self.d9.get("ascendant")
|
||
d9_asc_idx = SIGNS.index(d9_asc) if d9_asc in SIGNS else None
|
||
self._d9_house_lords: Dict[int, str] = {}
|
||
if d9_asc_idx is not None:
|
||
for h in range(1, 13):
|
||
sign = SIGNS[(d9_asc_idx + h - 1) % 12]
|
||
self._d9_house_lords[h] = self._resolve_sign_lord(sign)
|
||
|
||
# 预计算 D60 宫主星(用于依赖 Shashtiamsa 的 B.V. Raman Yoga)
|
||
d60_asc = self.d60.get("ascendant")
|
||
d60_asc_idx = SIGNS.index(d60_asc) if d60_asc in SIGNS else None
|
||
self._d60_house_lords: Dict[int, str] = {}
|
||
if d60_asc_idx is not None:
|
||
for h in range(1, 13):
|
||
sign = SIGNS[(d60_asc_idx + h - 1) % 12]
|
||
self._d60_house_lords[h] = self._resolve_sign_lord(sign)
|
||
|
||
def _sign_index_of_planet(self, planet: str) -> Optional[int]:
|
||
sign = self.sign_of(planet)
|
||
return SIGNS.index(sign) if sign in SIGNS else None
|
||
|
||
def _rasi_drishti_sign_indices_from(self, sign_idx: int) -> List[int]:
|
||
sign_name = SIGNS[sign_idx]
|
||
if sign_name in MOVABLE_SIGNS:
|
||
return [i for i in [1, 4, 7, 10] if i not in ((sign_idx + 1) % 12, (sign_idx - 1) % 12)]
|
||
if sign_name in FIXED_SIGNS:
|
||
return [i for i in [0, 3, 6, 9] if i not in ((sign_idx + 1) % 12, (sign_idx - 1) % 12)]
|
||
return [SIGNS.index(s) for s in DUAL_SIGNS if SIGNS.index(s) != sign_idx]
|
||
|
||
def _stronger_co_lord(self, planet1: str, planet2: str) -> str:
|
||
"""Approximate PyJHora stronger_planet_from_planet_positions() for Sc/Aq co-lords."""
|
||
h1 = self._sign_index_of_planet(planet1)
|
||
h2 = self._sign_index_of_planet(planet2)
|
||
if h1 is None:
|
||
return planet2
|
||
if h2 is None:
|
||
return planet1
|
||
|
||
node_owned_sign = RAHU_KETU_OWNED_SIGN_INDEX.get(planet1) or RAHU_KETU_OWNED_SIGN_INDEX.get(planet2)
|
||
if node_owned_sign is not None:
|
||
if h1 == node_owned_sign and h2 != node_owned_sign:
|
||
return planet2
|
||
if h2 == node_owned_sign and h1 != node_owned_sign:
|
||
return planet1
|
||
|
||
planet1_co_count = sum(1 for p in self.planets if self._sign_index_of_planet(p) == h1) - 1
|
||
planet2_co_count = sum(1 for p in self.planets if self._sign_index_of_planet(p) == h2) - 1
|
||
if planet1_co_count > planet2_co_count:
|
||
return planet1
|
||
if planet2_co_count > planet1_co_count:
|
||
return planet2
|
||
|
||
dispositor1 = self._resolve_sign_lord(SIGNS[h1], dynamic_co_lords=False)
|
||
dispositor2 = self._resolve_sign_lord(SIGNS[h2], dynamic_co_lords=False)
|
||
support1 = sum(
|
||
self._sign_index_of_planet(p) == h1
|
||
for p in ['Mercury', 'Jupiter', dispositor1]
|
||
if p in self.planets
|
||
)
|
||
support1 += sum(
|
||
p in self.planets and self._sign_index_of_planet(p) in self._rasi_drishti_sign_indices_from(h1)
|
||
for p in ['Mercury', 'Jupiter', dispositor1]
|
||
)
|
||
support2 = sum(
|
||
self._sign_index_of_planet(p) == h2
|
||
for p in ['Mercury', 'Jupiter', dispositor2]
|
||
if p in self.planets
|
||
)
|
||
support2 += sum(
|
||
p in self.planets and self._sign_index_of_planet(p) in self._rasi_drishti_sign_indices_from(h2)
|
||
for p in ['Mercury', 'Jupiter', dispositor2]
|
||
)
|
||
if support1 > support2:
|
||
return planet1
|
||
if support2 > support1:
|
||
return planet2
|
||
|
||
strength1 = HOUSE_STRENGTHS.get(planet1, [None] * 12)[h1]
|
||
strength2 = HOUSE_STRENGTHS.get(planet2, [None] * 12)[h2]
|
||
if strength1 == 4 and strength2 is not None and strength1 > strength2:
|
||
return planet1
|
||
if strength2 == 4 and strength1 is not None and strength2 > strength1:
|
||
return planet2
|
||
|
||
def modality_rank(sign_idx: int) -> int:
|
||
sign_name = SIGNS[sign_idx]
|
||
if sign_name in DUAL_SIGNS:
|
||
return 3
|
||
if sign_name in FIXED_SIGNS:
|
||
return 2
|
||
return 1
|
||
|
||
rank1 = modality_rank(h1)
|
||
rank2 = modality_rank(h2)
|
||
if rank1 > rank2:
|
||
return planet1
|
||
if rank2 > rank1:
|
||
return planet2
|
||
|
||
degree1 = self.degree_of(planet1)
|
||
degree2 = self.degree_of(planet2)
|
||
if degree1 is not None and degree2 is not None:
|
||
return planet1 if degree1 > degree2 else planet2
|
||
return planet1
|
||
|
||
def _resolve_sign_lord(self, sign: str, dynamic_co_lords: bool = True) -> Optional[str]:
|
||
if not dynamic_co_lords:
|
||
return SIGN_LORDS.get(sign)
|
||
if sign == 'Scorpio':
|
||
return self._stronger_co_lord('Mars', 'Ketu')
|
||
if sign == 'Aquarius':
|
||
return self._stronger_co_lord('Saturn', 'Rahu')
|
||
return SIGN_LORDS.get(sign)
|
||
|
||
# --- 基础查询 ---
|
||
def house_of(self, planet: str) -> Optional[int]:
|
||
return self.planets.get(planet, {}).get("house")
|
||
|
||
def sign_of(self, planet: str) -> Optional[str]:
|
||
return self.planets.get(planet, {}).get("sign")
|
||
|
||
def degree_of(self, planet: str) -> Optional[float]:
|
||
return self.planets.get(planet, {}).get("degree")
|
||
|
||
def lord_of_house(self, house: int) -> Optional[str]:
|
||
return self._house_lords.get(house)
|
||
|
||
def planets_in_house(self, house: int) -> List[str]:
|
||
return [p for p, info in self.planets.items() if info.get("house") == house]
|
||
|
||
# --- D9 / Panchanga / Upagraha 扩展上下文 ---
|
||
def d9_house_of(self, planet: str) -> Optional[int]:
|
||
return self.d9.get("planets", {}).get(planet, {}).get("house")
|
||
|
||
def d9_sign_of(self, planet: str) -> Optional[str]:
|
||
return self.d9.get("planets", {}).get(planet, {}).get("sign")
|
||
|
||
def d9_lord_of_house(self, house: int) -> Optional[str]:
|
||
return self._d9_house_lords.get(house)
|
||
|
||
def navamsa_dispositor(self, planet: str) -> Optional[str]:
|
||
sign = self.d9_sign_of(planet)
|
||
return self._resolve_sign_lord(sign) if sign else None
|
||
|
||
# --- D60 / Shashtiamsa 扩展上下文 ---
|
||
def d60_house_of(self, planet: str) -> Optional[int]:
|
||
return self.d60.get("planets", {}).get(planet, {}).get("house")
|
||
|
||
def d60_sign_of(self, planet: str) -> Optional[str]:
|
||
return self.d60.get("planets", {}).get(planet, {}).get("sign")
|
||
|
||
def d60_lord_of_house(self, house: int) -> Optional[str]:
|
||
return self._d60_house_lords.get(house)
|
||
|
||
def shashtiamsa_dispositor(self, planet: str) -> Optional[str]:
|
||
sign = self.d60_sign_of(planet)
|
||
return self._resolve_sign_lord(sign) if sign else None
|
||
|
||
# --- D9 尊严查询(用于跨分盘 Yoga 判断) ---
|
||
def is_exalted_in_d9(self, planet: str) -> bool:
|
||
s = self.d9_sign_of(planet)
|
||
return s is not None and EXALTATION.get(planet) == s
|
||
|
||
def is_debilitated_in_d9(self, planet: str) -> bool:
|
||
s = self.d9_sign_of(planet)
|
||
return s is not None and DEBILITATION.get(planet) == s
|
||
|
||
def is_own_sign_in_d9(self, planet: str) -> bool:
|
||
s = self.d9_sign_of(planet)
|
||
return s is not None and self._resolve_sign_lord(s) == planet
|
||
|
||
def is_moolatrikona_in_d9(self, planet: str) -> bool:
|
||
s = self.d9_sign_of(planet)
|
||
return s is not None and MOOLATRIKONA_SIGN.get(planet) == s
|
||
|
||
def dignity_in_d9(self, planet: str) -> str:
|
||
s = self.d9_sign_of(planet)
|
||
return _get_dignity_level(planet, s) if s else 'NEUTRAL'
|
||
|
||
def is_friendly_navamsa(self, planet: str) -> bool:
|
||
"""Check if planet is in friendly sign in D9 (used by dharidhra method1)."""
|
||
s = self.d9_sign_of(planet)
|
||
if not s:
|
||
return False
|
||
lord = self._resolve_sign_lord(s)
|
||
return lord in FRIENDLY_PLANETS.get(planet, [])
|
||
|
||
def is_unfriendly_navamsa(self, planet: str) -> bool:
|
||
"""Check if planet is in unfriendly/enemy sign in D9."""
|
||
s = self.d9_sign_of(planet)
|
||
if not s:
|
||
return False
|
||
lord = self._resolve_sign_lord(s)
|
||
# Enemy if not friendly and not self
|
||
if lord == planet:
|
||
return False
|
||
return lord not in FRIENDLY_PLANETS.get(planet, [])
|
||
|
||
# --- Functional Malefic / Benefic(基于月亮盈亏和宫主关系) ---
|
||
def functional_malefics(self) -> List[str]:
|
||
"""
|
||
Return functional malefics for this chart.
|
||
Rules (per PyJHora/B.V. Raman):
|
||
- Lords of 3, 6, 8, 11, 12 are functional malefics
|
||
- Mercury is malefic if conjunct with malefic
|
||
- Moon is malefic if waning (Krishna Paksha)
|
||
"""
|
||
fm = []
|
||
dusthana_lords = [self.lord_of_house(h) for h in [3, 6, 8, 11, 12]]
|
||
fm.extend([l for l in dusthana_lords if l])
|
||
|
||
# Mercury becomes functional malefic if conjunct with natural malefic
|
||
if 'Mercury' in self.planets:
|
||
merc_h = self.house_of('Mercury')
|
||
merc_conj_malefic = any(
|
||
m in self.planets and self.house_of(m) == merc_h
|
||
for m in ['Mars', 'Saturn', 'Rahu', 'Ketu', 'Sun']
|
||
)
|
||
if merc_conj_malefic and 'Mercury' not in fm:
|
||
fm.append('Mercury')
|
||
|
||
# Waning Moon is functional malefic
|
||
if self.is_waning_moon() and 'Moon' in self.planets and 'Moon' not in fm:
|
||
fm.append('Moon')
|
||
|
||
return list(set(fm))
|
||
|
||
def functional_benefics(self) -> List[str]:
|
||
"""Return functional benefics = all planets minus functional malefics minus natural malefics."""
|
||
fm = set(self.functional_malefics())
|
||
nm = set(MALEFICS)
|
||
return [p for p in ALL_PLANETS if p in self.planets and p not in fm and p not in nm]
|
||
|
||
# --- Shashtiamsa / Vaiseshikamsa 简化评分 ---
|
||
def is_shashtiamsa_evil(self, planet: str) -> bool:
|
||
"""
|
||
Simplified evil shashtiamsa check.
|
||
In D60, if planet is debilitated, in enemy sign, or in dusthana house → evil.
|
||
"""
|
||
s = self.d60_sign_of(planet)
|
||
h = self.d60_house_of(planet)
|
||
if s is None or h is None:
|
||
return False
|
||
debil = DEBILITATION.get(planet) == s
|
||
lord = self._resolve_sign_lord(s)
|
||
enemy = lord not in FRIENDLY_PLANETS.get(planet, []) and lord != planet
|
||
dusthana = h in [6, 8, 12]
|
||
return debil or enemy or dusthana
|
||
|
||
def is_shashtiamsa_good(self, planet: str) -> bool:
|
||
"""Good shashtiamsa: exalted, own sign, or moolatrikona in D60."""
|
||
s = self.d60_sign_of(planet)
|
||
if s is None:
|
||
return False
|
||
return EXALTATION.get(planet) == s or self._resolve_sign_lord(s) == planet or MOOLATRIKONA_SIGN.get(planet) == s
|
||
|
||
def vaiseshikamsa_score(self, planet: str) -> int:
|
||
"""
|
||
Simplified Vaiseshikamsa scoring (0-20 scale, higher = better).
|
||
Combines D1 dignity + D9 dignity + D60 dignity.
|
||
"""
|
||
score = 0
|
||
# D1 dignity (0-8)
|
||
d1_dignity = self.dignity(planet)
|
||
d1_scores = {'EXALTED': 8, 'OWN_SIGN': 7, 'MOOLATRIKONA': 6, 'NEUTRAL': 3, 'DEBILITATED': 0}
|
||
score += d1_scores.get(d1_dignity, 3)
|
||
|
||
# D9 dignity (0-7)
|
||
d9_dignity = self.dignity_in_d9(planet)
|
||
score += d1_scores.get(d9_dignity, 3) - 1 # slightly lower weight
|
||
|
||
# D60 dignity (0-5)
|
||
s60 = self.d60_sign_of(planet)
|
||
if s60:
|
||
d60_dignity = _get_dignity_level(planet, s60)
|
||
d60_scores = {'EXALTED': 5, 'OWN_SIGN': 4, 'MOOLATRIKONA': 3, 'NEUTRAL': 2, 'DEBILITATED': 0}
|
||
score += d60_scores.get(d60_dignity, 2)
|
||
|
||
return score
|
||
|
||
def tithi(self) -> Optional[int]:
|
||
return self.panchanga.get("tithi")
|
||
|
||
def is_waning_moon(self) -> bool:
|
||
return bool(self.panchanga.get("is_waning_moon"))
|
||
|
||
def upagraha_house(self, name: str) -> Optional[int]:
|
||
payload = self.upagraha.get(str(name).lower(), {})
|
||
return payload.get("house")
|
||
|
||
def upagraha_sign(self, name: str) -> Optional[str]:
|
||
payload = self.upagraha.get(str(name).lower(), {})
|
||
return payload.get("sign")
|
||
|
||
# --- 尊严 ---
|
||
def is_exalted(self, planet: str) -> bool:
|
||
s = self.sign_of(planet)
|
||
return s is not None and EXALTATION.get(planet) == s
|
||
|
||
def is_own_sign(self, planet: str) -> bool:
|
||
s = self.sign_of(planet)
|
||
return s is not None and self._resolve_sign_lord(s) == planet
|
||
|
||
def is_moolatrikona(self, planet: str) -> bool:
|
||
s = self.sign_of(planet)
|
||
return s is not None and MOOLATRIKONA_SIGN.get(planet) == s
|
||
|
||
def is_debilitated(self, planet: str) -> bool:
|
||
s = self.sign_of(planet)
|
||
return s is not None and DEBILITATION.get(planet) == s
|
||
|
||
def dignity(self, planet: str) -> str:
|
||
s = self.sign_of(planet)
|
||
return _get_dignity_level(planet, s) if s else 'NEUTRAL'
|
||
|
||
# --- 宫位性质 ---
|
||
def is_kendra(self, house: int) -> bool:
|
||
return house in [1, 4, 7, 10]
|
||
|
||
def is_trikona(self, house: int) -> bool:
|
||
return house in [1, 5, 9]
|
||
|
||
def is_dusthana(self, house: int) -> bool:
|
||
return house in [6, 8, 12]
|
||
|
||
def is_upachaya(self, house: int) -> bool:
|
||
return house in [3, 6, 10, 11]
|
||
|
||
def is_maraka(self, house: int) -> bool:
|
||
return house in [2, 7]
|
||
|
||
def is_badhaka(self, house: int) -> bool:
|
||
"""Badhaka 宫:基本盘7宫,固定盘9宫,变动盘11宫"""
|
||
asc = self.ascendant
|
||
if asc in ['Aries', 'Cancer', 'Libra', 'Capricorn']:
|
||
return house == 11
|
||
if asc in ['Taurus', 'Leo', 'Scorpio', 'Aquarius']:
|
||
return house == 9
|
||
return house == 7
|
||
|
||
# --- 相对宫位关系 ---
|
||
def house_offset(self, from_house: int, to_house: int) -> int:
|
||
"""从 from_house 到 to_house 的宫位偏移 (0=同宫, 1=下一宫, ...)"""
|
||
return (to_house - from_house) % 12
|
||
|
||
def is_kendra_from(self, planet: str, from_planet: str) -> bool:
|
||
h1 = self.house_of(from_planet)
|
||
h2 = self.house_of(planet)
|
||
if h1 is None or h2 is None:
|
||
return False
|
||
return self.house_offset(h1, h2) in [0, 3, 6, 9]
|
||
|
||
def is_trikona_from(self, planet: str, from_planet: str) -> bool:
|
||
h1 = self.house_of(from_planet)
|
||
h2 = self.house_of(planet)
|
||
if h1 is None or h2 is None:
|
||
return False
|
||
return self.house_offset(h1, h2) in [0, 4, 8]
|
||
|
||
def is_2nd_or_12th_from(self, planet: str, from_planet: str) -> bool:
|
||
h1 = self.house_of(from_planet)
|
||
h2 = self.house_of(planet)
|
||
if h1 is None or h2 is None:
|
||
return False
|
||
return self.house_offset(h1, h2) in [1, 11]
|
||
|
||
# --- 角色解析 ---
|
||
def resolve(self, ref: Any) -> List[str]:
|
||
"""
|
||
解析行星引用为具体行星名称列表。
|
||
支持: 字符串行星名、角色对象、角色字符串。
|
||
"""
|
||
if isinstance(ref, str):
|
||
if ref in self.planets:
|
||
return [ref]
|
||
if ref == "benefics":
|
||
return [p for p in BENEFICS if p in self.planets]
|
||
if ref == "malefics":
|
||
return [p for p in MALEFICS if p in self.planets]
|
||
if ref == "all":
|
||
return list(self.planets.keys())
|
||
if ref in ("visible", "sun_to_saturn"):
|
||
return [p for p in ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'] if p in self.planets]
|
||
if ref == "kendra_lords":
|
||
return list(dict.fromkeys([self._house_lords[h] for h in [1, 4, 7, 10]]))
|
||
if ref == "trikona_lords":
|
||
return list(dict.fromkeys([self._house_lords[h] for h in [1, 5, 9]]))
|
||
if ref == "dusthana_lords":
|
||
return list(dict.fromkeys([self._house_lords[h] for h in [6, 8, 12]]))
|
||
if ref == "upachaya_lords":
|
||
return list(dict.fromkeys([self._house_lords[h] for h in [3, 6, 10, 11]]))
|
||
if ref.startswith("lord:"):
|
||
house = int(ref.split(":")[1])
|
||
lord = self._house_lords.get(house)
|
||
return [lord] if lord else []
|
||
return []
|
||
|
||
if isinstance(ref, dict):
|
||
role = ref.get("role")
|
||
if role == "lord":
|
||
house = ref.get("house")
|
||
lord = self._house_lords.get(house)
|
||
return [lord] if lord else []
|
||
if role == "kendra_lords":
|
||
return list(dict.fromkeys([self._house_lords[h] for h in [1, 4, 7, 10]]))
|
||
if role == "trikona_lords":
|
||
return list(dict.fromkeys([self._house_lords[h] for h in [1, 5, 9]]))
|
||
if role == "dusthana_lords":
|
||
return list(dict.fromkeys([self._house_lords[h] for h in [6, 8, 12]]))
|
||
if role == "upachaya_lords":
|
||
return list(dict.fromkeys([self._house_lords[h] for h in [3, 6, 10, 11]]))
|
||
if role == "benefics":
|
||
return [p for p in BENEFICS if p in self.planets]
|
||
if role == "malefics":
|
||
return [p for p in MALEFICS if p in self.planets]
|
||
if role == "all":
|
||
return list(self.planets.keys())
|
||
if role in ("visible", "sun_to_saturn"):
|
||
return [p for p in ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'] if p in self.planets]
|
||
if role == "set":
|
||
return [p for p in ref.get("planets", []) if p in self.planets]
|
||
return []
|
||
|
||
def resolve_single(self, ref: Any) -> Optional[str]:
|
||
"""解析为单个行星,返回 None 或行星名"""
|
||
lst = self.resolve(ref)
|
||
return lst[0] if lst else None
|
||
|
||
|
||
# ============================================================================
|
||
# YogaEngine: 规则加载与检测
|
||
# ============================================================================
|
||
class YogaEngine:
|
||
"""Yoga 检测引擎。从 JSON 加载规则,对星盘执行条件匹配。"""
|
||
|
||
def __init__(self, rules_path: str):
|
||
with open(rules_path, 'r', encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
self.schema_version = data.get("schema_version", "1.0")
|
||
self.rules = [r for r in data.get("rules", []) if r.get("enabled", True)]
|
||
|
||
def detect(self, planets: Dict[str, Dict], ascendant: str, context: Optional[Dict] = None) -> List[Dict]:
|
||
"""
|
||
检测 Yoga。
|
||
返回: [{name, name_cn, combination, effects, strength, source, bvr_id, category}, ...]
|
||
"""
|
||
ctx = YogaContext(planets, ascendant, context=context)
|
||
results = []
|
||
seen_dedup = set()
|
||
|
||
# 规则匹配
|
||
for rule in self.rules:
|
||
matches = self._eval_rule(rule, ctx)
|
||
for m in matches:
|
||
entry = {
|
||
"name": rule["name"],
|
||
"name_cn": rule["name_cn"],
|
||
"combination": m.get("combination", ""),
|
||
"effects": rule.get("effects", []),
|
||
"strength": m.get("strength", rule.get("strength", "中")),
|
||
"source": rule.get("source", ""),
|
||
"bvr_id": rule.get("bvr_id"),
|
||
"category": rule.get("category", ""),
|
||
"rule_id": rule.get("id", ""),
|
||
}
|
||
dedup_key = rule.get("dedup_key") or rule["name"]
|
||
dedup_val = f"{dedup_key}:{m.get('combination', '')}"
|
||
if dedup_val in seen_dedup:
|
||
continue
|
||
seen_dedup.add(dedup_val)
|
||
results.append(entry)
|
||
|
||
# ── 附加算法级 Yoga(不在 rules JSON 中) ──
|
||
results += self._detect_solar_lunar_yogas(ctx)
|
||
|
||
return results
|
||
|
||
def _detect_solar_lunar_yogas(self, ctx: YogaContext) -> List[Dict]:
|
||
"""
|
||
检测 Solar Yogas (Veshi/Voshi/Ubhayachari) 和 Lunar Yogas (Sunapha/Anapha/Durudhura/Kemadruma)。
|
||
|
||
来源:从 dangling commit f19369c 恢复的失散代码(原在 jyotish_engine.py cmd_yoga 中)。
|
||
算法:基于行星相对于太阳/月亮的位置,非规则 JSON 可表达的。
|
||
"""
|
||
results = []
|
||
p = ctx.planets
|
||
planets_in_house = {} # house_num -> [planet_names]
|
||
# 如果度数是浮点,尝试排序
|
||
sun_house = None
|
||
moon_house = None
|
||
for pname, pinfo in p.items():
|
||
h = pinfo.get("house")
|
||
# 计算行星实际度数
|
||
if h is not None:
|
||
planets_in_house.setdefault(h, []).append(pname)
|
||
if pname == "Sun":
|
||
sun_house = h
|
||
elif pname == "Moon":
|
||
moon_house = h
|
||
|
||
# Solar Yogas
|
||
if sun_house is not None:
|
||
h2 = ((sun_house - 1 + 1) % 12) + 1 # 太阳第2宫
|
||
h12 = ((sun_house - 1 + 11) % 12) + 1 # 太阳第12宫
|
||
p_in_h2 = [n for n in planets_in_house.get(h2, []) if n != "Sun"]
|
||
p_in_h12 = [n for n in planets_in_house.get(h12, []) if n != "Sun"]
|
||
if p_in_h12 and not p_in_h2:
|
||
results.append({"name": "Voshi Yoga", "name_cn": "太阳前瑜伽",
|
||
"combination": f"{'+'.join(p_in_h12)}在太阳第12宫",
|
||
"effects": ["口才出众", "善于表达", "受人尊敬", "知识渊博"],
|
||
"strength": "中", "category": "solar_yoga", "source": "Parashara"})
|
||
if p_in_h2 and not p_in_h12:
|
||
results.append({"name": "Veshi Yoga", "name_cn": "太阳后瑜伽",
|
||
"combination": f"{'+'.join(p_in_h2)}在太阳第2宫",
|
||
"effects": ["财富充裕", "生活舒适", "性情愉快", "人缘好"],
|
||
"strength": "中", "category": "solar_yoga", "source": "Parashara"})
|
||
if p_in_h2 and p_in_h12:
|
||
results.append({"name": "Ubhayachari Yoga", "name_cn": "太阳双夹瑜伽",
|
||
"combination": "太阳两侧均有行星",
|
||
"effects": ["性格坚毅", "口才与财富兼备", "受人爱戴", "社会影响力"],
|
||
"strength": "强", "category": "solar_yoga", "source": "Parashara"})
|
||
|
||
# Lunar Yogas
|
||
if moon_house is not None:
|
||
h2 = ((moon_house - 1 + 1) % 12) + 1 # 月亮第2宫
|
||
h12 = ((moon_house - 1 + 11) % 12) + 1 # 月亮第12宫
|
||
p_in_h2 = [n for n in planets_in_house.get(h2, []) if n != "Moon"]
|
||
p_in_h12 = [n for n in planets_in_house.get(h12, []) if n != "Moon"]
|
||
if p_in_h12 and not p_in_h2:
|
||
results.append({"name": "Anapha Yoga", "name_cn": "月后瑜伽",
|
||
"combination": f"{'+'.join(p_in_h12)}在月亮第12宫",
|
||
"effects": ["体格健壮", "名声良好", "品德高尚", "行业领袖"],
|
||
"strength": "中", "category": "lunar_yoga", "source": "Parashara"})
|
||
if p_in_h2 and not p_in_h12:
|
||
results.append({"name": "Sunapha Yoga", "name_cn": "月前瑜伽",
|
||
"combination": f"{'+'.join(p_in_h2)}在月亮第2宫",
|
||
"effects": ["自力更生", "财富充裕", "受人尊敬", "生活富足"],
|
||
"strength": "中", "category": "lunar_yoga", "source": "Parashara"})
|
||
if p_in_h2 and p_in_h12:
|
||
results.append({"name": "Durudhura Yoga", "name_cn": "月双夹瑜伽",
|
||
"combination": "月亮两侧均有行星",
|
||
"effects": ["享受丰富", "善于辞令", "性格坚定", "多元才华"],
|
||
"strength": "强", "category": "lunar_yoga", "source": "Parashara"})
|
||
|
||
return results
|
||
|
||
# ------------------------------------------------------------------------
|
||
# 规则级评估
|
||
# ------------------------------------------------------------------------
|
||
def _eval_rule(self, rule: Dict, ctx: YogaContext) -> List[Dict]:
|
||
"""评估单条规则,返回匹配列表(空表示无匹配)"""
|
||
logic = rule.get("logic", {})
|
||
return self._eval_condition(logic, ctx, rule, {})
|
||
|
||
def _eval_condition(self, cond: Dict, ctx: YogaContext, rule: Dict,
|
||
bindings: Dict) -> List[Dict]:
|
||
"""评估条件节点,返回匹配列表"""
|
||
# v6.0.31: 兼容历史规则中的 "op" 字段。
|
||
# 早期批量扩展脚本误用 op 而不是 type;如果只读 type,会导致这些规则静默失效。
|
||
ctype = cond.get("type") or cond.get("op")
|
||
# v6.0.32: 兼容批量生成规则使用的 compound_conditions/compound 结构。
|
||
if ctype == "compound_conditions":
|
||
cond = {**cond, "type": "and"}
|
||
return self._eval_and(cond, ctx, rule, bindings)
|
||
if ctype == "compound":
|
||
operator = str(cond.get("operator", "AND")).lower()
|
||
cond = {**cond, "type": "or" if operator == "or" else "and"}
|
||
return self._eval_condition(cond, ctx, rule, bindings)
|
||
|
||
handler = getattr(self, f"_eval_{ctype}", None)
|
||
if handler:
|
||
return handler(cond, ctx, rule, bindings)
|
||
|
||
# v6.0.32: 兼容 PyJHora/BVR 批量规则中的语义型 condition type,
|
||
# 避免未知类型静默导致整条 compound rule 永远失效。
|
||
if ctype:
|
||
return self._eval_semantic_condition(ctype, cond, ctx, rule, bindings)
|
||
return []
|
||
|
||
# ------------------------------------------------------------------------
|
||
# 复合条件
|
||
# ------------------------------------------------------------------------
|
||
def _eval_and(self, cond, ctx, rule, bindings):
|
||
"""AND: 所有子条件必须同时满足"""
|
||
subs = cond.get("conditions", [])
|
||
if not subs:
|
||
return [{"combination": "", "strength": rule.get("strength", "中")}]
|
||
|
||
results = self._eval_condition(subs[0], ctx, rule, bindings)
|
||
if not results:
|
||
return []
|
||
|
||
for sub in subs[1:]:
|
||
new_results = []
|
||
for match in results:
|
||
b = {**bindings, **match.get("bindings", {})}
|
||
sub_matches = self._eval_condition(sub, ctx, rule, b)
|
||
for sm in sub_matches:
|
||
combo = match.get("combination", "")
|
||
sm_combo = sm.get("combination", "")
|
||
merged_combo = "; ".join(filter(None, [combo, sm_combo]))
|
||
new_results.append({
|
||
"combination": merged_combo,
|
||
"strength": sm.get("strength", match.get("strength")),
|
||
"bindings": {**match.get("bindings", {}), **sm.get("bindings", {})}
|
||
})
|
||
results = new_results
|
||
if not results:
|
||
return []
|
||
return results
|
||
|
||
def _eval_or(self, cond, ctx, rule, bindings):
|
||
"""OR: 任一子条件满足即可"""
|
||
all_results = []
|
||
for sub in cond.get("conditions", []):
|
||
matches = self._eval_condition(sub, ctx, rule, bindings)
|
||
all_results.extend(matches)
|
||
return all_results
|
||
|
||
def _eval_not(self, cond, ctx, rule, bindings):
|
||
"""NOT: 子条件不满足时返回一个空匹配"""
|
||
matches = self._eval_condition(cond.get("condition", {}), ctx, rule, bindings)
|
||
return [] if matches else [{"combination": "", "strength": rule.get("strength", "中")}]
|
||
|
||
# ------------------------------------------------------------------------
|
||
# 单条件类型
|
||
# ------------------------------------------------------------------------
|
||
def _eval_same_house(self, cond, ctx, rule, bindings):
|
||
"""两颗行星同宫"""
|
||
# 支持两种写法:
|
||
# 1) {"a": ..., "b": ...}
|
||
# 2) {"planets": ["Sun", "Mercury", ...]}:任意两颗同宫即满足
|
||
if "planets" in cond and "a" not in cond and "b" not in cond:
|
||
planets = cond.get("planets", [])
|
||
pa = self._resolve_binding({"role": "set", "planets": planets}, ctx, bindings)
|
||
pb = pa
|
||
else:
|
||
pa = self._resolve_binding(cond.get("a"), ctx, bindings)
|
||
pb = self._resolve_binding(cond.get("b"), ctx, bindings)
|
||
results = []
|
||
for a in pa:
|
||
for b in pb:
|
||
if a == b:
|
||
continue
|
||
h1 = ctx.house_of(a)
|
||
h2 = ctx.house_of(b)
|
||
if h1 is not None and h1 == h2:
|
||
tmpl = cond.get("combo_template", "{a}与{b}同在第{h}宫")
|
||
combo = tmpl.format(a=PLANET_CN.get(a, a), b=PLANET_CN.get(b, b), h=h1, a_raw=a, b_raw=b)
|
||
results.append({"combination": combo, "strength": rule.get("strength", "中"),
|
||
"bindings": {"a": a, "b": b, "house": h1}})
|
||
return results
|
||
|
||
def _eval_in_houses(self, cond, ctx, rule, bindings):
|
||
"""行星在指定宫位列表中"""
|
||
refs = self._resolve_binding(cond.get("planet", "$planet"), ctx, bindings)
|
||
houses = cond.get("houses", [])
|
||
exclude = cond.get("exclude", [])
|
||
results = []
|
||
for p in refs:
|
||
if p in exclude:
|
||
continue
|
||
h = ctx.house_of(p)
|
||
if h is not None and h in houses:
|
||
tmpl = cond.get("combo_template", "{p}在第{h}宫")
|
||
combo = tmpl.format(p=PLANET_CN.get(p, p), h=h, p_raw=p)
|
||
results.append({"combination": combo, "strength": rule.get("strength", "中"),
|
||
"bindings": {"planet": p, "house": h}})
|
||
return results
|
||
|
||
def _eval_not_in_houses(self, cond, ctx, rule, bindings):
|
||
"""行星不在指定宫位列表中"""
|
||
refs = self._resolve_binding(cond.get("planet"), ctx, bindings)
|
||
houses = cond.get("houses", [])
|
||
results = []
|
||
for p in refs:
|
||
h = ctx.house_of(p)
|
||
if h is not None and h not in houses:
|
||
results.append({"combination": "", "strength": rule.get("strength", "中"),
|
||
"bindings": {"planet": p, "house": h}})
|
||
return results
|
||
|
||
def _eval_in_houses_from(self, cond, ctx, rule, bindings):
|
||
"""行星在指定行星/基准的相对宫位关系中"""
|
||
refs = self._resolve_binding(cond.get("planet"), ctx, bindings)
|
||
from_ref = cond.get("from")
|
||
relations = cond.get("relations", [])
|
||
results = []
|
||
|
||
# 解析 relations(支持字符串别名和整数偏移)
|
||
valid_offsets = set()
|
||
for r in relations:
|
||
if r == "kendra":
|
||
valid_offsets.update([0, 3, 6, 9])
|
||
elif r == "trikona":
|
||
valid_offsets.update([0, 4, 8])
|
||
elif r == "2nd_12th":
|
||
valid_offsets.update([1, 11])
|
||
elif r == "3rd_11th":
|
||
valid_offsets.update([2, 10])
|
||
elif r == "same":
|
||
valid_offsets.add(0)
|
||
elif isinstance(r, int):
|
||
valid_offsets.add(r)
|
||
|
||
for p in refs:
|
||
h1 = ctx.house_of(p)
|
||
if h1 is None:
|
||
continue
|
||
# from 可以是行星或固定宫位
|
||
if isinstance(from_ref, int):
|
||
h_base = from_ref
|
||
else:
|
||
from_planets = self._resolve_binding(from_ref, ctx, bindings)
|
||
for fp in from_planets:
|
||
h_base = ctx.house_of(fp)
|
||
if h_base is None:
|
||
continue
|
||
off = ctx.house_offset(h_base, h1)
|
||
if off in valid_offsets:
|
||
tmpl = cond.get("combo_template", "{p}在第{h}宫(从{fp}的{rel})")
|
||
rel_name = self._offset_name(off)
|
||
combo = tmpl.format(p=PLANET_CN.get(p, p), h=h1, fp=PLANET_CN.get(fp, fp),
|
||
rel=rel_name, off=off, p_raw=p, fp_raw=fp)
|
||
results.append({"combination": combo, "strength": rule.get("strength", "中"),
|
||
"bindings": {"planet": p, "from": fp, "house": h1}})
|
||
return results
|
||
|
||
def _eval_dignity(self, cond, ctx, rule, bindings):
|
||
"""行星尊严检查"""
|
||
refs = self._resolve_binding(cond.get("planet"), ctx, bindings)
|
||
dignity_type = cond.get("dignity") # exalted, own, moolatrikona, debilitated
|
||
results = []
|
||
for p in refs:
|
||
ok = False
|
||
if dignity_type == "exalted":
|
||
ok = ctx.is_exalted(p)
|
||
elif dignity_type == "own":
|
||
ok = ctx.is_own_sign(p)
|
||
elif dignity_type == "moolatrikona":
|
||
ok = ctx.is_moolatrikona(p)
|
||
elif dignity_type == "debilitated":
|
||
ok = ctx.is_debilitated(p)
|
||
elif dignity_type == "strong":
|
||
ok = ctx.is_exalted(p) or ctx.is_own_sign(p) or ctx.is_moolatrikona(p)
|
||
elif dignity_type == "not_debilitated":
|
||
ok = not ctx.is_debilitated(p)
|
||
elif dignity_type == "not_enemy":
|
||
ok = True # placeholder, not used in current rules
|
||
|
||
# v6.0.31: 兼容历史规则中的 status 字段。
|
||
# 批量规则曾使用 {"op":"dignity", "status":"exalted"},而引擎原本只读取 dignity。
|
||
if dignity_type is None:
|
||
status = cond.get("status")
|
||
if status == "exalted":
|
||
ok = ctx.is_exalted(p)
|
||
dignity_type = status
|
||
elif status == "own":
|
||
ok = ctx.is_own_sign(p)
|
||
dignity_type = status
|
||
elif status == "moolatrikona":
|
||
ok = ctx.is_moolatrikona(p)
|
||
dignity_type = status
|
||
elif status == "debilitated":
|
||
ok = ctx.is_debilitated(p)
|
||
dignity_type = status
|
||
elif status == "strong":
|
||
ok = ctx.is_exalted(p) or ctx.is_own_sign(p) or ctx.is_moolatrikona(p)
|
||
dignity_type = status
|
||
if ok:
|
||
sign = ctx.sign_of(p)
|
||
tmpl = cond.get("combo_template", "{p}{dignity}在{sign}")
|
||
combo = tmpl.format(p=PLANET_CN.get(p, p), dignity=dignity_type, sign=sign or "", p_raw=p)
|
||
results.append({"combination": combo, "strength": rule.get("strength", "中"),
|
||
"bindings": {"planet": p, "sign": sign}})
|
||
return results
|
||
|
||
def _eval_count(self, cond, ctx, rule, bindings):
|
||
"""统计满足条件的行星数量"""
|
||
refs = self._resolve_binding(cond.get("items", cond.get("planets")), ctx, bindings)
|
||
sub_cond = cond.get("condition", {})
|
||
min_count = cond.get("min", 1)
|
||
max_count = cond.get("max")
|
||
|
||
matched = []
|
||
for p in refs:
|
||
# 临时把 planet 绑定为 p,评估子条件
|
||
sub_bindings = {**bindings, "planet": p}
|
||
matches = self._eval_condition(sub_cond, ctx, rule, sub_bindings)
|
||
if matches:
|
||
matched.append(p)
|
||
|
||
count = len(matched)
|
||
ok = count >= min_count
|
||
if max_count is not None:
|
||
ok = ok and count <= max_count
|
||
|
||
if ok:
|
||
tmpl = cond.get("combo_template", "{count}颗行星满足条件")
|
||
combo = tmpl.format(count=count, planets="、".join(PLANET_CN.get(p, p) for p in matched))
|
||
return [{"combination": combo, "strength": rule.get("strength", "中"),
|
||
"bindings": {"count": count, "matched": matched}}]
|
||
return []
|
||
|
||
def _eval_houses_occupied(self, cond, ctx, rule, bindings):
|
||
"""行星占据的宫位数量"""
|
||
refs = self._resolve_binding(cond.get("planets", "all"), ctx, bindings)
|
||
occupied = set()
|
||
for p in refs:
|
||
h = ctx.house_of(p)
|
||
if h is not None:
|
||
occupied.add(h)
|
||
count = len(occupied)
|
||
target = cond.get("count")
|
||
min_count = cond.get("min")
|
||
max_count = cond.get("max")
|
||
|
||
ok = False
|
||
if target is not None:
|
||
ok = count == target
|
||
else:
|
||
ok = True
|
||
if min_count is not None:
|
||
ok = ok and count >= min_count
|
||
if max_count is not None:
|
||
ok = ok and count <= max_count
|
||
|
||
if ok:
|
||
tmpl = cond.get("combo_template", "行星分布在{count}个宫位")
|
||
combo = tmpl.format(count=count)
|
||
return [{"combination": combo, "strength": rule.get("strength", "中")}]
|
||
return []
|
||
|
||
def _eval_adjacent_houses(self, cond, ctx, rule, bindings):
|
||
"""目标宫位的前/后宫位有指定行星"""
|
||
target = cond.get("house")
|
||
sides = cond.get("sides", ["prev", "next"])
|
||
refs = self._resolve_binding(cond.get("planets"), ctx, bindings)
|
||
require_both = cond.get("both", False)
|
||
|
||
prev_h = ((target - 2) % 12) + 1
|
||
next_h = (target % 12) + 1
|
||
|
||
prev_ok = any(ctx.house_of(p) == prev_h for p in refs) if "prev" in sides else True
|
||
next_ok = any(ctx.house_of(p) == next_h for p in refs) if "next" in sides else True
|
||
|
||
if require_both:
|
||
ok = prev_ok and next_ok
|
||
else:
|
||
ok = prev_ok or next_ok
|
||
|
||
if ok:
|
||
tmpl = cond.get("combo_template", "第{target}宫被夹击")
|
||
combo = tmpl.format(target=target, prev=prev_h, next=next_h)
|
||
return [{"combination": combo, "strength": rule.get("strength", "中")}]
|
||
return []
|
||
|
||
def _eval_all_in_houses(self, cond, ctx, rule, bindings):
|
||
"""所有指定行星都在给定宫位集合中。"""
|
||
refs = self._resolve_binding(cond.get("planets", "visible"), ctx, bindings)
|
||
houses = set(cond.get("houses", []))
|
||
if not refs or not houses:
|
||
return []
|
||
occupied = [ctx.house_of(p) for p in refs]
|
||
ok = all(h in houses for h in occupied if h is not None) and len(occupied) == len(refs)
|
||
if ok:
|
||
tmpl = cond.get("combo_template", "所有指定行星均落入{houses}")
|
||
combo = tmpl.format(houses="/".join(str(h) for h in sorted(houses)), planets="、".join(PLANET_CN.get(p, p) for p in refs))
|
||
return [{"combination": combo, "strength": rule.get("strength", "中"),
|
||
"bindings": {"planets": refs, "houses": sorted(houses)}}]
|
||
return []
|
||
|
||
def _eval_all_in_house_sets(self, cond, ctx, rule, bindings):
|
||
"""所有指定行星都在多个候选宫位集合之一中。"""
|
||
refs = self._resolve_binding(cond.get("planets", "visible"), ctx, bindings)
|
||
house_sets = cond.get("house_sets", [])
|
||
if not refs or not house_sets:
|
||
return []
|
||
for houses in house_sets:
|
||
houses_set = set(houses)
|
||
occupied = [ctx.house_of(p) for p in refs]
|
||
ok = all(h in houses_set for h in occupied if h is not None) and len(occupied) == len(refs)
|
||
if ok:
|
||
tmpl = cond.get("combo_template", "所有指定行星均落入候选宫位集合{houses}")
|
||
combo = tmpl.format(houses="/".join(str(h) for h in sorted(houses_set)), planets="、".join(PLANET_CN.get(p, p) for p in refs))
|
||
return [{"combination": combo, "strength": rule.get("strength", "中"),
|
||
"bindings": {"planets": refs, "houses": sorted(houses_set)}}]
|
||
return []
|
||
|
||
def _eval_occupied_houses_exact(self, cond, ctx, rule, bindings):
|
||
"""指定行星实际占据的宫位集合与目标集合完全一致。"""
|
||
refs = self._resolve_binding(cond.get("planets", "visible"), ctx, bindings)
|
||
houses = set(cond.get("houses", []))
|
||
occupied = {ctx.house_of(p) for p in refs if ctx.house_of(p) is not None}
|
||
if refs and occupied == houses:
|
||
tmpl = cond.get("combo_template", "指定行星只占据{houses}")
|
||
combo = tmpl.format(houses="/".join(str(h) for h in sorted(houses)), planets="、".join(PLANET_CN.get(p, p) for p in refs))
|
||
return [{"combination": combo, "strength": rule.get("strength", "中"),
|
||
"bindings": {"planets": refs, "houses": sorted(houses)}}]
|
||
return []
|
||
|
||
def _eval_occupied_houses_exact_sets(self, cond, ctx, rule, bindings):
|
||
"""指定行星实际占据的宫位集合等于候选集合之一。"""
|
||
refs = self._resolve_binding(cond.get("planets", "visible"), ctx, bindings)
|
||
house_sets = cond.get("house_sets", [])
|
||
occupied = {ctx.house_of(p) for p in refs if ctx.house_of(p) is not None}
|
||
for houses in house_sets:
|
||
houses_set = set(houses)
|
||
if refs and occupied == houses_set:
|
||
tmpl = cond.get("combo_template", "指定行星只占据候选宫位集合{houses}")
|
||
combo = tmpl.format(houses="/".join(str(h) for h in sorted(houses_set)), planets="、".join(PLANET_CN.get(p, p) for p in refs))
|
||
return [{"combination": combo, "strength": rule.get("strength", "中"),
|
||
"bindings": {"planets": refs, "houses": sorted(houses_set)}}]
|
||
return []
|
||
|
||
def _eval_has_planet_in_house(self, cond, ctx, rule, bindings):
|
||
"""指定行星集合中至少一颗在某宫。"""
|
||
refs = self._resolve_binding(cond.get("planets", "all"), ctx, bindings)
|
||
house = cond.get("house")
|
||
matched = [p for p in refs if ctx.house_of(p) == house]
|
||
if matched:
|
||
tmpl = cond.get("combo_template", "第{house}宫有{planets}")
|
||
combo = tmpl.format(house=house, planets="、".join(PLANET_CN.get(p, p) for p in matched))
|
||
return [{"combination": combo, "strength": rule.get("strength", "中"),
|
||
"bindings": {"matched": matched, "house": house}}]
|
||
return []
|
||
|
||
def _eval_has_planet_in_house_from(self, cond, ctx, rule, bindings):
|
||
"""指定行星集合中至少一颗在某参考行星的相对宫位。houses 使用传统 1-12 口径。"""
|
||
refs = self._resolve_binding(cond.get("planets", "all"), ctx, bindings)
|
||
from_planet = cond.get("from") or cond.get("from_planet")
|
||
from_list = self._resolve_binding(from_planet, ctx, bindings)
|
||
houses = cond.get("houses", [])
|
||
exclude = set(cond.get("exclude", []))
|
||
results = []
|
||
for fp in from_list:
|
||
base = ctx.house_of(fp)
|
||
if base is None:
|
||
continue
|
||
for rel_house in houses:
|
||
target = ((base + rel_house - 2) % 12) + 1
|
||
matched = [p for p in refs if p not in exclude and p != fp and ctx.house_of(p) == target]
|
||
if matched:
|
||
tmpl = cond.get("combo_template", "{planets}在{fp}的第{rel}宫")
|
||
combo = tmpl.format(
|
||
planets="、".join(PLANET_CN.get(p, p) for p in matched),
|
||
fp=PLANET_CN.get(fp, fp),
|
||
rel=rel_house,
|
||
target=target,
|
||
)
|
||
results.append({"combination": combo, "strength": rule.get("strength", "中"),
|
||
"bindings": {"matched": matched, "from": fp, "rel_house": rel_house, "house": target}})
|
||
return results
|
||
|
||
def _eval_houses_with_planets_count(self, cond, ctx, rule, bindings):
|
||
"""在指定宫位集合中,有多少个宫位至少包含一颗指定集合行星。"""
|
||
refs = self._resolve_binding(cond.get("planets", "all"), ctx, bindings)
|
||
houses = cond.get("houses", [])
|
||
min_count = cond.get("min", 1)
|
||
max_count = cond.get("max")
|
||
occupied = []
|
||
for h in houses:
|
||
matched = [p for p in refs if ctx.house_of(p) == h]
|
||
if matched:
|
||
occupied.append((h, matched))
|
||
count = len(occupied)
|
||
ok = count >= min_count
|
||
if max_count is not None:
|
||
ok = ok and count <= max_count
|
||
if ok:
|
||
tmpl = cond.get("combo_template", "{count}个目标宫位有指定行星")
|
||
detail = "; ".join(f"第{h}宫:" + "、".join(PLANET_CN.get(p, p) for p in ps)
|
||
for h, ps in occupied)
|
||
combo = tmpl.format(count=count, detail=detail,
|
||
houses="/".join(str(h) for h, _ in occupied))
|
||
return [{"combination": combo, "strength": rule.get("strength", "中"),
|
||
"bindings": {"occupied": occupied, "count": count}}]
|
||
return []
|
||
|
||
def _eval_degree_gap(self, cond, ctx, rule, bindings):
|
||
"""同星座内两颗行星度数差小于阈值"""
|
||
pa = self._resolve_binding(cond.get("a"), ctx, bindings)
|
||
pb = self._resolve_binding(cond.get("b"), ctx, bindings)
|
||
max_gap = cond.get("max_gap", 1.0)
|
||
results = []
|
||
for a in pa:
|
||
for b in pb:
|
||
if a == b:
|
||
continue
|
||
s1 = ctx.sign_of(a)
|
||
s2 = ctx.sign_of(b)
|
||
if s1 is None or s1 != s2:
|
||
continue
|
||
d1 = ctx.degree_of(a)
|
||
d2 = ctx.degree_of(b)
|
||
if d1 is None or d2 is None:
|
||
continue
|
||
if abs(d1 - d2) < max_gap:
|
||
tmpl = cond.get("combo_template", "{a}与{b}在{sign}内相距{gap:.2f}°")
|
||
combo = tmpl.format(a=PLANET_CN.get(a, a), b=PLANET_CN.get(b, b), sign=s1,
|
||
gap=abs(d1 - d2))
|
||
results.append({"combination": combo, "strength": rule.get("strength", "中")})
|
||
return results
|
||
|
||
def _eval_parivartana(self, cond, ctx, rule, bindings):
|
||
"""两颗宫主星互换星座(Parivartana/互落)"""
|
||
la = ctx.resolve_single(cond.get("lord_a"))
|
||
lb = ctx.resolve_single(cond.get("lord_b"))
|
||
if la is None or lb is None or la == lb:
|
||
return []
|
||
sa = ctx.sign_of(la)
|
||
sb = ctx.sign_of(lb)
|
||
if sa is None or sb is None:
|
||
return []
|
||
lord_of_sa = SIGN_LORDS.get(sa)
|
||
lord_of_sb = SIGN_LORDS.get(sb)
|
||
if lord_of_sa == lb and lord_of_sb == la:
|
||
tmpl = cond.get("combo_template", "{la}与{lb}互换星座")
|
||
combo = tmpl.format(la=PLANET_CN.get(la, la), lb=PLANET_CN.get(lb, lb))
|
||
return [{"combination": combo, "strength": rule.get("strength", "强")}]
|
||
return []
|
||
|
||
def _eval_any_pair(self, cond, ctx, rule, bindings):
|
||
"""从集合 A 和集合 B 中各取一个元素,配对满足条件"""
|
||
if "planets" in cond and "from" not in cond and "to" not in cond:
|
||
set_a = self._resolve_binding({"role": "set", "planets": cond.get("planets", [])}, ctx, bindings)
|
||
set_b = set_a
|
||
exclude_self = True
|
||
else:
|
||
set_a = self._resolve_binding(cond.get("from"), ctx, bindings)
|
||
set_b = self._resolve_binding(cond.get("to"), ctx, bindings)
|
||
exclude_self = cond.get("exclude_self", False)
|
||
sub_cond = cond.get("condition", {})
|
||
|
||
results = []
|
||
for a in set_a:
|
||
for b in set_b:
|
||
if exclude_self and a == b:
|
||
continue
|
||
pair_bindings = {**bindings, "a": a, "b": b}
|
||
matches = self._eval_condition(sub_cond, ctx, rule, pair_bindings)
|
||
for m in matches:
|
||
m["bindings"] = {**m.get("bindings", {}), "a": a, "b": b}
|
||
results.extend(matches)
|
||
return results
|
||
|
||
def _eval_all_planets(self, cond, ctx, rule, bindings):
|
||
"""所有指定行星都满足某个条件"""
|
||
refs = self._resolve_binding(cond.get("planets", "all"), ctx, bindings)
|
||
sub_cond = cond.get("condition", {})
|
||
all_match = True
|
||
matched_combo_parts = []
|
||
for p in refs:
|
||
b = {**bindings, "planet": p}
|
||
matches = self._eval_condition(sub_cond, ctx, rule, b)
|
||
if not matches:
|
||
all_match = False
|
||
break
|
||
matched_combo_parts.append(matches[0].get("combination", ""))
|
||
if all_match and refs:
|
||
combo = "; ".join(filter(None, matched_combo_parts))
|
||
return [{"combination": combo, "strength": rule.get("strength", "中")}]
|
||
return []
|
||
|
||
def _eval_any_planet(self, cond, ctx, rule, bindings):
|
||
"""任一指定行星满足条件"""
|
||
refs = self._resolve_binding(cond.get("planets"), ctx, bindings)
|
||
sub_cond = cond.get("condition", {})
|
||
for p in refs:
|
||
b = {**bindings, "planet": p}
|
||
matches = self._eval_condition(sub_cond, ctx, rule, b)
|
||
if matches:
|
||
return matches
|
||
return []
|
||
|
||
def _eval_exists(self, cond, ctx, rule, bindings):
|
||
"""存在至少一个行星满足条件"""
|
||
refs = self._resolve_binding(cond.get("planets"), ctx, bindings)
|
||
sub_cond = cond.get("condition", {})
|
||
for p in refs:
|
||
b = {**bindings, "planet": p}
|
||
matches = self._eval_condition(sub_cond, ctx, rule, b)
|
||
if matches:
|
||
return matches
|
||
return []
|
||
|
||
def _eval_lord_in_house(self, cond, ctx, rule, bindings):
|
||
"""某宫主星在指定宫位"""
|
||
house = cond.get("house")
|
||
target_houses = cond.get("target_houses", [])
|
||
lord = ctx.lord_of_house(house)
|
||
if lord is None or lord not in ctx.planets:
|
||
return []
|
||
lh = ctx.house_of(lord)
|
||
if lh is not None and lh in target_houses:
|
||
tmpl = cond.get("combo_template", "{house}宫主{lord}在第{lh}宫")
|
||
combo = tmpl.format(house=house, lord=PLANET_CN.get(lord, lord), lh=lh)
|
||
return [{"combination": combo, "strength": rule.get("strength", "中"),
|
||
"bindings": {"lord": lord, "house": lh}}]
|
||
return []
|
||
|
||
def _eval_lords_relation(self, cond, ctx, rule, bindings):
|
||
"""两个宫主星之间的特定关系(同宫/互落等)"""
|
||
lord_a = ctx.resolve_single(cond.get("lord_a"))
|
||
lord_b = ctx.resolve_single(cond.get("lord_b"))
|
||
if lord_a is None or lord_b is None:
|
||
return []
|
||
relation = cond.get("relation", "same_house")
|
||
if relation == "same_house":
|
||
ha = ctx.house_of(lord_a)
|
||
hb = ctx.house_of(lord_b)
|
||
if ha is not None and ha == hb:
|
||
tmpl = cond.get("combo_template", "{la}与{lb}同在第{h}宫")
|
||
combo = tmpl.format(la=PLANET_CN.get(lord_a, lord_a), lb=PLANET_CN.get(lord_b, lord_b), h=ha)
|
||
return [{"combination": combo, "strength": rule.get("strength", "中")}]
|
||
return []
|
||
|
||
def _eval_custom(self, cond, ctx, rule, bindings):
|
||
"""自定义 Python 表达式(用于复杂规则)"""
|
||
expr = cond.get("expr", "")
|
||
if not expr:
|
||
return []
|
||
|
||
def house_of(p): return ctx.house_of(p)
|
||
def sign_of(p): return ctx.sign_of(p)
|
||
def deg_of(p): return ctx.degree_of(p)
|
||
def lord(h): return ctx.lord_of_house(h)
|
||
def in_houses(p, hs): h = house_of(p); return h is not None and h in hs
|
||
def dignity(p): return ctx.dignity(p)
|
||
def exalted(p): return ctx.is_exalted(p)
|
||
def own(p): return ctx.is_own_sign(p)
|
||
def debil(p): return ctx.is_debilitated(p)
|
||
def moola(p): return ctx.is_moolatrikona(p)
|
||
def kendra(h): return ctx.is_kendra(h)
|
||
def trikona(h): return ctx.is_trikona(h)
|
||
def dusthana(h): return ctx.is_dusthana(h)
|
||
def upachaya(h): return ctx.is_upachaya(h)
|
||
def kendra_from(p, fp): return ctx.is_kendra_from(p, fp)
|
||
def trikona_from(p, fp): return ctx.is_trikona_from(p, fp)
|
||
def offset(fh, th): return ctx.house_offset(fh, th)
|
||
def planets_in(h): return ctx.planets_in_house(h)
|
||
def resolve(ref): return ctx.resolve(ref)
|
||
|
||
# v6.0.32: 修复 custom 规则中常用的缺失辅助函数
|
||
def same_house(a, b):
|
||
"""两颗行星是否同宫(允许自比较:同星恒为 True)"""
|
||
ha = ctx.house_of(a)
|
||
hb = ctx.house_of(b)
|
||
return ha is not None and ha == hb
|
||
|
||
def aspect(a, b):
|
||
"""行星a是否aspect行星b(7th aspect + Mars/Jupiter/Saturn的特殊aspect)"""
|
||
if a == b:
|
||
return False
|
||
ha = ctx.house_of(a)
|
||
hb = ctx.house_of(b)
|
||
if ha is None or hb is None:
|
||
return False
|
||
off = ctx.house_offset(ha, hb)
|
||
# 7th aspect for all planets
|
||
if off == 6:
|
||
return True
|
||
# Mars: 4th, 8th aspects
|
||
if a == 'Mars' and off in (3, 7):
|
||
return True
|
||
# Jupiter: 5th, 9th aspects
|
||
if a == 'Jupiter' and off in (4, 8):
|
||
return True
|
||
# Saturn: 3rd, 10th aspects
|
||
if a == 'Saturn' and off in (2, 9):
|
||
return True
|
||
return False
|
||
|
||
def aspects_house(p, h):
|
||
if p not in ctx.planets or h is None:
|
||
return False
|
||
return h == ctx.house_of(p) or offset(ctx.house_of(p), h) in (6, 3 if p == 'Mars' else -1, 7 if p == 'Mars' else -1, 4 if p == 'Jupiter' else -1, 8 if p == 'Jupiter' else -1, 2 if p == 'Saturn' else -1, 9 if p == 'Saturn' else -1)
|
||
|
||
def graha_aspects_house(p, h):
|
||
if p not in ctx.planets or h is None:
|
||
return False
|
||
return offset(ctx.house_of(p), h) in (6, 3 if p == 'Mars' else -1, 7 if p == 'Mars' else -1, 4 if p == 'Jupiter' else -1, 8 if p == 'Jupiter' else -1, 2 if p == 'Saturn' else -1, 9 if p == 'Saturn' else -1)
|
||
|
||
def external_benchmark_planets_aspecting_raasi(p, h):
|
||
"""Replicate PyJHora house.planets_aspecting_the_raasi() behavior for source parity."""
|
||
if p not in ctx.planets or h is None:
|
||
return False
|
||
sign_name = ctx.sign_of(p)
|
||
if sign_name not in SIGNS:
|
||
return False
|
||
target_rasi_idx = (ctx.asc_idx + h - 1) % 12
|
||
aspected_signs = rasi_drishti_signs_from(sign_name)
|
||
planet_ids_in_aspected_signs = [
|
||
PLANET_INDEX[q]
|
||
for q in ctx.planets
|
||
if q in PLANET_INDEX and ctx.sign_of(q) in aspected_signs
|
||
]
|
||
return target_rasi_idx in planet_ids_in_aspected_signs
|
||
|
||
def external_benchmark_aspected_planets_of_raasi(h):
|
||
"""Replicate PyJHora house.aspected_planets_of_the_raasi(): planets whose rasi drishti hits a target house."""
|
||
if h is None:
|
||
return []
|
||
target_sign = house_sign(h)
|
||
return [
|
||
p for p in ctx.planets
|
||
if p in PLANET_INDEX and target_sign in rasi_drishti_signs_from(ctx.sign_of(p))
|
||
]
|
||
|
||
def rasi_drishti_signs_from(sign_name):
|
||
if sign_name not in SIGNS:
|
||
return []
|
||
sign_idx = SIGNS.index(sign_name)
|
||
if sign_name in MOVABLE_SIGNS:
|
||
return [SIGNS[i] for i in [1, 4, 7, 10] if i not in ((sign_idx + 1) % 12, (sign_idx - 1) % 12)]
|
||
if sign_name in FIXED_SIGNS:
|
||
return [SIGNS[i] for i in [0, 3, 6, 9] if i not in ((sign_idx + 1) % 12, (sign_idx - 1) % 12)]
|
||
return [s for s in DUAL_SIGNS if s != sign_name]
|
||
|
||
def rasi_aspects_house(p, h):
|
||
if p not in ctx.planets or h is None:
|
||
return False
|
||
return house_sign(h) in rasi_drishti_signs_from(ctx.sign_of(p))
|
||
|
||
def rasi_aspects(a, b):
|
||
return a in ctx.planets and b in ctx.planets and rasi_aspects_house(a, ctx.house_of(b))
|
||
|
||
def rasi_aspected_by_planets(h):
|
||
return [p for p in ctx.planets if rasi_aspects_house(p, h)]
|
||
|
||
def sign_index_of_planet(p):
|
||
sign_name = ctx.sign_of(p)
|
||
return SIGNS.index(sign_name) if sign_name in SIGNS else None
|
||
|
||
def house_strength(p):
|
||
sign_idx = sign_index_of_planet(p)
|
||
if sign_idx is None or p not in HOUSE_STRENGTHS:
|
||
return None
|
||
strength = HOUSE_STRENGTHS[p][sign_idx]
|
||
sign_name = SIGNS[sign_idx]
|
||
# v6.0.39: PyJHora dynamic co-lord owner strength for Scorpio/Aquarius.
|
||
# Without this, Ketu/Rahu can become house lords but still look non-owner in strength checks.
|
||
if sign_name in ('Scorpio', 'Aquarius') and ctx._resolve_sign_lord(sign_name) == p:
|
||
return max(strength, 5)
|
||
return strength
|
||
|
||
def strong(p, include_neutral=False):
|
||
strength = house_strength(p)
|
||
if strength is None:
|
||
return False
|
||
return strength >= (2 if include_neutral else 3)
|
||
|
||
def weak(p):
|
||
strength = house_strength(p)
|
||
return strength is not None and strength <= 2
|
||
|
||
def associated(a, b):
|
||
return a in ctx.planets and b in ctx.planets and (same_house(a, b) or aspect(a, b) or aspect(b, a))
|
||
|
||
def temporal_friend(a, b):
|
||
if a not in ctx.planets or b not in ctx.planets or a == b:
|
||
return False
|
||
# PyJHora temporary friends: 2/3/4/10/11/12 from a planet.
|
||
return offset(ctx.house_of(a), ctx.house_of(b)) in [1, 2, 3, 9, 10, 11]
|
||
|
||
def natural_friend(a, b):
|
||
return a in FRIENDLY_PLANETS.get(b, []) and b in FRIENDLY_PLANETS.get(a, [])
|
||
|
||
def occupants(h):
|
||
return ctx.planets_in_house(h) if h is not None else []
|
||
|
||
def only_benefics_in_house(h):
|
||
ps = occupants(h)
|
||
return bool(ps) and all(p in BENEFICS for p in ps)
|
||
|
||
def only_malefics_in_house(h):
|
||
ps = occupants(h)
|
||
return bool(ps) and all(p in MALEFICS for p in ps)
|
||
|
||
def house_has_benefic(h):
|
||
return any(p in BENEFICS for p in occupants(h))
|
||
|
||
def house_has_malefic(h):
|
||
return any(p in MALEFICS for p in occupants(h))
|
||
|
||
def house_sign(h):
|
||
if h is None:
|
||
return None
|
||
return SIGNS[(ctx.asc_idx + h - 1) % 12]
|
||
|
||
def movable_house(h):
|
||
return house_sign(h) in MOVABLE_SIGNS
|
||
|
||
def d9_house_of(p): return ctx.d9_house_of(p)
|
||
def d9_sign_of(p): return ctx.d9_sign_of(p)
|
||
def d9_lord_of_house(h): return ctx.d9_lord_of_house(h)
|
||
def navamsa_dispositor(p): return ctx.navamsa_dispositor(p)
|
||
def tithi(): return ctx.tithi()
|
||
def is_waning_moon(): return ctx.is_waning_moon()
|
||
def upagraha_house(name): return ctx.upagraha_house(name)
|
||
def upagraha_sign(name): return ctx.upagraha_sign(name)
|
||
def gulika_house(): return ctx.upagraha_house("gulika")
|
||
def maandi_house(): return ctx.upagraha_house("maandi")
|
||
def gulika_sign(): return ctx.upagraha_sign("gulika")
|
||
def maandi_sign(): return ctx.upagraha_sign("maandi")
|
||
|
||
def exal(p): return ctx.is_exalted(p)
|
||
def lord_of_house(h): return ctx.lord_of_house(h)
|
||
def sign(p): return ctx.sign_of(p)
|
||
|
||
def check_amala_from(base_house):
|
||
"""Amala: 第10宫仅有吉星,支持从Lagna/Moon等基准宫位计算。"""
|
||
target = ((base_house - 1 + 9) % 12) + 1
|
||
occupants = ctx.planets_in_house(target)
|
||
return bool(occupants) and all(p in BENEFICS for p in occupants)
|
||
|
||
def external_benchmark_natural_benefics():
|
||
"""Replicate PyJHora yoga._get_natural_benefics(): Jupiter, Venus, plus benefic Mercury."""
|
||
benefics = [p for p in ["Jupiter", "Venus"] if p in ctx.planets]
|
||
mercury_house = ctx.house_of("Mercury")
|
||
if mercury_house is not None:
|
||
mercury_alone = len(ctx.planets_in_house(mercury_house)) == 1
|
||
mercury_with_jupiter_or_venus = any(ctx.house_of(p) == mercury_house for p in ["Jupiter", "Venus"] if p in ctx.planets)
|
||
if mercury_alone or mercury_with_jupiter_or_venus:
|
||
benefics.append("Mercury")
|
||
return benefics
|
||
|
||
# 辅助:获取星盘所有行星名列表
|
||
def _planets_list():
|
||
return list(ctx.planets.keys())
|
||
# 辅助:获取所有宫主星(1-12宫)
|
||
def _all_lords():
|
||
return [ctx.lord_of_house(h) for h in range(1, 13)]
|
||
# 辅助:获取角宫主星列表
|
||
def _kendra_lords_list():
|
||
ai = SIGNS.index(ctx.ascendant) if ctx.ascendant in SIGNS else 0
|
||
return list(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 4, 7, 10]]))
|
||
# 辅助:获取三方宫主星列表
|
||
def _trikona_lords_list():
|
||
ai = SIGNS.index(ctx.ascendant) if ctx.ascendant in SIGNS else 0
|
||
return list(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 5, 9]]))
|
||
|
||
safe_globals = {
|
||
# 安全内置函数:只开放规则表达式需要的纯函数,避免 custom 规则因 all/any/len/set 缺失而静默失效。
|
||
"all": all, "any": any, "len": len, "set": set, "tuple": tuple, "sorted": sorted,
|
||
"abs": abs, "min": min, "max": max, "sum": sum, "range": range,
|
||
"list": list, "bool": bool,
|
||
"ctx": ctx, "bindings": bindings, "rule": rule,
|
||
"SIGNS": SIGNS, "SIGN_LORDS": SIGN_LORDS,
|
||
"FRIENDLY_PLANETS": FRIENDLY_PLANETS,
|
||
"RAHU_KETU_OWNED_SIGN_INDEX": RAHU_KETU_OWNED_SIGN_INDEX,
|
||
"EXALTATION": EXALTATION, "DEBILITATION": DEBILITATION,
|
||
"PLANET_CN": PLANET_CN, "BENEFICS": BENEFICS, "MALEFICS": MALEFICS,
|
||
"MOVABLE_SIGNS": MOVABLE_SIGNS, "FIXED_SIGNS": FIXED_SIGNS, "DUAL_SIGNS": DUAL_SIGNS,
|
||
"PLANET_INDEX": PLANET_INDEX, "HOUSE_STRENGTHS": HOUSE_STRENGTHS,
|
||
"ALL_PLANETS": ALL_PLANETS, "MOOLATRIKONA_SIGN": MOOLATRIKONA_SIGN,
|
||
# 基础查询
|
||
"house_of": house_of, "sign_of": sign_of, "deg_of": deg_of,
|
||
"get_sign_of": sign_of, # 别名
|
||
"lord": lord, "lords_of": lord, # 别名(接受宫位数)
|
||
# 尊严检查
|
||
"dignity": dignity,
|
||
"exalted": exalted, "is_exalted": exalted,
|
||
"own": own, "is_own_sign": own,
|
||
"debil": debil, "is_debilitated": debil,
|
||
"moola": moola, "is_moolatrikona": moola,
|
||
# 宫位类型
|
||
"in_houses": in_houses, "planets_in": planets_in,
|
||
"kendra": kendra, "is_kendra": kendra,
|
||
"trikona": trikona, "is_trikona": trikona,
|
||
"dusthana": dusthana, "is_dusthana": dusthana,
|
||
"upachaya": upachaya, "is_upachaya": upachaya,
|
||
"kendra_from": kendra_from, "trikona_from": trikona_from,
|
||
"offset": offset,
|
||
# v6.0.32: 同宫与相位检查(custom规则常用)
|
||
"same_house": same_house, "aspect": aspect, "aspects_house": aspects_house,
|
||
"graha_aspects_house": graha_aspects_house,
|
||
"external_benchmark_planets_aspecting_raasi": external_benchmark_planets_aspecting_raasi,
|
||
"external_benchmark_aspected_planets_of_raasi": external_benchmark_aspected_planets_of_raasi,
|
||
"rasi_drishti_signs_from": rasi_drishti_signs_from,
|
||
"rasi_aspects_house": rasi_aspects_house, "rasi_aspects": rasi_aspects,
|
||
"rasi_aspected_by_planets": rasi_aspected_by_planets,
|
||
"house_strength": house_strength, "strong": strong, "weak": weak,
|
||
"associated": associated, "temporal_friend": temporal_friend, "natural_friend": natural_friend,
|
||
"occupants": occupants, "only_benefics_in_house": only_benefics_in_house,
|
||
"only_malefics_in_house": only_malefics_in_house,
|
||
"house_has_benefic": house_has_benefic, "house_has_malefic": house_has_malefic,
|
||
"house_sign": house_sign, "movable_house": movable_house,
|
||
"external_benchmark_natural_benefics": external_benchmark_natural_benefics,
|
||
"d9_house_of": d9_house_of, "d9_sign_of": d9_sign_of,
|
||
"d9_lord_of_house": d9_lord_of_house, "navamsa_dispositor": navamsa_dispositor,
|
||
"tithi": tithi, "is_waning_moon": is_waning_moon,
|
||
"upagraha_house": upagraha_house, "upagraha_sign": upagraha_sign,
|
||
"gulika_house": gulika_house, "maandi_house": maandi_house,
|
||
"gulika_sign": gulika_sign, "maandi_sign": maandi_sign,
|
||
# v6.1.3: D60 / Shashtiamsa 扩展
|
||
"d60_house_of": lambda p: ctx.d60_house_of(p),
|
||
"d60_sign_of": lambda p: ctx.d60_sign_of(p),
|
||
"d60_lord_of_house": lambda h: ctx.d60_lord_of_house(h),
|
||
"shashtiamsa_dispositor": lambda p: ctx.shashtiamsa_dispositor(p),
|
||
"is_shashtiamsa_evil": lambda p: ctx.is_shashtiamsa_evil(p),
|
||
"is_shashtiamsa_good": lambda p: ctx.is_shashtiamsa_good(p),
|
||
# v6.1.3: D9 尊严扩展
|
||
"is_exalted_in_d9": lambda p: ctx.is_exalted_in_d9(p),
|
||
"is_debilitated_in_d9": lambda p: ctx.is_debilitated_in_d9(p),
|
||
"is_own_sign_in_d9": lambda p: ctx.is_own_sign_in_d9(p),
|
||
"is_moolatrikona_in_d9": lambda p: ctx.is_moolatrikona_in_d9(p),
|
||
"dignity_in_d9": lambda p: ctx.dignity_in_d9(p),
|
||
"is_friendly_navamsa": lambda p: ctx.is_friendly_navamsa(p),
|
||
"is_unfriendly_navamsa": lambda p: ctx.is_unfriendly_navamsa(p),
|
||
# v6.1.3: Functional malefic / benefic
|
||
"functional_malefics": lambda: ctx.functional_malefics(),
|
||
"functional_benefics": lambda: ctx.functional_benefics(),
|
||
# v6.1.3: Vaiseshikamsa scoring
|
||
"vaiseshikamsa_score": lambda p: ctx.vaiseshikamsa_score(p),
|
||
"exal": exal, "lord_of_house": lord_of_house, "sign": sign,
|
||
"check_amala_from": check_amala_from,
|
||
"Benefics": BENEFICS, "Malefics": MALEFICS,
|
||
"planets": ctx.planets,
|
||
# 解析
|
||
"resolve": resolve,
|
||
# 列表辅助
|
||
"planets_list": _planets_list,
|
||
"all_lords": _all_lords,
|
||
"kendra_lords_list": _kendra_lords_list,
|
||
"trikona_lords_list": _trikona_lords_list,
|
||
}
|
||
# v6.0.32: 支持多行语句(if/else/for等)+ 末尾表达式求值模式。
|
||
# 不能简单 split 最后一行:多行 if/else 的最后一行经常只是 else 分支内部表达式,
|
||
# 直接 exec 前半段会产生缩进不完整。这里用 AST 捕获“实际执行分支”的末尾表达式。
|
||
result = None
|
||
exec_globals = {"__builtins__": {}, **safe_globals}
|
||
|
||
def _capture_tail_expr(block):
|
||
"""把代码块末尾表达式改写为 __result__ = <expr>;递归处理 if/for/while 分支。"""
|
||
if not block:
|
||
return block
|
||
last = block[-1]
|
||
if isinstance(last, ast.Expr):
|
||
block[-1] = ast.Assign(targets=[ast.Name(id="__result__", ctx=ast.Store())], value=last.value)
|
||
elif isinstance(last, ast.If):
|
||
last.body = _capture_tail_expr(last.body)
|
||
last.orelse = _capture_tail_expr(last.orelse)
|
||
elif isinstance(last, (ast.For, ast.While)):
|
||
last.body = _capture_tail_expr(last.body)
|
||
last.orelse = _capture_tail_expr(last.orelse)
|
||
elif isinstance(last, ast.Try):
|
||
last.body = _capture_tail_expr(last.body)
|
||
last.orelse = _capture_tail_expr(last.orelse)
|
||
last.finalbody = _capture_tail_expr(last.finalbody)
|
||
for handler in last.handlers:
|
||
handler.body = _capture_tail_expr(handler.body)
|
||
return block
|
||
|
||
try:
|
||
# 同一命名空间同时作为 globals/locals,保证 list/dict comprehension 能访问 BENEFICS 等名称。
|
||
result = eval(expr, exec_globals, exec_globals)
|
||
except SyntaxError:
|
||
try:
|
||
tree = ast.parse(expr.strip(), mode="exec")
|
||
tree.body = _capture_tail_expr(tree.body)
|
||
ast.fix_missing_locations(tree)
|
||
exec(compile(tree, "<yoga_custom>", "exec"), exec_globals, exec_globals)
|
||
result = exec_globals.get("__result__")
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
if result:
|
||
combo = cond.get("combo_template", "自定义条件满足")
|
||
if callable(combo):
|
||
combo = combo(ctx, bindings)
|
||
strength = cond.get("strength", rule.get("strength", "中"))
|
||
if callable(strength):
|
||
strength = strength(ctx, bindings)
|
||
return [{"combination": combo, "strength": strength}]
|
||
return []
|
||
|
||
def _eval_semantic_condition(self, ctype, cond, ctx, rule, bindings):
|
||
"""兼容批量抽取规则中的语义型条件。返回保守布尔匹配。"""
|
||
def success(combo=None):
|
||
return [{"combination": combo or cond.get("note", ctype), "strength": rule.get("strength", "中")}]
|
||
|
||
def house(ref):
|
||
if isinstance(ref, int):
|
||
return ref
|
||
if isinstance(ref, str):
|
||
if ref in ctx.planets:
|
||
return ctx.house_of(ref)
|
||
low = ref.lower()
|
||
if low in ("lagna", "asc", "ascendant"):
|
||
return 1
|
||
if low == "moon":
|
||
return ctx.house_of("Moon")
|
||
return None
|
||
|
||
def planet_list(default=None):
|
||
ps = cond.get("planets", default or [])
|
||
if ps == "all":
|
||
return [p for p in ALL_PLANETS if p in ctx.planets]
|
||
if isinstance(ps, str):
|
||
return [ps] if ps in ctx.planets else []
|
||
return [p for p in ps if p in ctx.planets]
|
||
|
||
def aspects(a, b):
|
||
# b 可为行星名或宫位整数;支持检查行星对宫位/行星的传统相位。
|
||
ha = ctx.house_of(a)
|
||
hb = ctx.house_of(b) if isinstance(b, str) else b
|
||
if ha is None or hb is None or a == b:
|
||
return False
|
||
off = ctx.house_offset(ha, hb)
|
||
return off == 6 or (a == "Mars" and off in (3, 7)) or (a == "Jupiter" and off in (4, 8)) or (a == "Saturn" and off in (2, 9))
|
||
|
||
def related(a, b):
|
||
return a in ctx.planets and b in ctx.planets and (ctx.house_of(a) == ctx.house_of(b) or aspects(a, b) or aspects(b, a))
|
||
|
||
def house_lord(h):
|
||
return ctx.lord_of_house(h) if isinstance(h, int) else None
|
||
|
||
def is_benefic(p):
|
||
return p in BENEFICS
|
||
|
||
def is_malefic(p):
|
||
return p in MALEFICS
|
||
|
||
def is_strong(p):
|
||
return p in ctx.planets and (ctx.is_exalted(p) or ctx.is_own_sign(p) or ctx.is_moolatrikona(p) or ctx.is_kendra(ctx.house_of(p)) or ctx.is_trikona(ctx.house_of(p)))
|
||
|
||
def rel_house(base, offset0):
|
||
return ((base - 1 + offset0) % 12) + 1 if base else None
|
||
|
||
if ctype == "placeholder":
|
||
return []
|
||
if ctype == "planet_in_lagna":
|
||
p = cond.get("planet")
|
||
return success() if p in ctx.planets and ctx.house_of(p) == 1 else []
|
||
if ctype == "planet_in_house":
|
||
p = cond.get("planet")
|
||
h = cond.get("house")
|
||
return success() if p in ctx.planets and ctx.house_of(p) == h else []
|
||
if ctype == "venus_in_house":
|
||
h = cond.get("house")
|
||
return success() if "Venus" in ctx.planets and ctx.house_of("Venus") == h else []
|
||
if ctype == "benefic_in_house":
|
||
h = cond.get("house")
|
||
return success() if any(ctx.house_of(p) == h for p in BENEFICS if p in ctx.planets) else []
|
||
if ctype == "planets_in_house":
|
||
h = cond.get("house")
|
||
ps = planet_list()
|
||
return success() if h and ps and all(ctx.house_of(p) == h for p in ps) else []
|
||
if ctype == "planets_conjunct_in_same_house":
|
||
ps = planet_list()
|
||
return success() if len(ps) >= 2 and len({ctx.house_of(p) for p in ps}) == 1 else []
|
||
if ctype == "planets_in_exactly_n_signs":
|
||
ps = planet_list(['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'])
|
||
n = cond.get("n")
|
||
return success() if n and len({ctx.sign_of(p) for p in ps if p in ctx.planets}) == n else []
|
||
if ctype == "planets_in_kendras":
|
||
ps = planet_list()
|
||
return success() if ps and all(ctx.is_kendra(ctx.house_of(p)) for p in ps) else []
|
||
if ctype == "planets_in_trines":
|
||
ps = planet_list()
|
||
return success() if ps and all(ctx.is_trikona(ctx.house_of(p)) for p in ps) else []
|
||
if ctype == "malefics_in_kendras_and_trines":
|
||
ps = [p for p in MALEFICS if p in ctx.planets]
|
||
return success() if any(ctx.is_kendra(ctx.house_of(p)) for p in ps) and any(ctx.is_trikona(ctx.house_of(p)) for p in ps) else []
|
||
if ctype == "malefics_in_lagna":
|
||
return success() if any(ctx.house_of(p) == 1 for p in MALEFICS if p in ctx.planets) else []
|
||
if ctype == "malefics_in_trines":
|
||
return success() if any(ctx.is_trikona(ctx.house_of(p)) for p in MALEFICS if p in ctx.planets) else []
|
||
if ctype == "planet_exalted_in_kendra":
|
||
p = cond.get("planet")
|
||
return success() if p in ctx.planets and ctx.is_exalted(p) and ctx.is_kendra(ctx.house_of(p)) else []
|
||
if ctype == "lord_exalted":
|
||
p = house_lord(cond.get("lord_of") or cond.get("house"))
|
||
return success() if p and ctx.is_exalted(p) else []
|
||
if ctype == "lord_in_kendra":
|
||
p = house_lord(cond.get("lord_of") or cond.get("house"))
|
||
return success() if p and ctx.is_kendra(ctx.house_of(p)) else []
|
||
if ctype == "lord_in_exaltation_own_or_mool_trikona":
|
||
p = house_lord(cond.get("lord_of") or cond.get("house"))
|
||
return success() if p and (ctx.is_exalted(p) or ctx.is_own_sign(p) or ctx.is_moolatrikona(p)) else []
|
||
if ctype == "mercury_strong_in_kendra_or_trikona":
|
||
return success() if "Mercury" in ctx.planets and is_strong("Mercury") and (ctx.is_kendra(ctx.house_of("Mercury")) or ctx.is_trikona(ctx.house_of("Mercury"))) else []
|
||
if ctype == "moon_strong":
|
||
return success() if "Moon" in ctx.planets and is_strong("Moon") else []
|
||
if ctype == "planet_in_own_or_exalted_sign":
|
||
p = cond.get("planet")
|
||
return success() if p in ctx.planets and (ctx.is_own_sign(p) or ctx.is_exalted(p)) else []
|
||
if ctype == "planet_aspected_by_planet":
|
||
p = cond.get("planet") or cond.get("to")
|
||
by = cond.get("by") or cond.get("from")
|
||
return success() if p in ctx.planets and by in ctx.planets and aspects(by, p) else []
|
||
if ctype == "navamsa_lord_of_2_5_11_exalted_joins_9th_lord":
|
||
l9 = house_lord(9)
|
||
for h in (2, 5, 11):
|
||
lord_h = house_lord(h)
|
||
disp = ctx.navamsa_dispositor(lord_h) if lord_h else None
|
||
if disp and l9 and disp in ctx.planets and ctx.is_exalted(disp) and related(disp, l9):
|
||
return success()
|
||
return []
|
||
if ctype == "navamsa_lord_of_moon_exalted_in_rasi":
|
||
disp = ctx.navamsa_dispositor("Moon")
|
||
return success() if disp and disp in ctx.planets and ctx.is_exalted(disp) else []
|
||
if ctype == "navamsa_dispositor_exalted_in_10th_with_lagna_lord":
|
||
ref = cond.get("reference_planet")
|
||
if ref == "10th_lord":
|
||
ref_planet = house_lord(10)
|
||
else:
|
||
ref_planet = ref if ref in ctx.planets else None
|
||
disp = ctx.navamsa_dispositor(ref_planet) if ref_planet else None
|
||
l1 = house_lord(1)
|
||
return success() if disp and l1 and disp in ctx.planets and ctx.is_exalted(disp) and ctx.house_of(disp) == 10 and related(disp, l1) else []
|
||
if ctype == "navamsa_lord_of_4th_lord_in_12th":
|
||
l4 = house_lord(4)
|
||
disp = ctx.navamsa_dispositor(l4) if l4 else None
|
||
return success() if disp and disp in ctx.planets and ctx.house_of(disp) == 12 else []
|
||
if ctype == "moon_with_malefics_in_navamsa_cancer_scorpio":
|
||
moon_sign = ctx.d9_sign_of("Moon")
|
||
moon_house = ctx.d9_house_of("Moon")
|
||
return success() if moon_sign in {"Cancer", "Scorpio"} and moon_house and any(ctx.d9_house_of(p) == moon_house for p in MALEFICS if p in ctx.planets and p != "Moon") else []
|
||
if ctype == "sun_and_mandi_in_house":
|
||
target = cond.get("house") or cond.get("in_house")
|
||
if target is None and cond.get("house_offset") is not None:
|
||
target = int(cond.get("house_offset")) + 1
|
||
return success() if target and ctx.house_of("Sun") == target and ctx.upagraha_house("maandi") == target else []
|
||
if ctype == "planet_conjunct_planet":
|
||
a = cond.get("planet") or cond.get("a")
|
||
b = cond.get("with") or cond.get("b")
|
||
return success() if a in ctx.planets and b in ctx.planets and ctx.house_of(a) == ctx.house_of(b) else []
|
||
if ctype == "lord_in_house":
|
||
lord = ctx.lord_of_house(cond.get("lord_of") or cond.get("house") or cond.get("lord_house"))
|
||
target = cond.get("in_house") or cond.get("target_house")
|
||
return success() if lord and ctx.house_of(lord) == target else []
|
||
if ctype == "house_lord_in_kendra":
|
||
lord = ctx.lord_of_house(cond.get("house"))
|
||
return success() if lord and ctx.is_kendra(ctx.house_of(lord)) else []
|
||
if ctype == "house_lord_in_trine":
|
||
lord = ctx.lord_of_house(cond.get("house"))
|
||
return success() if lord and ctx.is_trikona(ctx.house_of(lord)) else []
|
||
if ctype in ("lagna_hemmed_by_malefics_not_aspected_by_benefics", "lagna_hemmed_by_benefics_not_aspected_by_malefics"):
|
||
group = MALEFICS if "malefics" in ctype else BENEFICS
|
||
blockers = BENEFICS if "malefics" in ctype else MALEFICS
|
||
has_2 = any(ctx.house_of(p) == 2 for p in group if p in ctx.planets)
|
||
has_12 = any(ctx.house_of(p) == 12 for p in group if p in ctx.planets)
|
||
aspected = any(aspects(p, 1) for p in blockers if p in ctx.planets)
|
||
return success() if has_2 and has_12 and not aspected else []
|
||
if ctype == "planets_in_mutual_kendras_or_conjunction":
|
||
ps = planet_list()
|
||
if len(ps) >= 2:
|
||
a, b = ps[0], ps[1]
|
||
ha, hb = ctx.house_of(a), ctx.house_of(b)
|
||
return success() if ha and hb and (ha == hb or ctx.house_offset(ha, hb) in (0, 3, 6, 9)) else []
|
||
if ctype == "lords_in_mutual_conjunction_or_aspect":
|
||
houses = cond.get("lords") or []
|
||
ps = [house_lord(h) for h in houses]
|
||
return success() if len(ps) >= 2 and related(ps[0], ps[1]) else []
|
||
if ctype == "aspected_by_benefics":
|
||
p = cond.get("planet")
|
||
if isinstance(p, str) and p.endswith("_lord"):
|
||
p = house_lord(int(p.split("_")[0])) if p.split("_")[0].isdigit() else None
|
||
return success() if p and any(aspects(b, p) for b in BENEFICS if b in ctx.planets) else []
|
||
if ctype == "aspected_by_malefic":
|
||
p = cond.get("planet")
|
||
if p == "4th_lord":
|
||
p = house_lord(4)
|
||
return success() if p and any(aspects(m, p) for m in MALEFICS if m in ctx.planets) else []
|
||
if ctype == "5th_lord_afflicted_by_saturn_or_rahu":
|
||
p = house_lord(5)
|
||
return success() if p and (related(p, "Saturn") or related(p, "Rahu")) else []
|
||
if ctype == "malefic_in_lagna_or_gulika_in_trine_OR_gulika_with_kendra_trine_lord_OR_l1_with_rahu_sat_ket":
|
||
l1 = house_lord(1)
|
||
gh = ctx.upagraha_house("gulika")
|
||
# PyJHora: condition 3 stands alone; condition 1 requires BOTH malefic in Lagna AND Gulika in trine.
|
||
l1_with_rahu_sat_ket = l1 and any(ctx.house_of(l1) == ctx.house_of(p) for p in ["Rahu", "Saturn", "Ketu"] if p in ctx.planets and p != l1)
|
||
if l1_with_rahu_sat_ket:
|
||
return success()
|
||
if gh is None:
|
||
return []
|
||
malefic_in_lagna = any(ctx.house_of(p) == 1 for p in MALEFICS if p in ctx.planets)
|
||
gulika_in_trine = gh in (1, 5, 9)
|
||
if malefic_in_lagna and gulika_in_trine:
|
||
return success()
|
||
kendra_trine_lords = {house_lord(h) for h in (1, 4, 5, 7, 9, 10)}
|
||
gulika_with_kendra_trine_lord = any(
|
||
lord and lord in ctx.planets and ctx.house_of(lord) == gh for lord in kendra_trine_lords
|
||
)
|
||
return success() if gulika_with_kendra_trine_lord else []
|
||
if ctype == "same_lord_for_1st_and_4th":
|
||
return success() if house_lord(1) == house_lord(4) else []
|
||
if ctype == "1st_and_4th_lords_are_natural_or_temporal_friends":
|
||
# 保守近似:同宫/互相位视作 temporal association。
|
||
l1, l4 = house_lord(1), house_lord(4)
|
||
return success() if l1 and l4 and related(l1, l4) else []
|
||
if ctype == "1st_and_4th_lords_aspected_by_benefics":
|
||
l1, l4 = house_lord(1), house_lord(4)
|
||
ok1 = l1 and any(aspects(b, l1) for b in BENEFICS if b in ctx.planets)
|
||
ok4 = l4 and any(aspects(b, l4) for b in BENEFICS if b in ctx.planets)
|
||
return success() if ok1 and ok4 else []
|
||
if ctype == "4th_lord_is_benefic_aspected_by_benefic":
|
||
l4 = house_lord(4)
|
||
return success() if l4 and is_benefic(l4) and any(aspects(b, l4) for b in BENEFICS if b in ctx.planets and b != l4) else []
|
||
if ctype == "4th_house_or_lord_with_aspect_of_jupiter":
|
||
l4 = house_lord(4)
|
||
return success() if "Jupiter" in ctx.planets and (aspects("Jupiter", 4) or (l4 and related("Jupiter", l4))) else []
|
||
if ctype == "4th_lord_with_benefics":
|
||
l4 = house_lord(4)
|
||
return success() if l4 and any(ctx.house_of(l4) == ctx.house_of(b) for b in BENEFICS if b in ctx.planets and b != l4) else []
|
||
if ctype == "4th_lord_aspected_by_benefics":
|
||
l4 = house_lord(4)
|
||
return success() if l4 and any(aspects(b, l4) for b in BENEFICS if b in ctx.planets and b != l4) else []
|
||
if ctype == "4th_lord_in_kendra_or_trikona":
|
||
l4 = house_lord(4)
|
||
return success() if l4 and (ctx.is_kendra(ctx.house_of(l4)) or ctx.is_trikona(ctx.house_of(l4))) else []
|
||
if ctype == "lagna_lord_in_dry_sign":
|
||
l1 = house_lord(1)
|
||
# PyJHora const.dry_signs = [0,1,2,4,5,8] = Aries,Taurus,Gemini,Leo,Virgo,Sagittarius
|
||
dry_signs = {"Aries", "Taurus", "Gemini", "Leo", "Virgo", "Sagittarius"}
|
||
# PyJHora const.dry_planets = [0,2,6] = Sun,Mars,Saturn
|
||
dry_lords = {"Sun", "Mars", "Saturn"}
|
||
# PyJHora Y112: (ll_house in dry_signs) OR (ll_house_owner in dry_planets)
|
||
# ll_house = sign of lagna lord; ll_house_owner = dynamic lord of that sign
|
||
ll_in_dry = l1 and ctx.sign_of(l1) in dry_signs
|
||
l1_house = ctx.house_of(l1) if l1 else None
|
||
ll_owner_dry = l1_house and ctx.lord_of_house(l1_house) in dry_lords
|
||
return success() if l1 and (ll_in_dry or ll_owner_dry) else []
|
||
if ctype == "navamsa_lagna_in_dry_planet_sign":
|
||
d9_asc = ctx.d9.get("ascendant")
|
||
dry_lords = {"Sun", "Mars", "Saturn"}
|
||
return success() if d9_asc and SIGN_LORDS.get(d9_asc) in dry_lords else []
|
||
if ctype == "lagna_lord_and_navamsa_lord_both_in_watery_signs":
|
||
l1 = house_lord(1)
|
||
navamsa_lord = ctx.navamsa_dispositor(l1) if l1 else None
|
||
watery = {"Cancer", "Scorpio", "Pisces"}
|
||
return success() if l1 and navamsa_lord and ctx.sign_of(l1) in watery and ctx.sign_of(navamsa_lord) in watery else []
|
||
if ctype == "jupiter_in_lagna_or_aspects_lagna_from_watery":
|
||
return success() if "Jupiter" in ctx.planets and (ctx.house_of("Jupiter") == 1 or (ctx.sign_of("Jupiter") in {"Cancer", "Scorpio", "Pisces"} and aspects("Jupiter", 1))) else []
|
||
if ctype == "lagna_watery_with_benefics_or_lagna_lord_watery":
|
||
l1 = house_lord(1)
|
||
asc_watery = ctx.ascendant in {"Cancer", "Scorpio", "Pisces"}
|
||
watery_planets = {"Moon", "Venus"}
|
||
return success() if (asc_watery and any(ctx.house_of(b) == 1 for b in BENEFICS if b in ctx.planets)) or (l1 in watery_planets) else []
|
||
if ctype == "jupiter_in_lagna_mars_in_7th":
|
||
return success() if ctx.house_of("Jupiter") == 1 and ctx.house_of("Mars") == 7 else []
|
||
if ctype == "saturn_in_lagna_mars_in_5_7_9":
|
||
return success() if ctx.house_of("Saturn") == 1 and ctx.house_of("Mars") in (5, 7, 9) else []
|
||
if ctype == "saturn_in_12th_with_waning_moon":
|
||
return success() if ctx.house_of("Saturn") == 12 and ctx.is_waning_moon() else []
|
||
if ctype == "moon_mercury_in_kendra_with_planet":
|
||
return success() if ctx.house_of("Moon") == ctx.house_of("Mercury") and ctx.is_kendra(ctx.house_of("Moon")) and len(ctx.planets_in_house(ctx.house_of("Moon"))) >= 3 else []
|
||
if ctype == "four_planet_chain_all_in_kendra_trine_or_exaltation":
|
||
l1 = house_lord(1)
|
||
if not l1:
|
||
return []
|
||
chain = [l1]
|
||
current = l1
|
||
for _ in range(2):
|
||
sign_current = ctx.sign_of(current)
|
||
current = SIGN_LORDS.get(sign_current) if sign_current else None
|
||
if not current:
|
||
return []
|
||
chain.append(current)
|
||
d9_disp = ctx.navamsa_dispositor(current)
|
||
if not d9_disp:
|
||
return []
|
||
chain.append(d9_disp)
|
||
ok = all(
|
||
p in ctx.planets and (ctx.is_kendra(ctx.house_of(p)) or ctx.is_trikona(ctx.house_of(p)) or ctx.is_exalted(p))
|
||
for p in chain
|
||
)
|
||
return success() if ok else []
|
||
if ctype == "planets_in_relative_houses":
|
||
base = house(cond.get("from", "lagna")) or 1
|
||
offsets = cond.get("houses") or cond.get("offsets") or []
|
||
ps = planet_list(ALL_PLANETS)
|
||
targets = {rel_house(base, int(o) % 12) for o in offsets}
|
||
return success() if any(ctx.house_of(p) in targets for p in ps) else []
|
||
if ctype == "house_empty":
|
||
h = cond.get("house")
|
||
return success() if h and not ctx.planets_in_house(h) else []
|
||
if ctype == "planet_in_house_with_aspect_or_conjunction":
|
||
"""Jupiter in 2nd/5th, conjoined or aspected by Mercury AND Venus (kalaanidhi yoga)"""
|
||
planet = cond.get("planet", "")
|
||
house_offsets = cond.get("house_offsets", [])
|
||
aspecting_or_conjoining = cond.get("aspecting_or_conjoining", [])
|
||
if not planet or planet not in ctx.planets:
|
||
return []
|
||
ph = ctx.house_of(planet)
|
||
if ph is None:
|
||
return []
|
||
# Check planet is in one of the target houses (from lagna)
|
||
target_houses = set()
|
||
for off in house_offsets:
|
||
h = (1 + off) % 12
|
||
if h == 0: h = 12
|
||
target_houses.add(h)
|
||
if ph not in target_houses:
|
||
return []
|
||
# Check ALL specified planets must conjoin or aspect
|
||
for other in aspecting_or_conjoining:
|
||
if other not in ctx.planets:
|
||
return []
|
||
# Check conjunction (same house)
|
||
if ctx.house_of(other) == ph:
|
||
continue
|
||
# Check aspect (graha drishti)
|
||
if aspects(other, planet):
|
||
continue
|
||
# Neither conjoined nor aspecting → fail
|
||
return []
|
||
return success()
|
||
return []
|
||
|
||
# ------------------------------------------------------------------------
|
||
# 辅助方法
|
||
# ------------------------------------------------------------------------
|
||
def _resolve_binding(self, ref, ctx, bindings):
|
||
"""解析引用,支持变量绑定替换"""
|
||
if ref is None:
|
||
return []
|
||
if isinstance(ref, str) and ref.startswith("$"):
|
||
# 绑定变量引用,如 "$a"
|
||
val = bindings.get(ref[1:])
|
||
if val is None:
|
||
return []
|
||
return [val] if isinstance(val, str) else (val if isinstance(val, list) else [val])
|
||
return ctx.resolve(ref)
|
||
|
||
@staticmethod
|
||
def _offset_name(off: int) -> str:
|
||
names = {0: "同宫", 1: "2宫", 2: "3宫", 3: "4宫(Kendra)", 4: "5宫(Trine)",
|
||
5: "6宫", 6: "7宫(Kendra)", 7: "8宫", 8: "9宫(Trine)",
|
||
9: "10宫(Kendra)", 10: "11宫", 11: "12宫"}
|
||
return names.get(off, f"{off+1}宫")
|
||
|
||
|
||
# ============================================================================
|
||
# 便捷入口
|
||
# ============================================================================
|
||
def detect_yogas(planets: Dict[str, Dict], ascendant: str,
|
||
rules_path: Optional[str] = None,
|
||
context: Optional[Dict] = None) -> List[Dict]:
|
||
"""便捷函数:检测 Yoga"""
|
||
if rules_path is None:
|
||
# 自动查找规则文件
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
skill_dir = os.path.dirname(script_dir)
|
||
rules_path = os.path.join(skill_dir, "references", "yoga_rules.json")
|
||
engine = YogaEngine(rules_path)
|
||
return engine.detect(planets, ascendant, context=context)
|
||
|
||
|
||
def detect_yogas_from_json(chart_json: Dict, rules_path: Optional[str] = None) -> List[Dict]:
|
||
"""从 chart JSON 结构中提取 planets + ascendant 并检测"""
|
||
planets = chart_json.get("planets", {})
|
||
asc = chart_json.get("ascendant", chart_json.get("ascendant_sign", "Aries"))
|
||
return detect_yogas(planets, asc, rules_path, context=chart_json.get("context"))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# 简单自测
|
||
test_planets = {
|
||
"Sun": {"sign": "Aries", "house": 1, "degree": 10.5},
|
||
"Moon": {"sign": "Cancer", "house": 4, "degree": 15.0},
|
||
"Jupiter": {"sign": "Libra", "house": 7, "degree": 20.0},
|
||
}
|
||
print("YogaEngine loaded. Run with a rules file to test.")
|