Another session shipped a different BUG-1613 (loading curtain) to staging. Mapping in order: 1613->1622, 1614->1623, 1615->1624, 1616->1625, 1617->1626, in BUG_HISTORY headings and bodies, CHANGELOG, ledger, research notes, code comments, rule text, test notes and the holdout contract text. Text only. shadbala.py and the two replay scripts are in the frozen identity, so an ERR-110 re-freeze follows in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2313 lines
111 KiB
Python
2313 lines
111 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 functools
|
||
import json
|
||
import os
|
||
from typing import Dict, List, Any, Optional
|
||
|
||
try: # scripts/ on sys.path
|
||
from main_yoga_detectors import main_detector_rows
|
||
except ImportError: # package import (scripts.yoga_engine)
|
||
from scripts.main_yoga_detectors import main_detector_rows
|
||
|
||
# ============================================================================
|
||
# 基础常量(自包含,避免循环导入)
|
||
# ============================================================================
|
||
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'],
|
||
}
|
||
# #166 Q5 (BUG-1596): natural (naisargika) enemies, BPHS 3.55; used for the
|
||
# strict Gajakesari "not in an enemy sign" check (PVR textbook definition).
|
||
NATURAL_ENEMIES = {
|
||
'Sun': ['Venus', 'Saturn'], 'Moon': [], 'Mars': ['Mercury'],
|
||
'Mercury': ['Moon'], 'Jupiter': ['Mercury', 'Venus'],
|
||
'Venus': ['Sun', 'Moon'], 'Saturn': ['Sun', 'Moon', 'Mars'],
|
||
}
|
||
# Combustion orbs (direct motion), same values as jyotish_engine.COMBUSTION_ORBS
|
||
# direct column; used only when the chart row carries no `combust` flag.
|
||
COMBUSTION_ORBS_DIRECT = {'Moon': 12.0, 'Mars': 17.0, 'Mercury': 14.0, 'Jupiter': 11.0, 'Venus': 10.0, 'Saturn': 15.0}
|
||
# Short Chinese planet names for bilingual rule triggers (#166 Q5 / Q6).
|
||
PLANET_ZH = {'Sun': '太阳', 'Moon': '月亮', 'Mars': '火星', 'Mercury': '水星', 'Jupiter': '木星',
|
||
'Venus': '金星', 'Saturn': '土星', 'Rahu': '罗睺', 'Ketu': '计都'}
|
||
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)}
|
||
# Jaimini co-lord comparison only (_stronger_co_lord); not a traditional own sign (BUG-1211).
|
||
RAHU_KETU_OWNED_SIGN_INDEX = {'Rahu': 10, 'Ketu': 7}
|
||
# Planets that form the solar / lunar neighbour yogas (BUG-1187, BUG-1206).
|
||
FIVE_TRUE_PLANETS = ('Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn')
|
||
# 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.
|
||
def badhaka_house(ascendant_sign: str) -> Optional[int]:
|
||
"""Badhaka 宫的唯一定义:变动 11、固定 9、双体 7;上升未知返回 None。"""
|
||
if ascendant_sign in MOVABLE_SIGNS:
|
||
return 11
|
||
if ascendant_sign in FIXED_SIGNS:
|
||
return 9
|
||
if ascendant_sign in DUAL_SIGNS:
|
||
return 7
|
||
return None
|
||
|
||
|
||
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],
|
||
}
|
||
|
||
|
||
# ── English twin for combination text (TASK-report-english-edition-20260929) ──
|
||
# The Chinese combination strings stay exactly as before; each one also carries
|
||
# an English rendering so the reader's English edition never has to translate
|
||
# Chinese back. `BiText` compares, hashes and formats as its Chinese text.
|
||
class BiText(str):
|
||
"""A Chinese string that also carries its English twin in ``.en``."""
|
||
|
||
en: str
|
||
|
||
def __new__(cls, zh, en=None):
|
||
obj = super().__new__(cls, zh)
|
||
obj.en = str(zh) if en is None else str(en)
|
||
return obj
|
||
|
||
|
||
def _en_text(value) -> str:
|
||
return value.en if isinstance(value, BiText) else str(value)
|
||
|
||
|
||
def _planet(p) -> BiText:
|
||
return BiText(PLANET_CN.get(p, p), p)
|
||
|
||
|
||
def _planets(ps) -> BiText:
|
||
ps = list(ps)
|
||
return BiText("、".join(PLANET_CN.get(p, p) for p in ps), ", ".join(str(p) for p in ps))
|
||
|
||
|
||
def _bijoin(sep: str, parts) -> BiText:
|
||
kept = [part for part in parts if part]
|
||
return BiText(sep.join(str(part) for part in kept), sep.join(_en_text(part) for part in kept))
|
||
|
||
|
||
DEFAULT_COMBO_EN = {
|
||
"{a}与{b}同在第{h}宫": "{a} and {b} together in house {h}",
|
||
"{p}在第{h}宫": "{p} in house {h}",
|
||
"{p}在第{h}宫(从{fp}的{rel})": "{p} in house {h} ({rel} from {fp})",
|
||
"{p}{dignity}在{sign}": "{p} {dignity} in {sign}",
|
||
"{count}颗行星满足条件": "{count} planets meet the condition",
|
||
"行星分布在{count}个宫位": "Planets spread over {count} houses",
|
||
"第{target}宫被夹击": "House {target} hemmed in",
|
||
"所有指定行星均落入{houses}": "All listed planets fall in houses {houses}",
|
||
"所有指定行星均落入候选宫位集合{houses}": "All listed planets fall in the house set {houses}",
|
||
"指定行星只占据{houses}": "Listed planets occupy only houses {houses}",
|
||
"指定行星只占据候选宫位集合{houses}": "Listed planets occupy only the house set {houses}",
|
||
"第{house}宫有{planets}": "House {house} holds {planets}",
|
||
"{planets}在{fp}的第{rel}宫": "{planets} in house {rel} from {fp}",
|
||
"{count}个目标宫位有指定行星": "{count} target houses hold listed planets",
|
||
"{a}与{b}在{sign}内相距{gap:.2f}°": "{a} and {b} within {gap:.2f}° in {sign}",
|
||
"{la}与{lb}互换星座": "{la} and {lb} exchange signs",
|
||
"{house}宫主{lord}在第{lh}宫": "Lord of house {house}, {lord}, in house {lh}",
|
||
"{la}与{lb}同在第{h}宫": "{la} and {lb} together in house {h}",
|
||
"自定义条件满足": "Custom condition met",
|
||
}
|
||
|
||
OFFSET_NAME_EN = {0: "same house", 1: "2nd", 2: "3rd", 3: "4th (Kendra)", 4: "5th (Trine)",
|
||
5: "6th", 6: "7th (Kendra)", 7: "8th", 8: "9th (Trine)",
|
||
9: "10th (Kendra)", 10: "11th", 11: "12th"}
|
||
|
||
STRENGTH_EN = {
|
||
"强": "Strong", "中": "Moderate", "弱": "Weak", "待评估": "To be assessed", "观察": "Watch",
|
||
"部分减弱": "partly weakened",
|
||
}
|
||
|
||
|
||
# Chinese wording for the English ``note`` of semantic (BVR / PyJHora batch)
|
||
# conditions. The note stays the English twin. One note maps to one Chinese
|
||
# sentence, so the dedup key keeps the same equality classes (BUG-1418).
|
||
_SEMANTIC_NOTES_ZH_PATH = os.path.join(
|
||
os.path.dirname(os.path.abspath(__file__)), "..", "references", "yoga_semantic_notes_zh.json")
|
||
# A condition without a note used to print its type key. Keep one fixed
|
||
# sentence per type so two matches of the same type still dedupe together.
|
||
_SEMANTIC_TYPE_ZH = {
|
||
"planet_in_house": "行星落在规则指定的宫位",
|
||
"planet_in_lagna": "行星落在上升宫",
|
||
}
|
||
|
||
|
||
@functools.lru_cache(maxsize=1)
|
||
def _semantic_notes_zh() -> Dict[str, str]:
|
||
try:
|
||
with open(_SEMANTIC_NOTES_ZH_PATH, "r", encoding="utf-8") as handle:
|
||
data = json.load(handle)
|
||
except (OSError, ValueError):
|
||
return {}
|
||
return {str(key): str(value) for key, value in data.items()} if isinstance(data, dict) else {}
|
||
|
||
|
||
def _semantic_text(cond: Dict, ctype: str):
|
||
"""``cond.get("note", ctype)`` with Chinese text; the old value stays as ``.en``."""
|
||
if "note" in cond:
|
||
note = cond.get("note")
|
||
zh = _semantic_notes_zh().get(str(note)) if note else None
|
||
return BiText(zh, note) if zh else note
|
||
zh = _SEMANTIC_TYPE_ZH.get(str(ctype))
|
||
return BiText(zh, ctype) if zh else ctype
|
||
|
||
|
||
def _template_text(zh: str, en) -> BiText:
|
||
"""``BiText(zh, en)``; a rule template written only in English gets its Chinese sentence (BUG-1418)."""
|
||
if not any("\u4e00" <= char <= "\u9fff" for char in zh):
|
||
chinese = _semantic_notes_zh().get(zh)
|
||
if chinese:
|
||
return BiText(chinese, zh if en is None else en)
|
||
return BiText(zh, en)
|
||
|
||
|
||
def _combo(cond: Dict, default_zh: str, **values) -> BiText:
|
||
"""Format the rule's combination template in Chinese (unchanged) and in English."""
|
||
zh = cond.get("combo_template", default_zh).format(**values)
|
||
en_template = cond.get("combo_template_en") if "combo_template" in cond else DEFAULT_COMBO_EN.get(default_zh)
|
||
en = None
|
||
if isinstance(en_template, str):
|
||
try:
|
||
en = en_template.format(**{key: (value.en if isinstance(value, BiText) else value) for key, value in values.items()})
|
||
except (KeyError, IndexError, ValueError):
|
||
en = None
|
||
return _template_text(zh, en)
|
||
|
||
|
||
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 宫主星。BUG-1211 (ruling 甲12): the main detection path uses the
|
||
# fixed traditional lords (Scorpio -> Mars, Aquarius -> Saturn), as the lord
|
||
# chain, functional roles and the frontend do. The v6.0.39 PyJHora "stronger
|
||
# co-lord" resolver (_stronger_co_lord) is kept for Jaimini variants only and
|
||
# runs only when a caller asks for dynamic_co_lords=True.
|
||
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.
|
||
|
||
Jaimini-variant only (BUG-1211): reached only through
|
||
`_resolve_sign_lord(..., dynamic_co_lords=True)`; no main-path caller.
|
||
RAHU_KETU_OWNED_SIGN_INDEX is this comparison's internal convention, not a
|
||
traditional own sign.
|
||
"""
|
||
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 = False) -> 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]:
|
||
"""Ownership roles only; natural nature is a separate rule predicate."""
|
||
try:
|
||
from .functional_benefics import derive_functional_benefic_malefic
|
||
except ImportError:
|
||
from functional_benefics import derive_functional_benefic_malefic
|
||
return derive_functional_benefic_malefic(self.ascendant)['functional_malefics']
|
||
|
||
def functional_benefics(self) -> List[str]:
|
||
"""Use the same named ownership profile as the API and report."""
|
||
try:
|
||
from .functional_benefics import derive_functional_benefic_malefic
|
||
except ImportError:
|
||
from functional_benefics import derive_functional_benefic_malefic
|
||
benefics = derive_functional_benefic_malefic(self.ascendant)['functional_benefics']
|
||
return [p for p in benefics if p in self.planets]
|
||
|
||
# --- 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 宫:变动上升 11 宫,固定上升 9 宫,双体上升 7 宫。"""
|
||
return badhaka_house(self.ascendant) == house
|
||
|
||
# --- 相对宫位关系 ---
|
||
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
|
||
|
||
|
||
# ============================================================================
|
||
# custom 规则表达式的编译缓存(BUG-735)
|
||
# ============================================================================
|
||
# `expr` 只来自仓库内的静态规则表 references/yoga_rules.json,进程生命周期内不变,
|
||
# 改前却每个请求重新编译一遍(本机实测 386 次/次检测:182 次 eval 字符串编译 +
|
||
# 204 次 ast.parse/compile)。这里只缓存 code object。
|
||
#
|
||
# 硬约束:求值命名空间 exec_globals 依赖每次不同的盘上下文(ctx),必须每次新建;
|
||
# 绝不缓存求值结果——缓存结果会让所有人拿到同一张盘的 yoga 判定。
|
||
#
|
||
# maxsize 512:规则表去重后 192 条表达式,留出增长余量,同时给缓存一个硬上界。
|
||
_CUSTOM_EXPR_CACHE_MAXSIZE = 512
|
||
|
||
|
||
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
|
||
|
||
|
||
@functools.lru_cache(maxsize=_CUSTOM_EXPR_CACHE_MAXSIZE)
|
||
def _compiled_eval_code(expr):
|
||
"""表达式模式的 code object;不是单个 expression 时返回 None(改前由 SyntaxError 表达)。"""
|
||
try:
|
||
return compile(expr, "<string>", "eval")
|
||
except SyntaxError:
|
||
return None
|
||
|
||
|
||
@functools.lru_cache(maxsize=_CUSTOM_EXPR_CACHE_MAXSIZE)
|
||
def _compiled_exec_code(expr):
|
||
"""多语句模式的 code object:先按 AST 捕获末尾表达式,再编译。
|
||
|
||
v6.0.32: 支持多行语句(if/else/for 等)+ 末尾表达式求值模式。
|
||
不能简单 split 最后一行:多行 if/else 的最后一行经常只是 else 分支内部表达式,
|
||
直接 exec 前半段会产生缩进不完整。这里用 AST 捕获“实际执行分支”的末尾表达式。
|
||
"""
|
||
try:
|
||
tree = ast.parse(expr.strip(), mode="exec")
|
||
tree.body = _capture_tail_expr(tree.body)
|
||
ast.fix_missing_locations(tree)
|
||
return compile(tree, "<yoga_custom>", "exec")
|
||
except Exception: # noqa: BLE001 - a custom rule expression is expected to fail; that rule is skipped
|
||
return None
|
||
|
||
|
||
@functools.lru_cache(maxsize=1)
|
||
def _shadbala_mercury_rule():
|
||
"""shadbala.mercury_malefic_companions, imported on first use.
|
||
|
||
#166 (BUG-1624): the strict Gajakesari supporter check reuses the one
|
||
implementation of the Mercury-by-association rule (BPHS 3.10-11 as
|
||
labelled by shadbala.MERCURY_NATURE_CONVENTION) instead of re-deciding it.
|
||
Imported lazily because shadbala pulls in swisseph; no import cycle
|
||
(shadbala imports only varga / ayanamsa_utils).
|
||
"""
|
||
try: # scripts/ on sys.path
|
||
from shadbala import mercury_malefic_companions
|
||
except ImportError: # package import (scripts.yoga_engine)
|
||
from scripts.shadbala import mercury_malefic_companions
|
||
return mercury_malefic_companions
|
||
|
||
|
||
def _run_custom_expr(expr, exec_globals):
|
||
"""按改前语义求值 custom 表达式:先表达式模式,SyntaxError 退到多语句 exec 模式。
|
||
|
||
只有 code object 来自缓存;`exec_globals` 由调用方每次新建。
|
||
"""
|
||
result = None
|
||
code = _compiled_eval_code(expr)
|
||
if code is not None:
|
||
try:
|
||
# 同一命名空间同时作为 globals/locals,保证 list/dict comprehension 能访问 BENEFICS 等名称。
|
||
result = eval(code, exec_globals, exec_globals)
|
||
except SyntaxError:
|
||
code = None # 运行期 SyntaxError:与改前一样退到多语句分支
|
||
except Exception: # noqa: BLE001 - a custom rule expression is expected to fail; that rule is skipped
|
||
return None
|
||
if code is None:
|
||
exec_code = _compiled_exec_code(expr)
|
||
if exec_code is not None:
|
||
try:
|
||
exec(exec_code, exec_globals, exec_globals)
|
||
result = exec_globals.get("__result__")
|
||
except Exception: # noqa: BLE001 - a custom rule expression is expected to fail; that rule is skipped
|
||
return None
|
||
return result
|
||
|
||
|
||
# ============================================================================
|
||
# YogaEngine: 规则加载与检测
|
||
# ============================================================================
|
||
# Lunar / solar duplicates of the BVR precise rules (bvr_002..004, bvr_016..018),
|
||
# retired so a chart is not judged twice by two definitions (sync4 T5; same
|
||
# six ids as upstream yinduzhanxing 2dd6062a RETIRED_RULE_IDS; upstream's other
|
||
# four retirements are outside this task's scope and not taken).
|
||
RETIRED_RULE_IDS = frozenset({
|
||
'voshi_yoga', 'veshi_yoga', 'ubhayachari_yoga', 'anapha_yoga',
|
||
'sunapha_yoga', 'durudhura_yoga',
|
||
})
|
||
|
||
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)
|
||
and r.get("id") not in RETIRED_RULE_IDS
|
||
and not r.get("reference_only")]
|
||
# `reference_only` rules stay in the registry with their source and
|
||
# logic but are never evaluated: the yoga they name has one main
|
||
# detector (scripts/main_yoga_detectors.py; astrologer rulings
|
||
# 2026-10-03), whose rows `detect` appends below.
|
||
self.reference_only_rule_ids = sorted(
|
||
r.get("id") for r in data.get("rules", []) if r.get("reference_only"))
|
||
|
||
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:
|
||
combination = m.get("combination", "")
|
||
strength = m.get("strength", rule.get("strength", "中"))
|
||
entry = {
|
||
"name": rule["name"],
|
||
"name_cn": rule["name_cn"],
|
||
"combination": str(combination),
|
||
"effects": rule.get("effects", []),
|
||
"strength": strength,
|
||
"source": rule.get("source", ""),
|
||
"bvr_id": rule.get("bvr_id"),
|
||
"category": rule.get("category", ""),
|
||
"rule_id": rule.get("id", ""),
|
||
**({"triggers": m["triggers"]} if m.get("triggers") else {}),
|
||
**({"triggers_zh": m["triggers_zh"]} if m.get("triggers_zh") else {}),
|
||
**({"canonical_mapping": rule["canonical_mapping"]} if rule.get("canonical_mapping") else {}),
|
||
# English twins for the reader's English edition; the Chinese
|
||
# fields above are unchanged.
|
||
"combination_en": _en_text(combination),
|
||
"effects_en": list(rule.get("effects_en") or rule.get("effects", [])),
|
||
"strength_en": rule.get("strength_en") if strength == rule.get("strength") and rule.get("strength_en") else STRENGTH_EN.get(str(strength), str(strength)),
|
||
}
|
||
# Budha-Aditya is a conjunction witness, but a combust
|
||
# Mercury must not be presented as an unqualified full yoga
|
||
# (upstream yinduzhanxing 994638f0; sync5 U7).
|
||
if rule.get('id') == 'budhaditya_yoga':
|
||
mercury = ctx.planets.get('Mercury') or {}
|
||
if mercury.get('combust') is True:
|
||
entry['condition_status'] = 'combustion_requires_review'
|
||
entry['traditional_effects'] = entry['effects']
|
||
entry['effects'] = []
|
||
entry['strength'] = 'conditional'
|
||
entry['combustion_witness'] = {
|
||
'planet': 'Mercury',
|
||
'combust': True,
|
||
'claim_boundary': 'Do not treat Budha-Aditya as an unqualified benefic result while Mercury is combust.'
|
||
}
|
||
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)
|
||
# ── 主检测器(同名 JSON 规则为 reference_only) ──
|
||
results += main_detector_rows(ctx.planets, ctx.ascendant)
|
||
|
||
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 可表达的。
|
||
|
||
只计火、水、木、金、土五星(BPHS 第 37 章第 7–10 颂、第 38 章第 1 颂;
|
||
Raman 300 Combinations 第 2–5 条):太阳、月亮与罗睺、计都不形成这些格局。
|
||
BUG-1187 把同名 JSON 规则改成只计五星,但漏了这一段算法版,罗计或太阳
|
||
单独在月亮邻宫时仍出 Anapha / Sunapha / Durudhura,与 Kemadruma 同盘
|
||
矛盾(BUG-1206)。Kemadruma 本身由 main_yoga_detectors 出。
|
||
"""
|
||
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 in FIVE_TRUE_PLANETS]
|
||
p_in_h12 = [n for n in planets_in_house.get(h12, []) if n in FIVE_TRUE_PLANETS]
|
||
if p_in_h12 and not p_in_h2:
|
||
results.append({"name": "Voshi Yoga", "name_cn": "太阳前瑜伽",
|
||
"combination": f"{'+'.join(p_in_h12)}在太阳第12宫",
|
||
"combination_en": f"{'+'.join(p_in_h12)} in the 12th from the Sun",
|
||
"effects": ["口才出众", "善于表达", "受人尊敬", "知识渊博"],
|
||
"effects_en": ["Eloquent", "Expressive", "Respected", "Learned"],
|
||
"strength_en": "Moderate",
|
||
"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宫",
|
||
"combination_en": f"{'+'.join(p_in_h2)} in the 2nd from the Sun",
|
||
"effects": ["财富充裕", "生活舒适", "性情愉快", "人缘好"],
|
||
"effects_en": ["Ample wealth", "Comfortable life", "Cheerful temperament", "Well liked"],
|
||
"strength_en": "Moderate",
|
||
"strength": "中", "category": "solar_yoga", "source": "Parashara"})
|
||
if p_in_h2 and p_in_h12:
|
||
results.append({"name": "Ubhayachari Yoga", "name_cn": "太阳双夹瑜伽",
|
||
"combination": "太阳两侧均有行星",
|
||
"combination_en": "Planets on both sides of the Sun",
|
||
"effects": ["性格坚毅", "口才与财富兼备", "受人爱戴", "社会影响力"],
|
||
"effects_en": ["Steadfast character", "Both eloquence and wealth", "Beloved by others", "Social influence"],
|
||
"strength_en": "Strong",
|
||
"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 in FIVE_TRUE_PLANETS]
|
||
p_in_h12 = [n for n in planets_in_house.get(h12, []) if n in FIVE_TRUE_PLANETS]
|
||
if p_in_h12 and not p_in_h2:
|
||
results.append({"name": "Anapha Yoga", "name_cn": "月后瑜伽",
|
||
"combination": f"{'+'.join(p_in_h12)}在月亮第12宫",
|
||
"combination_en": f"{'+'.join(p_in_h12)} in the 12th from the Moon",
|
||
"effects": ["体格健壮", "名声良好", "品德高尚", "行业领袖"],
|
||
"effects_en": ["Robust physique", "Good reputation", "Noble character", "Leader in one's field"],
|
||
"strength_en": "Moderate",
|
||
"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宫",
|
||
"combination_en": f"{'+'.join(p_in_h2)} in the 2nd from the Moon",
|
||
"effects": ["自力更生", "财富充裕", "受人尊敬", "生活富足"],
|
||
"effects_en": ["Self-made", "Ample wealth", "Respected", "Prosperous life"],
|
||
"strength_en": "Moderate",
|
||
"strength": "中", "category": "lunar_yoga", "source": "Parashara"})
|
||
if p_in_h2 and p_in_h12:
|
||
results.append({"name": "Durudhura Yoga", "name_cn": "月双夹瑜伽",
|
||
"combination": "月亮两侧均有行星",
|
||
"combination_en": "Planets on both sides of the Moon",
|
||
"effects": ["享受丰富", "善于辞令", "性格坚定", "多元才华"],
|
||
"effects_en": ["Many comforts", "Articulate", "Firm character", "Versatile talents"],
|
||
"strength_en": "Strong",
|
||
"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 = _bijoin("; ", [combo, sm_combo])
|
||
new_results.append({
|
||
"combination": merged_combo,
|
||
"strength": sm.get("strength", match.get("strength")),
|
||
"bindings": {**match.get("bindings", {}), **sm.get("bindings", {})},
|
||
**({"triggers": (match.get("triggers") or []) + (sm.get("triggers") or []),
|
||
"triggers_zh": (match.get("triggers_zh") or []) + (sm.get("triggers_zh") or [])}
|
||
if match.get("triggers") or sm.get("triggers") else {}),
|
||
})
|
||
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:
|
||
combo = _combo(cond, "{a}与{b}同在第{h}宫", a=_planet(a), b=_planet(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:
|
||
combo = _combo(cond, "{p}在第{h}宫", p=_planet(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:
|
||
rel_name = BiText(self._offset_name(off), OFFSET_NAME_EN.get(off, f"{off + 1}th"))
|
||
combo = _combo(cond, "{p}在第{h}宫(从{fp}的{rel})", p=_planet(p), h=h1, fp=_planet(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)
|
||
combo = _combo(cond, "{p}{dignity}在{sign}", p=_planet(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:
|
||
combo = _combo(cond, "{count}颗行星满足条件", count=count, planets=_planets(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:
|
||
combo = _combo(cond, "行星分布在{count}个宫位", 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:
|
||
combo = _combo(cond, "第{target}宫被夹击", 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:
|
||
combo = _combo(cond, "所有指定行星均落入{houses}", houses="/".join(str(h) for h in sorted(houses)), planets=_planets(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:
|
||
combo = _combo(cond, "所有指定行星均落入候选宫位集合{houses}", houses="/".join(str(h) for h in sorted(houses_set)), planets=_planets(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:
|
||
combo = _combo(cond, "指定行星只占据{houses}", houses="/".join(str(h) for h in sorted(houses)), planets=_planets(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:
|
||
combo = _combo(cond, "指定行星只占据候选宫位集合{houses}", houses="/".join(str(h) for h in sorted(houses_set)), planets=_planets(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:
|
||
combo = _combo(cond, "第{house}宫有{planets}", house=house, planets=_planets(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:
|
||
combo = _combo(cond, "{planets}在{fp}的第{rel}宫",
|
||
planets=_planets(matched),
|
||
fp=_planet(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:
|
||
detail = _bijoin("; ", [BiText(f"第{h}宫:" + _planets(ps), f"house {h}: " + _planets(ps).en)
|
||
for h, ps in occupied])
|
||
combo = _combo(cond, "{count}个目标宫位有指定行星", 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:
|
||
combo = _combo(cond, "{a}与{b}在{sign}内相距{gap:.2f}°", a=_planet(a), b=_planet(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/互落)"""
|
||
# BUG-1572: inside any_pair the lords arrive as "$a" / "$b"; ctx.resolve
|
||
# knows no bindings, so raja_yoga_parivartana never fired. Only the bare
|
||
# "$x" form is bound here.
|
||
def single(ref):
|
||
if isinstance(ref, str) and ref.startswith("$"):
|
||
found = self._resolve_binding(ref, ctx, bindings)
|
||
return found[0] if found else None
|
||
return ctx.resolve_single(ref)
|
||
|
||
la = single(cond.get("lord_a"))
|
||
lb = 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:
|
||
combo = _combo(cond, "{la}与{lb}互换星座", la=_planet(la), lb=_planet(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 = _bijoin("; ", 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:
|
||
combo = _combo(cond, "{house}宫主{lord}在第{lh}宫", house=house, lord=_planet(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:
|
||
combo = _combo(cond, "{la}与{lb}同在第{h}宫", la=_planet(lord_a), lb=_planet(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 []
|
||
|
||
# BUG-735: exec_globals 每次新建(依赖当前盘 ctx),只有 code object 走进程级缓存。
|
||
exec_globals = self._build_custom_exec_globals(ctx, rule, bindings)
|
||
result = _run_custom_expr(expr, exec_globals)
|
||
|
||
if result:
|
||
combo = cond.get("combo_template", "自定义条件满足")
|
||
if isinstance(combo, str):
|
||
# Custom templates were never formatted; keep that, and pair the English text.
|
||
combo = _template_text(combo, cond.get("combo_template_en") if "combo_template" in cond else DEFAULT_COMBO_EN["自定义条件满足"])
|
||
if callable(combo):
|
||
combo = combo(ctx, bindings)
|
||
strength = cond.get("strength", rule.get("strength", "中"))
|
||
if callable(strength):
|
||
strength = strength(ctx, bindings)
|
||
# #166 Q6 (BUG-1597): a rule may set `triggers` (list of short strings)
|
||
# to name what actually fired; they are appended to the combination.
|
||
# An item is either one string (used in both editions) or a
|
||
# (Chinese, English) pair, so the Chinese report never carries English glue.
|
||
triggers = exec_globals.get("triggers")
|
||
if isinstance(triggers, list) and triggers:
|
||
pairs = [(item[0], item[1]) if isinstance(item, (list, tuple)) and len(item) == 2 else (item, item) for item in triggers]
|
||
zh_items = [str(zh) for zh, _ in pairs]
|
||
en_items = [str(en) for _, en in pairs]
|
||
combo = BiText(f"{combo}:{';'.join(zh_items)}", f"{_en_text(combo)}: {'; '.join(en_items)}")
|
||
return [{"combination": combo, "strength": strength, "triggers": en_items, "triggers_zh": zh_items}]
|
||
return [{"combination": combo, "strength": strength}]
|
||
return []
|
||
|
||
def _build_custom_exec_globals(self, ctx, rule, bindings):
|
||
"""构造 custom 表达式的求值命名空间。每次调用都新建,绑定当前这张盘的 ctx。"""
|
||
|
||
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 combust(p):
|
||
"""#166 Q5: the chart's `combust` flag, else the direct-motion orb from the Sun."""
|
||
info = ctx.planets.get(p) or {}
|
||
if isinstance(info.get('combust'), bool):
|
||
return info['combust']
|
||
if p not in COMBUSTION_ORBS_DIRECT or 'Sun' not in ctx.planets:
|
||
return False
|
||
def lon(q):
|
||
sign, deg = ctx.sign_of(q), ctx.degree_of(q)
|
||
if sign not in SIGNS or deg is None:
|
||
return None
|
||
return SIGNS.index(sign) * 30.0 + float(deg) % 30.0
|
||
a, b = lon(p), lon('Sun')
|
||
if a is None or b is None:
|
||
return False
|
||
return abs(((a - b + 180.0) % 360.0) - 180.0) <= COMBUSTION_ORBS_DIRECT[p]
|
||
|
||
def in_natural_enemy_sign(p):
|
||
"""#166 Q5: the sign lord is a natural enemy of p (BPHS 3.55).
|
||
|
||
Natural (not five-fold) enmity is a named choice, 待核 (#166
|
||
astrologer 2026-11-05: the PVR strict definition does not say which).
|
||
"""
|
||
sign = ctx.sign_of(p)
|
||
return sign in SIGN_LORDS and SIGN_LORDS[sign] in NATURAL_ENEMIES.get(p, [])
|
||
|
||
def mercury_malefic_companions():
|
||
"""BUG-1624: natural malefics sharing Mercury's sign; non-empty = Mercury is a natural malefic.
|
||
|
||
Same rule and label as Drik Bala (shadbala.MERCURY_NATURE_CONVENTION):
|
||
Sun, Mars, Saturn and the waning Moon count, Rahu / Ketu do not.
|
||
"""
|
||
return list(_shadbala_mercury_rule()(ctx.planets))
|
||
|
||
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
|
||
# set 顺序随进程哈希变化;排序后同一张盘的角宫主参与者顺序才稳定。
|
||
return sorted(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
|
||
# set 顺序随进程哈希变化;排序后同一张盘的三方主参与者顺序才稳定。
|
||
return sorted(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,
|
||
"combust": combust, "in_natural_enemy_sign": in_natural_enemy_sign,
|
||
"mercury_malefic_companions": mercury_malefic_companions,
|
||
"PLANET_ZH": PLANET_ZH,
|
||
"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,
|
||
}
|
||
return {"__builtins__": {}, **safe_globals}
|
||
|
||
def _eval_semantic_condition(self, ctype, cond, ctx, rule, bindings):
|
||
"""兼容批量抽取规则中的语义型条件。返回保守布尔匹配。"""
|
||
def success(combo=None):
|
||
text = combo or _semantic_text(cond, ctype)
|
||
return [{"combination": text, "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.")
|