diff --git a/references/yoga_rules.json b/references/yoga_rules.json index 59ecb57f..8e187375 100644 --- a/references/yoga_rules.json +++ b/references/yoga_rules.json @@ -1305,7 +1305,7 @@ "category": "chandra", "logic": { "type": "custom", - "expr": "\n# PyJHora/BVR-5: no planets other than Sun in 1st/2nd/12th from Moon,\n# and no planets other than Moon in quadrants from Lagna.\nif 'Moon' not in ctx.planets:\n False\nelse:\n moon_h = house_of('Moon')\n moon_zone = {moon_h, ((moon_h - 1 + 1) % 12) + 1, ((moon_h - 1 + 11) % 12) + 1}\n ky1 = all(p in ['Moon', 'Sun'] for p, info in ctx.planets.items() if info.get('house') in moon_zone)\n ky2 = all(p == 'Moon' for p, info in ctx.planets.items() if info.get('house') in [1, 4, 7, 10])\n ky1 and ky2\n", + "expr": "\n# PyJHora/BVR-5: none of the five true planets (Mars..Saturn) in 1st/2nd/12th from Moon;\n# the Sun and the nodes do not count (sync4 T5, BPHS / Raman 300 Combinations no. 5),\n# and no planets other than Moon in quadrants from Lagna.\nif 'Moon' not in ctx.planets:\n False\nelse:\n moon_h = house_of('Moon')\n moon_zone = {moon_h, ((moon_h - 1 + 1) % 12) + 1, ((moon_h - 1 + 11) % 12) + 1}\n ky1 = all(p in ['Moon', 'Sun', 'Rahu', 'Ketu'] for p, info in ctx.planets.items() if info.get('house') in moon_zone)\n ky2 = all(p == 'Moon' for p, info in ctx.planets.items() if info.get('house') in [1, 4, 7, 10])\n ky1 and ky2\n", "combo_template": "月亮1/2/12宫仅日月,且Lagna角宫仅月亮", "combo_template_en": "Only the Sun and Moon in the 1st/2nd/12th from the Moon, and only the Moon in the Kendras from the Lagna" }, @@ -9880,16 +9880,21 @@ "category": "surya", "logic": { "type": "has_planet_in_house_from", - "planets": "all", + "planets": { + "role": "set", + "planets": [ + "Mars", + "Mercury", + "Jupiter", + "Venus", + "Saturn" + ] + }, "from": "Sun", "houses": [ 2 ], - "exclude": [ - "Moon" - ], - "combo_template": "{planets}在太阳的第2宫", - "combo_template_en": "{planets} in the 2nd from the Sun" + "combo_template": "{planets}在太阳的第2宫" }, "effects": [ "太阳后方有行星支持", @@ -9917,16 +9922,21 @@ "category": "surya", "logic": { "type": "has_planet_in_house_from", - "planets": "all", + "planets": { + "role": "set", + "planets": [ + "Mars", + "Mercury", + "Jupiter", + "Venus", + "Saturn" + ] + }, "from": "Sun", "houses": [ 12 ], - "exclude": [ - "Moon" - ], - "combo_template": "{planets}在太阳的第12宫", - "combo_template_en": "{planets} in the 12th from the Sun" + "combo_template": "{planets}在太阳的第12宫" }, "effects": [ "太阳前方有行星铺垫", @@ -9959,29 +9969,39 @@ "conditions": [ { "type": "has_planet_in_house_from", - "planets": "all", + "planets": { + "role": "set", + "planets": [ + "Mars", + "Mercury", + "Jupiter", + "Venus", + "Saturn" + ] + }, "from": "Sun", "houses": [ 2 ], - "exclude": [ - "Moon" - ], - "combo_template": "{planets}在太阳的第2宫", - "combo_template_en": "{planets} in the 2nd from the Sun" + "combo_template": "{planets}在太阳的第2宫" }, { "type": "has_planet_in_house_from", - "planets": "all", + "planets": { + "role": "set", + "planets": [ + "Mars", + "Mercury", + "Jupiter", + "Venus", + "Saturn" + ] + }, "from": "Sun", "houses": [ 12 ], - "exclude": [ - "Moon" - ], - "combo_template": "{planets}在太阳的第12宫", - "combo_template_en": "{planets} in the 12th from the Sun" + "combo_template": "{planets}在太阳的第12宫" } ] }, @@ -10010,10 +10030,22 @@ "bvr_id": "BVR-2", "category": "chandra", "logic": { - "type": "custom", - "expr": "\n# PyJHora/BVR-2 Sunapha: target 2nd from Moon has at least one planet and Sun is absent.\nmoon_h = house_of('Moon')\nif moon_h is None:\n False\nelse:\n target = ((moon_h - 1 + 1) % 12) + 1\n ps = occupants(target)\n bool(ps) and 'Sun' not in ps\n", - "combo_template": "{planets}在月亮的第2宫", - "combo_template_en": "{planets} in the 2nd from the Moon" + "type": "has_planet_in_house_from", + "planets": { + "role": "set", + "planets": [ + "Mars", + "Mercury", + "Jupiter", + "Venus", + "Saturn" + ] + }, + "from": "Moon", + "houses": [ + 2 + ], + "combo_template": "{planets}在月亮的第2宫" }, "effects": [ "自力更生", @@ -10042,10 +10074,22 @@ "bvr_id": "BVR-3", "category": "chandra", "logic": { - "type": "custom", - "expr": "\n# PyJHora/BVR-3 Anapha: target 12th from Moon has at least one planet and Sun is absent.\nmoon_h = house_of('Moon')\nif moon_h is None:\n False\nelse:\n target = ((moon_h - 1 + 11) % 12) + 1\n ps = occupants(target)\n bool(ps) and 'Sun' not in ps\n", - "combo_template": "{planets}在月亮的第12宫", - "combo_template_en": "{planets} in the 12th from the Moon" + "type": "has_planet_in_house_from", + "planets": { + "role": "set", + "planets": [ + "Mars", + "Mercury", + "Jupiter", + "Venus", + "Saturn" + ] + }, + "from": "Moon", + "houses": [ + 12 + ], + "combo_template": "{planets}在月亮的第12宫" }, "effects": [ "心智有背景支持", @@ -10074,9 +10118,45 @@ "bvr_id": "BVR-4", "category": "chandra", "logic": { - "type": "custom", - "expr": "\n# PyJHora/BVR-4 Duradhara: both Sunapha and Anapha conditions are true.\nmoon_h = house_of('Moon')\nif moon_h is None:\n False\nelse:\n h2 = ((moon_h - 1 + 1) % 12) + 1\n h12 = ((moon_h - 1 + 11) % 12) + 1\n ps2 = occupants(h2)\n ps12 = occupants(h12)\n bool(ps2) and 'Sun' not in ps2 and bool(ps12) and 'Sun' not in ps12\n", - "combo_template": "PyJHora Chandra yoga logic" + "type": "and", + "conditions": [ + { + "type": "has_planet_in_house_from", + "planets": { + "role": "set", + "planets": [ + "Mars", + "Mercury", + "Jupiter", + "Venus", + "Saturn" + ] + }, + "from": "Moon", + "houses": [ + 2 + ], + "combo_template": "{planets}在月亮的第2宫" + }, + { + "type": "has_planet_in_house_from", + "planets": { + "role": "set", + "planets": [ + "Mars", + "Mercury", + "Jupiter", + "Venus", + "Saturn" + ] + }, + "from": "Moon", + "houses": [ + 12 + ], + "combo_template": "{planets}在月亮的第12宫" + } + ] }, "effects": [ "月亮前后均有支撑", diff --git a/scripts/yoga_engine.py b/scripts/yoga_engine.py index 11b89d9c..939e015a 100644 --- a/scripts/yoga_engine.py +++ b/scripts/yoga_engine.py @@ -694,6 +694,15 @@ def _run_custom_expr(expr, exec_globals): # ============================================================================ # 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 加载规则,对星盘执行条件匹配。""" @@ -701,7 +710,8 @@ class YogaEngine: 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)] + self.rules = [r for r in data.get("rules", []) if r.get("enabled", True) + and r.get("id") not in RETIRED_RULE_IDS] def detect(self, planets: Dict[str, Dict], ascendant: str, context: Optional[Dict] = None) -> List[Dict]: """ diff --git a/tests/test_lunar_solar_yogas_count_five_planets.py b/tests/test_lunar_solar_yogas_count_five_planets.py new file mode 100644 index 00000000..ac8b3e03 --- /dev/null +++ b/tests/test_lunar_solar_yogas_count_five_planets.py @@ -0,0 +1,48 @@ +"""sync4 T5: Sunaphaa / Anaphaa / Duradhara (Moon) and Vesi / Vosi / Ubhayachara +(Sun) count only Mars, Mercury, Jupiter, Venus, Saturn; Rahu / Ketu alone next +to the Moon or Sun do not form them, and Kemadruma ignores the nodes too. +Rule logic for the six BVR rules is upstream yinduzhanxing 2dd6062a.""" + +from __future__ import annotations + +from pathlib import Path + +from scripts.yoga_engine import RETIRED_RULE_IDS, YogaEngine + +RULES = Path(__file__).resolve().parents[1] / "references" / "yoga_rules.json" +SIGNS = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"] + + +def _chart(**signs: str) -> dict: + return {name: {"sign": sign, "house": SIGNS.index(sign) + 1, "degree": 15.0, "longitude": SIGNS.index(sign) * 30 + 15.0} + for name, sign in signs.items()} + + +def _names(planets: dict) -> set[str]: + return {row["name"] for row in YogaEngine(str(RULES)).detect(planets, "Aries")} + + +BASE = dict(Sun="Aquarius", Moon="Libra", Mars="Aquarius", Mercury="Aquarius", + Jupiter="Cancer", Venus="Pisces", Saturn="Capricorn", Rahu="Pisces") + + +def test_ketu_alone_behind_the_moon_is_not_anaphaa() -> None: + names = _names(_chart(**BASE, Ketu="Virgo")) + assert not {"Anaphaa Yoga", "Sunaphaa Yoga", "Duradhara Yoga"} & names + + +def test_saturn_behind_the_moon_is_anaphaa() -> None: + signs = {**BASE, "Saturn": "Virgo", "Ketu": "Virgo"} + assert "Anaphaa Yoga" in _names(_chart(**signs)) + + +def test_ketu_alone_after_the_sun_is_not_vesi() -> None: + signs = {**BASE, "Sun": "Leo", "Ketu": "Virgo", "Mars": "Capricorn", "Mercury": "Capricorn"} + assert "Vesi Yoga" not in _names(_chart(**signs)) + + +def test_duplicate_lunar_solar_rules_are_retired() -> None: + assert {"sunapha_yoga", "durudhura_yoga", "voshi_yoga", "veshi_yoga", "ubhayachari_yoga"} <= RETIRED_RULE_IDS + ids = {rule["id"] for rule in YogaEngine(str(RULES)).rules} + assert not ids & RETIRED_RULE_IDS diff --git a/tests/test_yoga_expr_compile_cache.py b/tests/test_yoga_expr_compile_cache.py index 813b5a86..39b3a072 100644 --- a/tests/test_yoga_expr_compile_cache.py +++ b/tests/test_yoga_expr_compile_cache.py @@ -161,7 +161,11 @@ def _globals_for(engine: YogaEngine, chart_index: int) -> dict: # --------------------------------------------------------------------------- def test_cached_compile_matches_pre_change_behaviour_for_every_rule_expression(engine) -> None: """全部 192 条去重表达式,缓存版与参照版在三张示例盘上逐条同值。""" - assert len(RULE_EXPRESSIONS) >= 190, "规则表表达式数量异常,等价证明的覆盖面不足" + # 原值: >= 190 + # 新值: >= 184 + # 原因: TASK-upstream-sync4-20261002 T5:bvr_002~004、bvr_016~018 六条从 custom 表达式改为 + # 声明式 has_planet_in_house_from(上游 2dd6062a),表达式条数相应减少;等价证明仍覆盖其余全部表达式 + assert len(RULE_EXPRESSIONS) >= 184, "规则表表达式数量异常,等价证明的覆盖面不足" mismatches = [] for chart_index in range(len(SAMPLE_CHARTS)):