feat: add yoga context validation fixtures

This commit is contained in:
732642856
2026-06-05 17:16:23 +08:00
parent 4b840b5c2a
commit 93610326fe
10 changed files with 24344 additions and 146 deletions
+36
View File
@@ -1,5 +1,41 @@
# 印度占星 Skill 更新日志
## v6.0.362026-06-05)—— Yoga 验证数据结构升级与 D9/Panchanga/Upagraha 上下文接入
> **目标**:为继续提升 Yoga 逻辑准确率补齐验证数据层,不再把所有 B.V. Raman / PyJHora 条件硬塞进 D1-only 近似,优先让标准测试集携带 D9/Navamsa、tithi/waning Moon、Gulika/Maandi 等上下文。
### 验证数据 schema 2.0
- 升级 `references/standard_test_charts.json``schema_version: "2.0"`,每张名人星盘除 PyJHora D1/Rasi Yoga 结果外,新增 `context`
- 新增上下文字段:
- `context.d1`:D1 上升、上升度数、九行星星座/宫位/度数。
- `context.d9`D9/Navamsa 上升、上升度数、九行星星座/宫位/度数。
- `context.panchanga`tithi、paksha、`is_waning_moon`、原始 tithi 输出。
- `context.upagraha`Gulika、Maandi 的 D1 星座/宫位/度数。
- 新增 `scripts/build_planet_positions_60.py`,从标准测试集的 context 重建 Skill-side `references/planet_positions_60.json`,保证 Skill 与 PyJHora 使用同一套 fixture 来源。
- 修正 `_compute_one_chart.py` 输出星座名时使用英文 `SIGNS` 常量,避免 PyJHora `utils.RAASI_LIST` 中的星座符号污染 JSON;同时修正 D9 上升计算为 `d1_asc_sign * 30 + d1_asc_degree` 后再分入 Navamsa。
### Yoga 引擎上下文接入
- `YogaContext` 新增可选 `context` 参数,并提供 D9/Panchanga/Upagraha 查询接口:`d9_house_of()``d9_sign_of()``d9_lord_of_house()``navamsa_dispositor()``tithi()``is_waning_moon()``upagraha_house()``upagraha_sign()`
- `YogaEngine.detect()``detect_yogas()``detect_yogas_from_json()` 支持透传扩展 context。
- `validate_logic_v2.py` 现在读取 `chart["context"]` 并传给 Yoga 引擎,后续 D9/tithi/Gulika 规则修复可以在同一验证框架里直接评估。
- custom Yoga 沙箱新增上下文 helper`d9_house_of``d9_sign_of``d9_lord_of_house``navamsa_dispositor``tithi``is_waning_moon``upagraha_house``gulika_house``maandi_house` 等。
- 首批语义条件从 D1 近似升级为 context-aware`navamsa_lagna_in_dry_planet_sign``lagna_lord_and_navamsa_lord_both_in_watery_signs``saturn_in_12th_with_waning_moon``sun_and_mandi_in_house`、若干 Navamsa dispositor 条件与 Gulika 相关条件。
### 当前验证结果
- `scripts/validate_logic_v2.py`Precision **83.84%**Recall **69.64%**F1 **76.08%**
- 本轮重点是把验证框架升级到可承载 D9/Panchanga/Upagraha 规则,指标暂与 v6.0.35 持平;这说明上下文接入没有制造新回归,后续可以开始逐条修依赖扩展上下文的 Top FN/FP。
- `scripts/run_quality_gate.py --skip-yoga-logic`:通过;完整 pytest **35 passed**BPHS/Ashtakavarga 不变量 **18/18** 通过,golden case 通过。
### 后续优化点
- 下一轮可直接针对 context-aware 队列修复:`mathibhramana``matrunasa``nishkapata``bahu_puthra``vanchana_chora_bheethi` 等。
- 可继续减少 D1-only 近似,把 PyJHora/B.V. Raman 中明确依赖 Navamsa、tithi、Gulika/Maandi 的条件逐条迁移到扩展上下文。
---
## v6.0.352026-06-05)—— Yoga 精度二次提升与 Top FP 收窄
> **目标**:在 v6.0.34 已大幅提升 F1 后,继续按验证报告 Top FP/FN 队列修正高影响规则,优先降低过宽误报,同时保持质量门禁通过。
+4 -4
View File
@@ -1,6 +1,6 @@
---
name: jyotish-vedic-astrology
version: 6.0.35
version: 6.0.36
description: 印度占星(Jyotish)专业解盘与推运系统。核心能力:PDF星盘输入→严谨解盘→精确推运应期输出。触发词:印度占星、吠陀占星、Jyotish、解盘、推运、星盘分析、Dasha、Transit、Nakshatra、Yoga、出生时间矫正、PDF星盘、读取PDF、分析PDF星盘、现代解读、误判纠错、Varga分盘、综合分析、过境分析、合盘、婚姻匹配、年运盘、Prashna、Argala、Jaimini、Shadbala、Ashtakavarga、HTML报告、深度解盘。
---
@@ -10,7 +10,7 @@ description: 印度占星(Jyotish)专业解盘与推运系统。核心能力
> **严格路由**`references/strict-workflow-router.md`(⭐涉及事业/婚恋/财务/应期/技法验证时必须优先读取)
> **覆盖矩阵**`references/technique-capability-matrix.md`(⭐判断技法 covered/partial/missing 时必须参考)
> **机器注册表**`references/technique_registry.json` + `scripts/audit_capabilities.py`(⭐用于自动审计与CI门禁)
> **版本**v6.0.35-yoga-precision-pass | **详细变更**`CHANGELOG.md`
> **版本**v6.0.36-yoga-context-fixtures | **详细变更**`CHANGELOG.md`
---
@@ -301,9 +301,9 @@ $PYTHON $SCRIPT <子命令> [参数]
---
**版本**v6.0.35-yoga-precision-pass
**版本**v6.0.36-yoga-context-fixtures
**创建日期**2026-04-20
**最后更新**2026-06-05v6.0.35 继续对齐 PyJHora/B.V. Raman 高影响 Yoga 条件,重点收窄 Top FP 并修复 Kahala 映射;当前 473 条 JSON 规则、379 条启用规则,D1/Rasi 逻辑验证 F1=76.08%,报告以 `references/validation_logic_report.json` 为准)
**最后更新**2026-06-05v6.0.36 将标准 Yoga 验证数据升级为 schema 2.0,加入 D1/D9/Panchanga/Gulika/Maandi 扩展上下文,并让 YogaEngine/validate_logic_v2 接收 context;当前 473 条 JSON 规则、379 条启用规则,D1/Rasi 逻辑验证 F1=76.08%,报告以 `references/validation_logic_report.json` 为准)
---
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+86 -6
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""用PyJhora计算单张星盘的Yoga列表 - 被build_standard_test_charts.py调用"""
import sys, json, os, datetime, io
import sys, json, datetime, io
# 抑制PyJhora的所有输出(stdout + stderr + logging
class SuppressAll:
@@ -14,9 +14,15 @@ class SuppressAll:
sys.stdout = self._stdout
sys.stderr = self._stderr
SIGNS = [
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
]
try:
with SuppressAll():
from jhora.horoscope.chart import yoga as yoga_mod
from jhora.panchanga import drik
from jhora.panchanga.drik import Place
import jhora.utils as utils
PYJHORA_OK = True
@@ -34,8 +40,43 @@ def tz_to_float(tz_str):
minutes = int(parts[1]) if len(parts) > 1 else 0
return sign * (hours + minutes / 60.0)
def _planet_dict_from_pyjhora_positions(positions, asc_sign):
"""Convert PyJHora planet positions to the skill validation schema."""
names = {
0: "Sun",
1: "Moon",
2: "Mars",
3: "Mercury",
4: "Jupiter",
5: "Venus",
6: "Saturn",
7: "Rahu",
8: "Ketu",
}
out = {}
for pid, (sign_idx, degree) in positions:
if pid not in names:
continue
out[names[pid]] = {
"sign": SIGNS[sign_idx],
"house": ((sign_idx - asc_sign) % 12) + 1,
"degree": degree,
}
return out
def _upagraha_payload(name, longitude_pair, asc_sign):
sign_idx, degree = longitude_pair
return {
"name": name,
"sign": SIGNS[sign_idx],
"house": ((sign_idx - asc_sign) % 12) + 1,
"degree": degree,
}
def compute_yogas(chart):
"""计算给定星盘的所有Yoga"""
"""计算给定星盘的所有Yoga,并输出 D1/D9/tithi/Gulika 验证上下文。"""
if not PYJHORA_OK:
return {"error": f"PyJhora import failed: {IMPORT_ERR}"}
try:
@@ -43,21 +84,60 @@ def compute_yogas(chart):
# 构造Place对象(timezone是浮点数小时)
tz = tz_to_float(chart["tz"])
place = Place(chart["city"], chart["lat"], chart["lon"], tz)
# 构造datetime并转JD
date_str = chart["date"] + " " + chart["time"]
if date_str.count(':') == 1:
date_str += ":00"
dt = datetime.datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
jd = utils.gregorian_to_jd(dt)
dob = drik.Date(dt.year, dt.month, dt.day)
tob = (dt.hour, dt.minute, dt.second)
# 计算 D1/Rasi Yoga。v2 逻辑正确性验证必须与 Skill 的 D1 Yoga 引擎逐层对齐;
# 不能使用 get_yoga_details_for_all_charts(),否则会把 D2/D9/D10 等分盘 Yoga 混入,
# 造成大量“Skill 漏检”的假阴性。
result = yoga_mod.get_yoga_details(jd, place, divisional_chart_factor=1)
yogas = list(result[0].keys()) if result and len(result) > 0 else []
return {"yogas": yogas, "count": len(yogas), "status": "ok"}
d1_positions = drik.dhasavarga(jd, place, 1)[:9]
d9_positions = drik.dhasavarga(jd, place, 9)[:9]
asc_info = drik.ascendant(jd, place)
d1_asc_sign, d1_asc_degree = asc_info[0], asc_info[1]
d9_asc_sign, d9_asc_degree = drik.dasavarga_from_long(d1_asc_sign * 30 + d1_asc_degree, 9)
tithi_info = drik.tithi(jd, place)
tithi_no = int(tithi_info[0]) if tithi_info else None
lunar_phase = None
if tithi_no:
lunar_phase = "waxing" if tithi_no <= 15 else "waning"
gulika = drik.gulika_longitude(dob, tob, place, divisional_chart_factor=1)
maandi = drik.maandi_longitude(dob, tob, place, divisional_chart_factor=1)
context = {
"schema_version": "2.0",
"jd": jd,
"d1": {
"ascendant": SIGNS[d1_asc_sign],
"ascendant_degree": d1_asc_degree,
"planets": _planet_dict_from_pyjhora_positions(d1_positions, d1_asc_sign),
},
"d9": {
"ascendant": SIGNS[d9_asc_sign],
"ascendant_degree": d9_asc_degree,
"planets": _planet_dict_from_pyjhora_positions(d9_positions, d9_asc_sign),
},
"panchanga": {
"tithi": tithi_no,
"paksha": lunar_phase,
"is_waning_moon": lunar_phase == "waning",
"raw_tithi": tithi_info,
},
"upagraha": {
"gulika": _upagraha_payload("Gulika", gulika, d1_asc_sign),
"maandi": _upagraha_payload("Maandi", maandi, d1_asc_sign),
},
}
return {"yogas": yogas, "count": len(yogas), "status": "ok", "context": context}
except Exception as e:
return {"error": str(e)}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Build Skill-side planet position fixtures from standard_test_charts context."""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STANDARD = ROOT / "references" / "standard_test_charts.json"
OUTPUT = ROOT / "references" / "planet_positions_60.json"
def main() -> int:
data = json.loads(STANDARD.read_text(encoding="utf-8"))
charts = []
for chart in data.get("charts", []):
context = chart.get("context", {})
d1 = context.get("d1", {})
if not d1.get("planets") or not d1.get("ascendant"):
raise ValueError(f"Missing D1 context for {chart.get('name')}")
charts.append(
{
"name": chart["name"],
"planets": d1["planets"],
"ascendant": d1["ascendant"],
"context": context,
}
)
output = {
"schema_version": "2.0",
"description": "Skill-side Yoga validation positions with D1/D9/Panchanga/Upagraha context",
"charts": charts,
}
OUTPUT.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"Saved {len(charts)} charts to {OUTPUT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+7 -1
View File
@@ -91,12 +91,18 @@ if __name__ == "__main__":
print(f"Standard Test Charts Builder - {len(CELEBRITY_CHARTS)} charts")
outpath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "references", "standard_test_charts.json")
os.makedirs(os.path.dirname(outpath), exist_ok=True)
output = {"schema_version": "1.0", "description": "Standard test charts for yoga validation", "charts": []}
output = {
"schema_version": "2.0",
"description": "Standard test charts for yoga validation with D1/D9/Panchanga/Upagraha context",
"charts": [],
}
for chart in CELEBRITY_CHARTS:
print(f" Computing {chart['name']}...", flush=True)
yogas = compute_pyjhora_yogas(chart)
entry = dict(chart)
entry["expected_yogas"] = yogas.get("yogas", [])
if "context" in yogas:
entry["context"] = yogas["context"]
entry["pyjhora_raw"] = yogas
output["charts"].append(entry)
with open(outpath, "w") as f:
+3
View File
@@ -21,6 +21,9 @@ COMPILE_TARGETS = [
ROOT / "scripts" / "yoga_engine.py",
ROOT / "scripts" / "audit_capabilities.py",
ROOT / "scripts" / "validate_bphs_invariants.py",
ROOT / "scripts" / "_compute_one_chart.py",
ROOT / "scripts" / "build_standard_test_charts.py",
ROOT / "scripts" / "build_planet_positions_60.py",
ROOT / "tests" / "run_golden_cases.py",
]
+2 -1
View File
@@ -279,9 +279,10 @@ def main():
name = chart['name']
planets = chart['planets']
ascendant = chart['ascendant']
context = chart.get('context')
# Skill 引擎检测
skill_results = engine.detect(planets, ascendant)
skill_results = engine.detect(planets, ascendant, context=context)
skill_yoga_ids = set(r['rule_id'] for r in skill_results)
skill_comp = skill_yoga_ids & comparable_rule_ids # 只保留可对比的
total_skill_comp += len(skill_comp)
+136 -11
View File
@@ -67,17 +67,30 @@ def _get_dignity_level(planet: str, sign: str) -> str:
class YogaContext:
"""Yoga 检测上下文。封装行星数据并提供各类查询方法。"""
def __init__(self, planets: Dict[str, Dict], ascendant: str):
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.panchanga = self.context.get("panchanga", {}) if isinstance(self.context, dict) else {}
self.upagraha = self.context.get("upagraha", {}) if isinstance(self.context, dict) else {}
# 预计算宫主星
# 预计算 D1 宫主星
self._house_lords: Dict[int, str] = {}
for h in range(1, 13):
sign = SIGNS[(self.asc_idx + h - 1) % 12]
self._house_lords[h] = SIGN_LORDS[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] = SIGN_LORDS[sign]
# --- 基础查询 ---
def house_of(self, planet: str) -> Optional[int]:
return self.planets.get(planet, {}).get("house")
@@ -94,6 +107,34 @@ class YogaContext:
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 SIGN_LORDS.get(sign) if sign else None
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)
@@ -241,12 +282,12 @@ class YogaEngine:
self.schema_version = data.get("schema_version", "1.0")
self.rules = [r for r in data.get("rules", []) if r.get("enabled", True)]
def detect(self, planets: Dict[str, Dict], ascendant: str) -> List[Dict]:
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)
ctx = YogaContext(planets, ascendant, context=context)
results = []
seen_dedup = set()
@@ -947,6 +988,19 @@ class YogaEngine:
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)
@@ -1008,6 +1062,12 @@ class YogaEngine:
"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,
"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,
"exal": exal, "lord_of_house": lord_of_house, "sign": sign,
"check_amala_from": check_amala_from,
"Benefics": BENEFICS, "Malefics": MALEFICS,
@@ -1187,6 +1247,39 @@ class YogaEngine:
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")
@@ -1233,7 +1326,13 @@ class YogaEngine:
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)
return success() if any(ctx.house_of(p) == 1 for p in MALEFICS if p in ctx.planets) or (l1 and any(related(l1, p) for p in ["Rahu", "Saturn", "Ketu"] if p in ctx.planets)) else []
gh = ctx.upagraha_house("gulika")
gulika_in_trine = gh in (1, 5, 9)
kendra_trine_lords = {house_lord(h) for h in (1, 4, 5, 7, 9, 10)}
gulika_with_kendra_trine_lord = gh is not None and any(
lord and lord in ctx.planets and ctx.house_of(lord) == gh for lord in kendra_trine_lords
)
return success() if any(ctx.house_of(p) == 1 for p in MALEFICS if p in ctx.planets) or gulika_in_trine or gulika_with_kendra_trine_lord or (l1 and any(related(l1, p) for p in ["Rahu", "Saturn", "Ketu"] if p in ctx.planets)) 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":
@@ -1265,10 +1364,14 @@ class YogaEngine:
dry_signs = {"Aries", "Gemini", "Leo", "Virgo", "Sagittarius", "Capricorn"}
return success() if l1 and ctx.sign_of(l1) in dry_signs else []
if ctype == "navamsa_lagna_in_dry_planet_sign":
return [] # 需要D9上下文,当前D1 Yoga验证中不强行近似
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)
return success() if l1 and ctx.sign_of(l1) in {"Cancer", "Scorpio", "Pisces"} else []
d9_lagna_lord = SIGN_LORDS.get(ctx.d9.get("ascendant"))
watery = {"Cancer", "Scorpio", "Pisces"}
return success() if l1 and d9_lagna_lord and ctx.sign_of(l1) in watery and ctx.sign_of(d9_lagna_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":
@@ -1280,9 +1383,30 @@ class YogaEngine:
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.house_of("Moon") == 12 else []
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 []
@@ -1321,7 +1445,8 @@ class YogaEngine:
# 便捷入口
# ============================================================================
def detect_yogas(planets: Dict[str, Dict], ascendant: str,
rules_path: Optional[str] = None) -> List[Dict]:
rules_path: Optional[str] = None,
context: Optional[Dict] = None) -> List[Dict]:
"""便捷函数:检测 Yoga"""
if rules_path is None:
# 自动查找规则文件
@@ -1329,14 +1454,14 @@ def detect_yogas(planets: Dict[str, Dict], ascendant: str,
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)
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)
return detect_yogas(planets, asc, rules_path, context=chart_json.get("context"))
if __name__ == "__main__":