diff --git a/references/yoga_rules.json b/references/yoga_rules.json index 2b99bb0b..a66874c0 100644 --- a/references/yoga_rules.json +++ b/references/yoga_rules.json @@ -1327,7 +1327,9 @@ "strength_en": "weak", "enabled": true, "source_reference": "BVR-5: No planets in 1st, 2nd, 12th from Moon", - "bvr_chapter": "Chapter 1" + "bvr_chapter": "Chapter 1", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲7, BUG-1206): Kemadruma has one main detector, scripts/yoga_expansion.py detect_kemadruma (2nd/12th from the Moon count only Mars..Saturn; no kendra cancellation, a kendra holding only Rahu/Ketu does not cancel it); emitted via scripts/main_yoga_detectors.py. A kendra-cancellation variant may only return as a separately labelled variant." }, { "id": "chandra_mangala_same", @@ -3741,7 +3743,9 @@ "strength_en": "weak", "enabled": true, "source_reference": "BVR-5: No planets in 1st, 2nd, 12th from Moon", - "bvr_chapter": "Chapter 1" + "bvr_chapter": "Chapter 1", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲7, BUG-1206): Kemadruma has one main detector, scripts/yoga_expansion.py detect_kemadruma (2nd/12th from the Moon count only Mars..Saturn; no kendra cancellation, a kendra holding only Rahu/Ketu does not cancel it); emitted via scripts/main_yoga_detectors.py. A kendra-cancellation variant may only return as a separately labelled variant." }, { "id": "daridra_yoga_6", diff --git a/scripts/divisional_yoga.py b/scripts/divisional_yoga.py index fda84278..7a7822af 100644 --- a/scripts/divisional_yoga.py +++ b/scripts/divisional_yoga.py @@ -236,15 +236,20 @@ def _detect_moon_varga_yogas(varga_planets: Dict) -> List[Dict]: moon = varga_planets['Moon'] moon_sign = moon.get('sign', '') + if moon_sign not in SIGNS: + return yogas - # Kemadruma检查 - has_neighbor = False - for pn in ['Sun', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn']: - if pn in varga_planets: - ps = varga_planets[pn] - if ps.get('house', 0) in (moon.get('house', 0) - 1, moon.get('house', 0) + 1): - has_neighbor = True - break + # Kemadruma:与 D1 主检测器同一口径(yoga_expansion.detect_kemadruma;占星师 + # 定稿 2026-10-03 甲7,BUG-1206)——月亮所在星座的前后一宫只数火水木金土,太阳、 + # 罗计不计,不启用角宫解除。旧写法按分盘里并不存在的 `house` 字段比相邻, + # 永远判「无邻星」,且把太阳算作邻星。 + moon_idx = SIGNS.index(moon_sign) + neighbours = {(moon_idx + 1) % 12, (moon_idx - 1) % 12} + has_neighbor = any( + varga_planets.get(pn, {}).get('sign') in SIGNS + and SIGNS.index(varga_planets[pn]['sign']) in neighbours + for pn in ('Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn') + ) if not has_neighbor: yogas.append({ 'name': 'Kemadruma (分盘)', diff --git a/scripts/main_yoga_detectors.py b/scripts/main_yoga_detectors.py index c022d37a..4153fae8 100644 --- a/scripts/main_yoga_detectors.py +++ b/scripts/main_yoga_detectors.py @@ -13,6 +13,10 @@ English reader edition). - Neecha Bhanga (BUG-1205, 乙14): `yogas_doshas.calc_nicha_bhanga_raj_yoga`; only the cancellation conditions that hold are listed, never called Raja Yoga. +- Kemadruma (BUG-1206, 甲7): `yoga_expansion.detect_kemadruma`; the 2nd / 12th + from the Moon count only Mars, Mercury, Jupiter, Venus, Saturn, and there is no + kendra cancellation (planets in kendras, including only Rahu / Ketu there, do + not cancel it). Inputs are D1 rows `{sign, house, lon?, retrograde?, navamsa_sign?}`. A missing `navamsa_sign` is derived from the sidereal longitude (Parashari navamsa: each @@ -25,8 +29,10 @@ from __future__ import annotations from typing import Any, Dict, List, Optional try: # scripts/ on sys.path + from yoga_expansion import detect_kemadruma from yogas_doshas import calc_nicha_bhanga_raj_yoga except ImportError: # package import (scripts.main_yoga_detectors) + from scripts.yoga_expansion import detect_kemadruma from scripts.yogas_doshas import calc_nicha_bhanga_raj_yoga SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', @@ -138,6 +144,30 @@ def neecha_bhanga_rows(planets: Dict[str, Any]) -> List[Dict[str, Any]]: return rows +def kemadruma_rows(planets: Dict[str, Any]) -> List[Dict[str, Any]]: + """Kemadruma from `yoga_expansion.detect_kemadruma` (no kendra cancellation).""" + result = detect_kemadruma(planets or {}) + if not result.get('present'): + return [] + moon_sign = (planets.get('Moon') or {}).get('sign') + return [{ + 'name': 'Kemadruma Yoga', + 'name_cn': '空劫瑜伽', + 'category': 'durbhaga', + 'planets': ['Moon'], + 'combination': f'月亮在{_sign_zh(moon_sign)},第2、12宫无火水木金土(太阳与罗计不计;不启用角宫解除)', + 'combination_en': (f'Moon in {moon_sign}; none of Mars to Saturn in the 2nd or 12th from it ' + '(the Sun and the nodes are not counted; no kendra cancellation)'), + 'effects': ['人生艰辛', '需自力更生', '精神挑战'], + 'effects_en': ['Hard life', 'Must rely on oneself', 'Mental and spiritual challenges'], + 'strength': '弱', + 'strength_en': 'weak', + 'source': 'BPHS', + 'rule_id': 'main_detector:kemadruma', + 'detector': 'yoga_expansion.detect_kemadruma', + }] + + def main_detector_rows(planets: Dict[str, Any], ascendant: Optional[str] = None) -> List[Dict[str, Any]]: """Every main-detector row for one D1 chart, in a fixed order.""" - return neecha_bhanga_rows(planets) + return kemadruma_rows(planets) + neecha_bhanga_rows(planets) diff --git a/scripts/yoga_engine.py b/scripts/yoga_engine.py index 5566b9b3..eb6dfd23 100644 --- a/scripts/yoga_engine.py +++ b/scripts/yoga_engine.py @@ -68,6 +68,8 @@ DUAL_SIGNS = ['Gemini', 'Virgo', 'Sagittarius', 'Pisces'] ALL_PLANETS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'] PLANET_INDEX = {planet: idx for idx, planet in enumerate(ALL_PLANETS)} RAHU_KETU_OWNED_SIGN_INDEX = {'Rahu': 10, 'Ketu': 7} +# 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]: @@ -797,6 +799,12 @@ class YogaEngine: 来源:从 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 @@ -818,8 +826,8 @@ class YogaEngine: if sun_house is not None: h2 = ((sun_house - 1 + 1) % 12) + 1 # 太阳第2宫 h12 = ((sun_house - 1 + 11) % 12) + 1 # 太阳第12宫 - p_in_h2 = [n for n in planets_in_house.get(h2, []) if n != "Sun"] - p_in_h12 = [n for n in planets_in_house.get(h12, []) if n != "Sun"] + 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宫", @@ -849,8 +857,8 @@ class YogaEngine: if moon_house is not None: h2 = ((moon_house - 1 + 1) % 12) + 1 # 月亮第2宫 h12 = ((moon_house - 1 + 11) % 12) + 1 # 月亮第12宫 - p_in_h2 = [n for n in planets_in_house.get(h2, []) if n != "Moon"] - p_in_h12 = [n for n in planets_in_house.get(h12, []) if n != "Moon"] + 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宫", diff --git a/tests/test_kemadruma_main_detector.py b/tests/test_kemadruma_main_detector.py new file mode 100644 index 00000000..1809d797 --- /dev/null +++ b/tests/test_kemadruma_main_detector.py @@ -0,0 +1,133 @@ +"""Kemadruma has one main detector (BUG-1206; astrologer rulings 2026-10-03 甲7). + +`yoga_expansion.detect_kemadruma`: the 2nd / 12th from the Moon count only Mars, +Mercury, Jupiter, Venus, Saturn; no kendra cancellation, so a kendra holding +only Rahu / Ketu (or any planet) does not cancel it. The two data-driven rules +are `reference_only`; the rule engine appends the main row; the algorithmic +solar / lunar neighbour yogas count the same five planets (the part BUG-1187 +missed), so one chart never carries Kemadruma together with Sunapha / Anapha / +Durudhura. `divisional_yoga` uses the same rule on vargas. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +from scripts.divisional_yoga import _detect_moon_varga_yogas +from scripts.jyotish_engine import compute_chart_data +from scripts.main_yoga_detectors import kemadruma_rows +from scripts.yoga_engine import YogaEngine +from scripts.yoga_expansion import detect_kemadruma + +ROOT = Path(__file__).resolve().parents[1] +RULES = ROOT / "references" / "yoga_rules.json" +SIGNS = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"] +LUNAR_NEIGHBOUR = re.compile(r"sunaph|anaph|durudh|duradh", re.IGNORECASE) + + +def _chart(asc: str = "Aries", **signs: str) -> dict: + asc_idx = SIGNS.index(asc) + return {name: {"sign": sign, "house": (SIGNS.index(sign) - asc_idx) % 12 + 1, "degree": 15.0, + "lon": SIGNS.index(sign) * 30 + 15.0} + for name, sign in signs.items()} + + +def _detect(planets: dict, asc: str = "Aries") -> list[dict]: + return YogaEngine(str(RULES)).detect(planets, asc) + + +# Moon in Libra (7th from Aries); five planets kept away from Virgo / Scorpio. +ISOLATED = dict(Sun="Aquarius", Moon="Libra", Mars="Aquarius", Mercury="Aquarius", + Jupiter="Cancer", Venus="Pisces", Saturn="Capricorn", Rahu="Pisces", Ketu="Virgo") + + +def test_data_driven_rules_are_reference_only() -> None: + rules = {rule["id"]: rule for rule in json.loads(RULES.read_text(encoding="utf-8"))["rules"]} + for rule_id in ("kemadruma_yoga", "kemadruma_variant"): + assert rules[rule_id]["reference_only"] is True + assert "BUG-1206" in rules[rule_id]["reference_only_reason"] + assert not {"kemadruma_yoga", "kemadruma_variant"} & {rule["id"] for rule in YogaEngine(str(RULES)).rules} + + +def test_kendra_with_only_nodes_does_not_cancel() -> None: + # Lagna kendras from Aries: Aries, Cancer, Libra, Capricorn. Only the nodes + # sit in kendras besides the Moon and Jupiter / Saturn (Cancer, Capricorn). + planets = _chart(**{**ISOLATED, "Rahu": "Aries", "Ketu": "Libra"}) + assert detect_kemadruma(planets)["present"] is True + names = [row["name"] for row in _detect(planets)] + assert names.count("Kemadruma Yoga") == 1 + + +def test_planets_in_kendras_do_not_cancel() -> None: + planets = _chart(**ISOLATED) + assert any(row["house"] in (1, 4, 7, 10) for name, row in planets.items() if name not in ("Moon", "Rahu", "Ketu")) + rows = [row for row in _detect(planets) if row["name"] == "Kemadruma Yoga"] + assert rows == kemadruma_rows(planets) + assert rows[0]["rule_id"] == "main_detector:kemadruma" + + +def test_ketu_next_to_the_moon_forms_no_lunar_neighbour_yoga() -> None: + names = {row["name"] for row in _detect(_chart(**ISOLATED))} + assert "Kemadruma Yoga" in names + assert not {name for name in names if LUNAR_NEIGHBOUR.search(name)} + + +def test_a_true_planet_next_to_the_moon_removes_kemadruma() -> None: + planets = _chart(**{**ISOLATED, "Saturn": "Virgo"}) + names = {row["name"] for row in _detect(planets)} + assert "Kemadruma Yoga" not in names + assert {"Anapha Yoga", "Anaphaa Yoga"} <= names + + +def test_sun_or_nodes_next_to_the_sun_form_no_solar_neighbour_yoga() -> None: + planets = _chart(**{**ISOLATED, "Sun": "Leo", "Moon": "Virgo", "Ketu": "Virgo", "Rahu": "Pisces", "Jupiter": "Sagittarius"}) + names = {row["name"] for row in _detect(planets)} + assert not {"Veshi Yoga", "Voshi Yoga", "Ubhayachari Yoga"} & names + + +def test_divisional_kemadruma_uses_the_same_rule() -> None: + def varga(**signs: str) -> dict: + return {name: {"sign": sign, "sign_idx": SIGNS.index(sign), "degree": 1.0} for name, sign in signs.items()} + + isolated = varga(Moon="Libra", Sun="Virgo", Rahu="Scorpio", Mars="Aries", Mercury="Aries", + Jupiter="Aries", Venus="Aries", Saturn="Aries") + assert [row["name"] for row in _detect_moon_varga_yogas(isolated)] == ["Kemadruma (分盘)"] + supported = varga(Moon="Libra", Sun="Aries", Mars="Scorpio", Mercury="Aries", Jupiter="Aries", + Venus="Aries", Saturn="Aries") + assert _detect_moon_varga_yogas(supported) == [] + + +FIGURES = ( + ("minute_rectification_development_v1.json", "steve_jobs_1955_development"), + ("minute_rectification_holdout_v4.json", "barack_obama_1961_aa_v4_holdout"), + ("minute_rectification_holdout_v4.json", "elizabeth_taylor_1932_aa_v4_holdout"), + ("minute_rectification_holdout_v5.json", "marilyn_monroe_1926_aa_v5_holdout"), + ("minute_rectification_holdout_v5.json", "judy_garland_1922_aa_v5_holdout"), + ("minute_rectification_holdout_v5.json", "edith_piaf_1915_aa_v5_holdout"), + ("minute_rectification_holdout_v5.json", "frida_kahlo_1907_aa_v5_holdout"), + ("minute_rectification_holdout_v5.json", "george_w_bush_1946_aa_v5_holdout"), + ("minute_rectification_holdout_v5.json", "zinedine_zidane_1972_aa_v5_holdout"), +) + + +@pytest.mark.parametrize("library,case_id", FIGURES) +def test_public_figures_never_carry_kemadruma_with_a_lunar_neighbour_yoga(library: str, case_id: str) -> None: + cases = json.loads((ROOT / "references/real_case_calibration" / library).read_text(encoding="utf-8"))["cases"] + birth = next(case for case in cases if case["case_id"] == case_id)["birth"] + year, month, day = (int(part) for part in birth["date"].split("-")) + hour, minute = (int(part) for part in birth["time"].split(":")) + chart, *_ = compute_chart_data(year, month, day, hour, minute, birth["latitude"], birth["longitude"], + birth["timezone_offset"], ayanamsa_name="raman") + planets = {name: {"sign": row["sign"], "house": row["house"], "degree": row["degree_in_sign"], + "lon": row["degree_raw"], "retrograde": row["retrograde"]} + for name, row in chart["planets"].items()} + names = {row["name"] for row in _detect(planets, chart["ascendant"]["sign"])} + kemadruma = "Kemadruma Yoga" in names + assert kemadruma == detect_kemadruma(planets)["present"] + if kemadruma: + assert not {name for name in names if LUNAR_NEIGHBOUR.search(name)}, case_id