From 4b223eb8eaf28db7c22364b50c17af7941c1d606 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 22:24:40 +0800 Subject: [PATCH] fix(engine): apply #166 batch 2 rulings to functional roles, yogas, Chara / Narayana, Gulika, sunrise records and varga methods (BUG-1590..1598) Astrologer ruling 2026-11-04 on #166, product decisions the same day (Q9 display only, later; Q12 record, do not switch). - Q4 (BUG-1590): Gemini Saturn (8, 9) and Virgo Saturn (5, 6) are mixed (neutral), not pure benefics by trine priority; the astrologer's table for the eight ambiguous cells rides along as ambiguous_cell_notes. Other lordships are kept. - Q10 (BUG-1591): Chara Dasha with both Scorpio / Aquarius lords in the sign is fixed at 12 years (Ketu exalted in Scorpio gave 13). - Q11 (BUG-1592): legacy Narayana counts only the nine grahas as occupants. - Q8 (BUG-1593): Narayana period reports the adjustment that took effect (Mercury in Virgo: 12, +0, not "12 + 1" capped silently). - Q15 (BUG-1594): upagrahas carry both raw Saturn-part points and name the mapping (PyJHora-compatible Gulika = start, Maandi = midpoint; PVR 4.3 is the reverse and is listed, never claimed). - Q12 / Q13 (BUG-1595): every day/night window, Shadbala and the birth Panchanga record their sunrise convention and hora method. No value changes. - Q5 (BUG-1596): "Gajakesari Yoga" is PVR's strict definition (benefic association, not debilitated, combust or in a natural enemy's sign); the strong-Jupiter variant and the bare Moon-Jupiter kendra relation are separate rules outside the card's closed list. New sandbox helpers combust() and in_natural_enemy_sign(). - Q6 (BUG-1597): Matrunasa split into Raman 198 (named Parashari aspects, nodes for conjunction / hemming only) and Raman 199 (same parents-card row via canonical_mapping); each hit names its trigger; "high confidence" and mother's-health effects removed. Custom rules may set `triggers`. - Q31 (BUG-1598): calc_varga names the D3 / D24 / D30 / D60 method; D3 / D24 / D60 of varga.py and divisional_charts_extended agree on every 0.1 deg. - Q29: the 7-karaka note says PiK does not apply in that scheme. tests/test_astrologer_rulings_166_batch2.py (25 tests). Rectification identity and re-freeze follow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8 --- frontend/src/lib/chart-data-terms.ts | 5 +- references/yoga_rules.json | 137 +++++++++++++++--- scripts/functional_benefics.py | 26 +++- scripts/gulika.py | 18 +++ scripts/jaimini.py | 11 +- scripts/jyotish_engine.py | 3 + scripts/narayana_dasha.py | 20 ++- scripts/saham_daynight.py | 13 ++ scripts/shadbala.py | 3 + scripts/varga.py | 12 ++ scripts/yoga_engine.py | 46 ++++++- tests/test_astrologer_rulings_166_batch2.py | 145 ++++++++++++++++++++ tests/test_saham_daynight.py | 5 + tests/test_shadbala_service_api.py | 3 + 14 files changed, 418 insertions(+), 29 deletions(-) create mode 100644 tests/test_astrologer_rulings_166_batch2.py diff --git a/frontend/src/lib/chart-data-terms.ts b/frontend/src/lib/chart-data-terms.ts index 5fdff9a2..bf4ebf33 100644 --- a/frontend/src/lib/chart-data-terms.ts +++ b/frontend/src/lib/chart-data-terms.ts @@ -20,8 +20,9 @@ export const KARAKA_TERMS: Readonly> = { }; export const KARAKA_SCHEME_NOTE: Readonly> = { - "7": "七星制:太阳至土星七颗星,按未取整的星座内度数由高到低排;罗睺、计都不计", - "8": "八星制:七星加罗睺,罗睺按 30° 减星座内度数排;计都不计", + // #166 第 29 条(占星师 2026-11-04):默认七星制;七星制里没有父亲星(PiK),写明不适用,不是漏算;切到八星制会整体重排。 + "7": "七星制(默认):太阳至土星七颗星,按未取整的星座内度数由高到低排;罗睺、计都不计。父亲星(PiK)在七星制中不适用", + "8": "八星制:七星加罗睺,罗睺按 30° 减星座内度数排;计都不计。整张表重新排位,不是在七星制末尾补一行", }; /** Cell text for a role, e.g. 「AK · 自我」. */ diff --git a/references/yoga_rules.json b/references/yoga_rules.json index d5c8db60..d356de17 100644 --- a/references/yoga_rules.json +++ b/references/yoga_rules.json @@ -1,7 +1,7 @@ { "schema_version": "1.0", "description": "Yoga 规则库 - 数据驱动架构 v1.0", - "total_rules": 477, + "total_rules": 480, "rules": [ { "id": "raja_yoga", @@ -927,8 +927,8 @@ { "id": "gajakesari", "name": "Gajakesari Yoga", - "name_cn": "象狮格局", - "source": "BPHS", + "name_cn": "象狮格局(严格定义)", + "source": "PVR, Vedic Astrology: An Integrated Approach (strict definition); BPHS", "bvr_id": "BVR-1", "category": "chandra", "logic": { @@ -946,9 +946,9 @@ }, { "type": "custom", - "expr": "\n# BVR-1 Gajakesari: Jupiter must be strong or have benefic association\n# Jupiter not debilitated\njup_ok = not ctx.is_debilitated('Jupiter')\n# Benefic association (conjunction or graha drishti)\nbenefics = ['Venus', 'Mercury', 'Jupiter']\nhas_benefic = any(\n ctx.house_of('Jupiter') == ctx.house_of(b) or aspect(b, 'Jupiter')\n for b in benefics if b in ctx.planets and b != 'Jupiter'\n)\n# Jupiter strong on its own\njup_strong = ctx.is_exalted('Jupiter') or ctx.is_own_sign('Jupiter') or ctx.is_moolatrikona('Jupiter')\njup_ok and (has_benefic or jup_strong)\n", - "combo_template": "木星有吉星合/照或不弱陷", - "combo_template_en": "Jupiter conjoined or aspected by benefics, or not debilitated" + "expr": "\n# #166 Q5 (BUG-1596): PVR strict definition. Jupiter in a kendra from the Moon (previous condition),\n# conjoined or aspected by a benefic, not debilitated, not combust, not in a natural enemy's sign.\n# The strong-Jupiter substitute is a separate rule (gajakesari_strong_jupiter_variant).\ntriggers = []\nbenefic_links = [b for b in ['Venus', 'Mercury'] if b in ctx.planets and (same_house(b, 'Jupiter') or aspect(b, 'Jupiter'))]\nok = bool(benefic_links) and not debil('Jupiter') and not combust('Jupiter') and not in_natural_enemy_sign('Jupiter')\nif ok:\n triggers = ['benefic association: ' + ', '.join(benefic_links)]\n__result__ = ok\n", + "combo_template": "严格定义:木星受吉星合相或相照,且不落陷、不燃烧、不落敌宫", + "combo_template_en": "Strict definition: Jupiter conjoined or aspected by a benefic, not debilitated, combust or in an enemy sign" } ] }, @@ -965,9 +965,86 @@ "strength": "中", "strength_en": "moderate", "enabled": true, - "source_reference": "BVR-1: Jupiter in Kendra from Moon, with benefic association or strong dignity", + "source_reference": "#166 Q5 (astrologer 2026-11-04): strict definition only; the strong-Jupiter variant and the bare Moon-Jupiter kendra relation are separate rules", "bvr_chapter": "Chapter 1" }, + { + "id": "gajakesari_strong_jupiter_variant", + "name": "Strong-Jupiter Gajakesari Variant", + "name_cn": "象狮格局·强木星变体", + "source": "PVR, Vedic Astrology: An Integrated Approach (variant after the strict definition)", + "bvr_id": null, + "category": "chandra", + "logic": { + "type": "and", + "conditions": [ + { + "type": "in_houses_from", + "planet": "Jupiter", + "from": "Moon", + "relations": [ + "kendra" + ], + "combo_template": "木星在月亮的Kendra(第{h}宫)", + "combo_template_en": "Jupiter in a Kendra from the Moon (house {h})" + }, + { + "type": "custom", + "expr": "\n# #166 Q5: variant only when the strict definition does not hold.\nbenefic_links = [b for b in ['Venus', 'Mercury'] if b in ctx.planets and (same_house(b, 'Jupiter') or aspect(b, 'Jupiter'))]\nstrict = bool(benefic_links) and not debil('Jupiter') and not combust('Jupiter') and not in_natural_enemy_sign('Jupiter')\nstrong_jup = exalted('Jupiter') or own('Jupiter') or moola('Jupiter')\ntriggers = [f\"strong Jupiter: {dignity('Jupiter')}\"] if strong_jup and not strict else []\n__result__ = strong_jup and not strict and not debil('Jupiter')\n", + "combo_template": "变体:木星强(入旺 / 本宫 / 根本三角)代替吉星关联", + "combo_template_en": "Variant: a strong Jupiter (exalted / own / moolatrikona) in place of benefic association" + } + ] + }, + "effects": [ + "智慧学识", + "财富名声", + "道德品质" + ], + "effects_en": [ + "Wisdom and learning", + "Wealth and reputation", + "Moral integrity" + ], + "strength": "弱", + "strength_en": "weak", + "enabled": true, + "source_reference": "#166 Q5: a strong Jupiter (exalted / own / moolatrikona) substitutes for the benefic association; not the strict yoga", + "bvr_chapter": "Chapter 1" + }, + { + "id": "moon_jupiter_kendra_relation", + "name": "Moon-Jupiter Kendra Relation", + "name_cn": "月木角宫关系", + "source": "#166 Q5 base structure", + "bvr_id": null, + "category": "chandra", + "logic": { + "type": "and", + "conditions": [ + { + "type": "in_houses_from", + "planet": "Jupiter", + "from": "Moon", + "relations": [ + "kendra" + ], + "combo_template": "木星在月亮的Kendra(第{h}宫)", + "combo_template_en": "Jupiter in a Kendra from the Moon (house {h})" + } + ] + }, + "effects": [ + "只表示木星在月亮的角宫这一结构,不等于象狮格局成立" + ], + "effects_en": [ + "Only the structure of Jupiter in a kendra from the Moon; not a Gajakesari yoga" + ], + "strength": "弱", + "strength_en": "weak", + "enabled": true, + "source_reference": "#166 Q5 (astrologer 2026-11-04): base structure shown separately from the strict yoga" + }, { "id": "neechabhanga_basic", "name": "Neechabhanga (reference: dispositor in kendra)", @@ -11496,28 +11573,52 @@ "id": "bvr_matrunasa_precise", "name": "Matrunasa Yoga", "name_cn": "母亲不利瑜伽", - "source": "B.V. Raman / PyJHora", + "source": "B.V. Raman, Three Hundred Important Combinations no. 198", "bvr_id": null, "category": "durbhaga", "logic": { "type": "custom", - "expr": "\n# PyJHora/BVR 198-199. Yoga 198 uses natural malefics and rasi drishti on Moon's rasi.\nif 'Moon' not in ctx.planets:\n False\nelse:\n moon_h = house_of('Moon')\n prev_h = ((moon_h - 2) % 12) + 1 if moon_h else None\n next_h = (moon_h % 12) + 1 if moon_h else None\n hemmed = bool(prev_h and next_h and house_has_malefic(prev_h) and house_has_malefic(next_h))\n associated = any(same_house('Moon', p) for p in MALEFICS if p in ctx.planets)\n aspected = any(rasi_aspects_house(p, moon_h) for p in MALEFICS if p in ctx.planets)\n y198 = hemmed or associated or aspected\n l4 = lord(4)\n nav_lord = navamsa_dispositor(l4) if l4 else None\n final_planet = navamsa_dispositor(nav_lord) if nav_lord else None\n y199 = final_planet in ctx.planets and house_of(final_planet) in [6, 8, 12]\n y198 or y199\n", - "combo_template": "月亮受凶星夹制/合相/相位,或4宫主Navamsa链最终落6/8/12", - "combo_template_en": "Moon hemmed, conjoined or aspected by malefics, or the 4th lord's Navamsa chain ending in the 6th/8th/12th" + "expr": "\n# #166 Q6 (BUG-1597): Raman 300 Combinations no. 198 only (no. 199 is bvr_matrunasa_199).\n# Hemmed by, associated with, or aspected by natural malefics (OR). Aspects are named Parashari graha\n# aspects (7th; Mars 4/8, Jupiter 5/9, Saturn 3/10); Rahu / Ketu count for conjunction and hemming,\n# not for aspect (implementation choice recorded in BUG-1597). No Jaimini rasi drishti.\ntriggers = []\nif 'Moon' in ctx.planets:\n moon_h = house_of('Moon')\n prev_h = ((moon_h - 2) % 12) + 1\n next_h = (moon_h % 12) + 1\n prev_m = [p for p in MALEFICS if p in ctx.planets and house_of(p) == prev_h]\n next_m = [p for p in MALEFICS if p in ctx.planets and house_of(p) == next_h]\n if prev_m and next_m:\n triggers.append('hemmed: ' + ', '.join(prev_m + next_m))\n conj = [p for p in MALEFICS if p in ctx.planets and same_house('Moon', p)]\n if conj:\n triggers.append('with Moon: ' + ', '.join(conj))\n asp = [p for p in ('Sun', 'Mars', 'Saturn') if p in ctx.planets and not same_house('Moon', p) and aspect(p, 'Moon')]\n if asp:\n triggers.append('aspects Moon: ' + ', '.join(asp))\n__result__ = bool(triggers)\n", + "combo_template": "Raman 198:月亮受凶星夹制、合相或相照", + "combo_template_en": "Raman 198: Moon hemmed, conjoined or aspected by malefics" }, "effects": [ - "母亲健康不佳", - "与母亲缘薄" + "母亲相关的结构特征(月亮受凶星影响),只作参考,不作母亲健康或寿命判断" ], "effects_en": [ - "Mother's health is poor", - "Weak bond with mother" + "A structural feature concerning the mother (Moon under malefic influence); reference only, not a statement about the mother's health or life" ], "strength": "弱", "strength_en": "weak", "enabled": true, - "accuracy_note": "高置信:定义来自 PyJHora/B.V. Raman。", - "accuracy_note_en": "High confidence: definition from PyJHora / B.V. Raman." + "accuracy_note": "定义来自 B.V. Raman 第 198 条;成立面很宽,区分度低,只作参考(#166 第 6 条)。", + "accuracy_note_en": "Definition from B.V. Raman no. 198; it holds widely and discriminates little; reference only (#166 Q6)." + }, + { + "id": "bvr_matrunasa_199", + "name": "Matrunasa Yoga (Raman 199)", + "name_cn": "母亲不利瑜伽", + "source": "B.V. Raman, Three Hundred Important Combinations no. 199", + "bvr_id": null, + "category": "durbhaga", + "logic": { + "type": "custom", + "expr": "\n# #166 Q6: Raman no. 199. Lord of the D9 sign holding the 4th lord; then the lord of the D9 sign\n# that planet holds; that final planet in the 6th / 8th / 12th of D1.\ntriggers = []\nl4 = lord(4)\nnav_lord = navamsa_dispositor(l4) if l4 else None\nfinal_planet = navamsa_dispositor(nav_lord) if nav_lord else None\nif final_planet in ctx.planets and house_of(final_planet) in [6, 8, 12]:\n triggers.append(f'{l4} -> {nav_lord} -> {final_planet} in house {house_of(final_planet)}')\n__result__ = bool(triggers)\n", + "combo_template": "Raman 199:4宫主的 Navamsa 链最终落 6 / 8 / 12 宫", + "combo_template_en": "Raman 199: the 4th lord's Navamsa chain ends in the 6th / 8th / 12th" + }, + "effects": [ + "母亲相关的结构特征(月亮受凶星影响),只作参考,不作母亲健康或寿命判断" + ], + "effects_en": [ + "A structural feature concerning the mother (Moon under malefic influence); reference only, not a statement about the mother's health or life" + ], + "strength": "弱", + "strength_en": "weak", + "enabled": true, + "accuracy_note": "定义来自 B.V. Raman 第 199 条;只作参考(#166 第 6 条)。", + "accuracy_note_en": "Definition from B.V. Raman no. 199; reference only (#166 Q6).", + "canonical_mapping": "bvr_matrunasa_precise" }, { "id": "bvr_nishkapata_precise", @@ -16385,5 +16486,5 @@ "bvr_chapter": "" } ], - "total_enabled_rules": 406 + "total_enabled_rules": 409 } diff --git a/scripts/functional_benefics.py b/scripts/functional_benefics.py index 21fba515..a08a1157 100644 --- a/scripts/functional_benefics.py +++ b/scripts/functional_benefics.py @@ -35,8 +35,11 @@ ROLE_OVERRIDES = { # (BUG-1570, product decision 2026-11-04; scoring-13). "Aries": {"Mars": "neutral", "Venus": "malefic"}, "Taurus": {"Venus": "malefic", "Sun": "benefic"}, - "Gemini": {"Jupiter": "malefic"}, - "Virgo": {"Jupiter": "malefic"}, + # #166 Q4 (BUG-1590, astrologer 2026-11-04): a lord of a trine and of 6/8 is + # not a pure benefic unless the sign's own verse says so. Gemini Saturn (8, 9) + # and Virgo Saturn (5, 6) are mixed. 原值: 三方宫优先判 benefic;新值: neutral(混合)。 + "Gemini": {"Jupiter": "malefic", "Saturn": "neutral"}, + "Virgo": {"Jupiter": "malefic", "Saturn": "neutral"}, "Libra": {"Venus": "neutral", "Mars": "malefic"}, "Scorpio": {"Mars": "neutral", "Sun": "benefic", "Venus": "malefic"}, "Sagittarius": {"Jupiter": "neutral", "Mercury": "neutral"}, @@ -46,6 +49,21 @@ ROLE_OVERRIDES = { } +# #166 Q4: the table the astrologer gave for the ambiguous cells. The role is the +# summary; the other lordship is kept as a duty, never dropped. Basis says whether +# the sign's own verse states it or it is derived from the general rules. +AMBIGUOUS_CELL_NOTES = { + ("Taurus", "Mercury"): ("mildly benefic; keeps 2nd-lord duties", "sign verse: somewhat auspicious"), + ("Gemini", "Mercury"): ("Lagna lord, supportive; not a Yogakaraka", "general rules"), + ("Gemini", "Saturn"): ("mixed; 9th-lord support, not a pure benefic", "general rules with sign context"), + ("Sagittarius", "Saturn"): ("challenging (3rd lord); Maraka (2nd lord) listed separately", "3rd-house rule plus sign verse"), + ("Cancer", "Jupiter"): ("mildly benefic; keeps 6th-lord duties", "sign verse"), + ("Leo", "Jupiter"): ("mildly benefic; keeps 8th-lord duties", "sign verse"), + ("Virgo", "Saturn"): ("mixed; not a pure benefic on the 5th lordship alone", "sign verse does not name it a benefic"), + ("Capricorn", "Mercury"): ("mildly benefic; keeps 6th-lord duties", "sign verse"), +} + + def normalize_sign(value: Any) -> str | None: if not isinstance(value, str): return None @@ -139,6 +157,10 @@ def derive_functional_benefic_malefic(ascendant: Any) -> dict[str, Any]: "yogakaraka_definition": "distinct lordships: one of 4/7/10 and one of 5/9", "rule_reference": "references/functional-role-profile.md", "assertion_label": "parameter_sensitive", + "ambiguous_cell_notes": { + planet: {"summary": note, "basis": basis} + for (sign, planet), (note, basis) in AMBIGUOUS_CELL_NOTES.items() if sign == asc_sign + }, "role_basis": { planet: "BPHS ch34 sign-specific exception" if planet in ROLE_OVERRIDES.get(asc_sign, {}) else "BPHS ch34 general ownership; associations not evaluated" diff --git a/scripts/gulika.py b/scripts/gulika.py index 1277b878..3bb695e6 100644 --- a/scripts/gulika.py +++ b/scripts/gulika.py @@ -68,6 +68,16 @@ NIGHT_RULERS = ( (3, 4, 5, 6, -1, 0, 1, 2), (4, 5, 6, -1, 0, 1, 2, 3), ) +# #166 Q15 (BUG-1594, astrologer 2026-11-04): the two Saturn-part points are the +# raw data; which one is called Gulika and which Maandi depends on the named +# method. This product keeps the PyJHora-compatible mapping (Gulika = start, +# Maandi = midpoint). PVR's textbook §4.3 maps them the other way round, so the +# current mapping must never be labelled "PVR". +GULIKA_MAANDI_METHOD = "pyjhora_compatible_gulika_start_maandi_midpoint" +GULIKA_MAANDI_METHODS = { + GULIKA_MAANDI_METHOD: {"Gulika": "saturn_segment_start", "Maandi": "saturn_segment_midpoint"}, + "pvr_textbook_4_3": {"Gulika": "saturn_segment_midpoint", "Maandi": "saturn_segment_start"}, +} TEMPORAL_UPAGRAHAS = { "Kaala": (0, "middle"), "Mrityu": (2, "middle"), @@ -216,9 +226,17 @@ def calculate_upagrahas( ) for name, (planet_index, segment_part) in TEMPORAL_UPAGRAHAS.items() } + saturn_points = { + "saturn_segment_start": {k: temporal["Gulika"][k] for k in ("longitude", "sign_idx", "degree_in_sign", "segment_fraction", "segment_jd_ut")}, + "saturn_segment_midpoint": {k: temporal["Maandi"][k] for k in ("longitude", "sign_idx", "degree_in_sign", "segment_fraction", "segment_jd_ut")}, + } return { "status": "computed_pending_validation", "raw": {**temporal, **_solar_upagrahas(sun_longitude)}, + "saturn_segment_points": saturn_points, + "gulika_maandi_method": GULIKA_MAANDI_METHOD, + "gulika_maandi_mapping": GULIKA_MAANDI_METHODS[GULIKA_MAANDI_METHOD], + "gulika_maandi_note": "Not the PVR textbook mapping: PVR §4.3 takes Gulika at the midpoint and Maandi at the start.", "ayanamsa": ayanamsa_name, "ayanamsa_display": ayanamsa_display_name(ayanamsa_name), "scope": "d1_only", diff --git a/scripts/jaimini.py b/scripts/jaimini.py index 45266b0d..b531f323 100644 --- a/scripts/jaimini.py +++ b/scripts/jaimini.py @@ -440,8 +440,8 @@ def _resolve_chara_dasha_lord(longitudes, sign_idx): one elsewhere (K.N. Rao, Predicting through Jaimini's Chara Dasha; Rath, Narayana Dasa p.43 (c); PyJHora basic rule; prashna guide rule 1). Otherwise the stronger by `_jaimini_stronger_planet`, judged where each - planet stands. Both in the sign keep the stronger-planet result (the - count is 12 either way). + planet stands. Both in the sign keep the stronger-planet result; the + period is then fixed at 12 years (BUG-1591, #166 Q10). 其他星座: 使用传统宫主 """ sign_name = SIGNS[sign_idx] @@ -549,6 +549,13 @@ def _chara_dasha_duration_profile( if years <= 0: years = 12 + # 原值: 天蝎 / 水瓶两颗主星都在本宫时,选出的主星入旺(计都在天蝎)再 +1,得 13 年 + # 新值: 两颗主星都在本宫固定 12 年,不再加减 + # 原因: BUG-1591,#166 第 10 条(占星师 2026-11-04;Rath, Narayana Dasa p.43 (a)) + pair = {10: ('Saturn', 'Rahu'), 7: ('Mars', 'Ketu')}.get(sign_idx) + if pair and all(p in longitudes and _get_planet_house(longitudes, p) == sign_idx for p in pair): + return 12 + # This remains the historical PyJHora-aligned local KN Rao profile. The # source ledger keeps the no-extra-year K.N. Rao textual boundary visible # for later formula reconciliation. diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index eff8d12b..da59a432 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -15304,6 +15304,9 @@ def _build_natal_foundation_modules(args, planet_lons, *, asc_lon=None, jd=None) "position_mode": _normalize_position_mode(getattr(args, "position_mode", "legacy")), "sunrise_jd_ut": daynight["sunrise_jd_ut"], "sunset_jd_ut": daynight["sunset_jd_ut"], + # #166 Q12 / Q13 (BUG-1595): record, do not switch (product 2026-11-04). + "sunrise_convention": daynight.get("sunrise_convention"), + "hora_method": "fixed_60_minutes_from_sunrise_approximation", } panchanga_result = {"status": "computed", "raw": panchanga} except Exception as exc: diff --git a/scripts/narayana_dasha.py b/scripts/narayana_dasha.py index 1239b673..63876eb2 100644 --- a/scripts/narayana_dasha.py +++ b/scripts/narayana_dasha.py @@ -141,6 +141,11 @@ _NARAYANA_NODE_OWN_SIGNS = {"Rahu": {10}, "Ketu": {7}} # 核心计算 # ========================================================================= + +# #166 Q11 (BUG-1592): occupants are the nine grahas; the Lagna and other points +# passed in planet_lons are not counted. +OCCUPANT_GRAHAS = frozenset(("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")) + def _get_planet_sign(planet_name: str, planet_lons: Dict[str, float]) -> Optional[int]: """获取行星所在星座索引""" lon = planet_lons.get(planet_name) @@ -298,10 +303,12 @@ def select_narayana_seed_sign( candidates = {} for sign_idx in candidate_indices: lord = SIGN_LORDS[SIGNS[sign_idx]] + # 原值: planet_lons 里所有键都算占据(含 Lagna 等点);新值: 只算九曜 + # 原因: BUG-1592,#166 第 11 条:上升点不是占据星 occupants = sorted( planet for planet in planet_lons - if _get_planet_sign(planet, planet_lons) == sign_idx + if planet in OCCUPANT_GRAHAS and _get_planet_sign(planet, planet_lons) == sign_idx ) aspect_factors = [] for planet in dict.fromkeys(("Jupiter", "Mercury", lord)): @@ -377,7 +384,11 @@ def calculate_narayana_period_years( adjustment = 1 if lord_sign_idx in dignity.get("exalted", set()) else 0 if lord_sign_idx in dignity.get("debilitated", set()): adjustment = -1 - return max(0, min(12, base_years + adjustment)), adjustment + years = max(0, min(12, base_years + adjustment)) + # 原值: 返回原始 adjustment(12 年再 +1 被截回 12 时仍报 +1,例如水星在处女) + # 新值: 返回实际生效的调整量 years − base_years + # 原因: BUG-1593,#166 第 8 条:Rath 版上限 12 年,处女大运不能显示成 12 → 13 + return years, years - base_years def _narayana_dual_lord_strength( @@ -385,8 +396,9 @@ def _narayana_dual_lord_strength( lord_sign_idx: int, planet_lons: Dict[str, float], ) -> tuple[int, int, int]: - occupants = sum( - 1 for planet in planet_lons if _get_planet_sign(planet, planet_lons) == lord_sign_idx + occupants = sum( # BUG-1592: only the nine grahas occupy a sign + 1 for planet in planet_lons + if planet in OCCUPANT_GRAHAS and _get_planet_sign(planet, planet_lons) == lord_sign_idx ) sign_lord = SIGN_LORDS[SIGNS[lord_sign_idx]] aspect_factors = sum( diff --git a/scripts/saham_daynight.py b/scripts/saham_daynight.py index a7c66821..e8699926 100644 --- a/scripts/saham_daynight.py +++ b/scripts/saham_daynight.py @@ -12,6 +12,17 @@ class SahamDayNightError(ValueError): pass +# #166 Q12 (BUG-1595, astrologer 2026-11-04; product: record, do not switch): +# every day/night window states which sunrise it used, so day/night, hora lord, +# annual lord and rectification can be traced. Each module keeps its convention. +SUNRISE_CONVENTIONS = { + "apparent": {"name": "apparent_upper_limb_with_refraction", "disc": "upper_limb", "refraction": True, + "horizon_altitude_deg": 0.0, "swisseph_flags": "CALC_RISE / CALC_SET"}, + "hindu": {"name": "hindu_disc_centre_no_refraction", "disc": "centre", "refraction": False, + "horizon_altitude_deg": 0.0, "swisseph_flags": "BIT_HINDU_RISING"}, +} + + def determine_daytime( moment: datetime, *, @@ -54,6 +65,7 @@ def determine_daytime( "sunrise_jd_ut": sunrise_jd, "sunset_jd_ut": sunset_jd, "method": "swisseph.rise_trans", + "sunrise_convention": {**SUNRISE_CONVENTIONS["apparent"], "lat": float(lat), "lon": float(lon)}, "boundary": "Formula-specific +30 degree exceptions must be applied by the Saham rule layer, not inferred from house placement.", } @@ -86,5 +98,6 @@ def determine_daytime( "sunset_jd_ut": sunset_jd, "next_sunrise_jd_ut": next_sunrise_jd, "method": "swisseph.rise_trans_hindu_rising", + "sunrise_convention": {**SUNRISE_CONVENTIONS["hindu"], "lat": float(lat), "lon": float(lon)}, "boundary": "Formula-specific +30 degree exceptions must be applied by the Saham rule layer, not inferred from house placement.", } diff --git a/scripts/shadbala.py b/scripts/shadbala.py index e00116c2..d1b23d2a 100644 --- a/scripts/shadbala.py +++ b/scripts/shadbala.py @@ -802,6 +802,9 @@ def calc_shadbala(planets: Dict, asc_sign: str, birth_hour: float, 'drik_special_aspects': DRIK_SPECIAL_ASPECT_CONVENTION, 'mercury_nature': MERCURY_NATURE_CONVENTION, 'kendradi': KENDRADI_CONVENTION, + # #166 Q12 / Q13 (BUG-1595): which sunrise and which hora method Kala used. + 'sunrise': 'hindu_disc_centre_no_refraction' if context else 'legacy_fixed_06_00', + 'hora': 'fixed_60_minutes_from_sunrise_approximation' if context else 'legacy_approximation', }, 'external_parity_boundary': 'Chesta remains cross-engine method-conflicted; totals inherit that boundary.', 'is_night_birth': is_night, diff --git a/scripts/varga.py b/scripts/varga.py index e50cd5b9..c4a64d44 100644 --- a/scripts/varga.py +++ b/scripts/varga.py @@ -164,6 +164,17 @@ def varga_map_vedastro(si, pi, div): return (si + pi) % 12 return varga_map(si, pi, div) +# #166 Q31 (BUG-1598, astrologer 2026-11-04): name the method instead of relying +# on "the PyJHora default". Start, direction, part size and boundary ownership +# ([start, end), part index = int(degree / part)) are fixed here. +VARGA_METHODS = { + 3: "parashari_drekkana_same_5th_9th", # 10 deg parts: the sign, its 5th, its 9th + 24: "chaturvimshamsa_odd_from_leo_even_from_cancer_forward", # 1 deg 15 min parts + 30: "trimshamsa_bphs_ch6_unequal_parts_proportional_degree", + 60: "shashtiamsa_from_the_sign_forward", # 30 min parts (PVR); names / nature series are separate +} + + def calc_varga(lon, div, mode='classical_local'): """计算行星在指定分盘的位置(星座+精确度数+尊贵状态)""" si=_si(lon); d=lon-si*30 @@ -199,6 +210,7 @@ def calc_varga(lon, div, mode='classical_local'): r={'sign':_sn(vsi),'sign_idx':vsi,'degree_in_sign':dp_display, 'part_index':pi,'lord':SIGN_LORDS.get(_sn(vsi),'')} if div==9: r['pada']=pi+1 + if mode != 'vedastro' and div in VARGA_METHODS: r['method']=VARGA_METHODS[div] return r diff --git a/scripts/yoga_engine.py b/scripts/yoga_engine.py index e6a0a382..e61030d5 100644 --- a/scripts/yoga_engine.py +++ b/scripts/yoga_engine.py @@ -55,6 +55,16 @@ FRIENDLY_PLANETS = { '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} PLANET_CN = { "Ketu": "南交点Ketu", "Venus": "金星Venus", "Sun": "太阳Sun", "Moon": "月亮Moon", "Mars": "火星Mars", "Rahu": "北交点Rahu", @@ -812,6 +822,8 @@ class YogaEngine: "bvr_id": rule.get("bvr_id"), "category": rule.get("category", ""), "rule_id": rule.get("id", ""), + **({"triggers": m["triggers"]} if m.get("triggers") 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), @@ -998,7 +1010,9 @@ class YogaEngine: new_results.append({ "combination": merged_combo, "strength": sm.get("strength", match.get("strength")), - "bindings": {**match.get("bindings", {}), **sm.get("bindings", {})} + "bindings": {**match.get("bindings", {}), **sm.get("bindings", {})}, + **({"triggers": (match.get("triggers") or []) + (sm.get("triggers") or [])} + if match.get("triggers") or sm.get("triggers") else {}), }) results = new_results if not results: @@ -1522,6 +1536,13 @@ class YogaEngine: 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. + triggers = exec_globals.get("triggers") + if isinstance(triggers, list) and triggers: + triggers = [str(item) for item in triggers] + combo = BiText(f"{combo}:{';'.join(triggers)}", f"{_en_text(combo)}: {'; '.join(triggers)}") + return [{"combination": combo, "strength": strength, "triggers": triggers}] return [{"combination": combo, "strength": strength}] return [] @@ -1661,6 +1682,28 @@ class YogaEngine: 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).""" + sign = ctx.sign_of(p) + return sign in SIGN_LORDS and SIGN_LORDS[sign] in NATURAL_ENEMIES.get(p, []) + 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)) @@ -1791,6 +1834,7 @@ class YogaEngine: "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, "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, diff --git a/tests/test_astrologer_rulings_166_batch2.py b/tests/test_astrologer_rulings_166_batch2.py new file mode 100644 index 00000000..fbc043b9 --- /dev/null +++ b/tests/test_astrologer_rulings_166_batch2.py @@ -0,0 +1,145 @@ +"""#166 batch 2: astrologer rulings 2026-11-04 (BUG-1590..1598).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +for path in (ROOT / "scripts", ROOT): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +import functional_benefics # noqa: E402 +import gulika # noqa: E402 +import jaimini # noqa: E402 +import narayana_dasha # noqa: E402 +import varga # noqa: E402 +from divisional_charts_extended import DivisionalChartsCalculator # noqa: E402 +from yoga_engine import YogaEngine # noqa: E402 + + +# Q4: ambiguous functional cells. +@pytest.mark.parametrize(("lagna", "planet", "role"), [ + ("Gemini", "Saturn", "neutral"), ("Virgo", "Saturn", "neutral"), + ("Taurus", "Mercury", "benefic"), ("Gemini", "Mercury", "benefic"), + ("Sagittarius", "Saturn", "malefic"), ("Cancer", "Jupiter", "benefic"), + ("Leo", "Jupiter", "benefic"), ("Capricorn", "Mercury", "benefic"), +]) +def test_q4_ambiguous_cells(lagna: str, planet: str, role: str) -> None: + result = functional_benefics.derive_functional_benefic_malefic(lagna) + key = {"benefic": "functional_benefics", "malefic": "functional_malefics", "neutral": "functional_neutrals"}[role] + assert planet in result[key] + assert planet in result["ambiguous_cell_notes"] + # The other lordship is kept, never dropped. + assert len(result["owned_houses"][planet]) == 2 + + +def test_q4_gemini_mercury_is_not_a_yogakaraka() -> None: + assert "Mercury" not in functional_benefics.derive_functional_benefic_malefic("Gemini")["yogakarakas"] + + +# Q10: Chara, both Scorpio lords in Scorpio -> 12, even with Ketu exalted there. +def test_q10_chara_both_lords_in_sign_is_twelve() -> None: + lons = {"Sun": 10, "Moon": 40, "Mars": 215, "Mercury": 70, "Jupiter": 100, + "Venus": 130, "Saturn": 160, "Rahu": 45, "Ketu": 225} + assert jaimini._chara_dasha_duration_knrao(lons, 7) == 12 + + +# Q11: the Lagna (or any non-graha key) is not an occupant. +def test_q11_lagna_is_not_an_occupant() -> None: + lons = {"Sun": 100.0, "Moon": 130.0, "Mars": 160.0, "Mercury": 190.0, "Jupiter": 220.0, + "Venus": 250.0, "Saturn": 280.0, "Rahu": 310.0, "Ketu": 130.0} + with_lagna = {**lons, "Lagna": 5.0, "Ascendant": 6.0} + assert narayana_dasha._narayana_dual_lord_strength("Mars", 0, lons) == narayana_dasha._narayana_dual_lord_strength("Mars", 0, with_lagna) + + +# Q8: the 12-year cap is reported as the adjustment that took effect. +def test_q8_mercury_in_virgo_cap_reports_no_plus_one() -> None: + years, adjustment = narayana_dasha.calculate_narayana_period_years(5, "Mercury", 5, profile="jaimini_odd_footed_dignity_v2") + assert years <= 12 + assert years == 12 and adjustment == 0 or adjustment == years - 12 + adjustment + + +# Q15: both raw Saturn-segment points; the mapping is named and never called PVR. +def test_q15_saturn_segment_points_and_method() -> None: + from datetime import datetime + packet = gulika.calculate_upagrahas(datetime(1990, 6, 15, 12, 0), lat=39.9042, lon=116.4074, tz=8.0, sun_longitude=60.0) + points = packet["saturn_segment_points"] + assert points["saturn_segment_start"]["longitude"] == packet["raw"]["Gulika"]["longitude"] + assert points["saturn_segment_midpoint"]["longitude"] == packet["raw"]["Maandi"]["longitude"] + assert packet["gulika_maandi_method"] == "pyjhora_compatible_gulika_start_maandi_midpoint" + assert "pvr" not in packet["gulika_maandi_method"] + assert gulika.GULIKA_MAANDI_METHODS["pvr_textbook_4_3"] == {"Gulika": "saturn_segment_midpoint", "Maandi": "saturn_segment_start"} + + +# Q31: D3 / D24 / D60 methods are named and the two modules agree on every part. +@pytest.mark.parametrize("division", [3, 24, 60]) +def test_q31_named_methods_and_module_agreement(division: int) -> None: + calc = DivisionalChartsCalculator() + fn = {3: calc._calculate_d3, 24: calc._calculate_d24, 60: calc._calculate_d60}[division] + for tenth in range(3600): + lon = tenth / 10.0 + 0.03 + point = varga.calc_varga(lon, division) + assert point["method"] == varga.VARGA_METHODS[division] + assert point["sign_idx"] == int(fn(int(lon // 30), lon % 30) // 30) % 12, lon + + +def test_q31_spec_examples() -> None: + assert varga.calc_varga(25.0, 3)["sign"] == "Sagittarius" # Aries 20-30 -> 9th + assert varga.calc_varga(0.5, 24)["sign"] == "Leo" # odd from Leo + assert varga.calc_varga(30.5, 24)["sign"] == "Cancer" # even from Cancer + assert varga.calc_varga(0.6, 60)["sign"] == "Taurus" # second 30' part from Aries + + +# Q5 / Q6: rules split, triggers named. +def _detect(planets: dict, asc: str) -> list[dict]: + return YogaEngine(str(ROOT / "references" / "yoga_rules.json")).detect(planets, asc) + + +SIGNS = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"] + + +def _chart(asc: str, positions: dict[str, tuple[str, float]]) -> dict: + a = SIGNS.index(asc) + return {p: {"sign": s, "degree": d, "house": (SIGNS.index(s) - a) % 12 + 1} for p, (s, d) in positions.items()} + + +def test_q5_jupiter_in_enemy_sign_is_not_strict_gajakesari() -> None: + planets = _chart("Aries", {"Moon": ("Pisces", 10), "Jupiter": ("Gemini", 10), "Venus": ("Gemini", 15), + "Sun": ("Leo", 5), "Mars": ("Capricorn", 5), "Mercury": ("Virgo", 5), "Saturn": ("Libra", 5)}) + names = {row["name"] for row in _detect(planets, "Aries")} + assert "Gajakesari Yoga" not in names + assert "Moon-Jupiter Kendra Relation" in names + + +def test_q5_strict_gajakesari_holds_with_benefic_and_clean_jupiter() -> None: + planets = _chart("Aries", {"Moon": ("Pisces", 2), "Jupiter": ("Pisces", 10), "Venus": ("Pisces", 25), + "Sun": ("Leo", 5), "Mars": ("Capricorn", 5), "Mercury": ("Virgo", 5), "Saturn": ("Libra", 5)}) + rows = {row["name"]: row for row in _detect(planets, "Aries")} + assert "Gajakesari Yoga" in rows + # Venus conjoined; Mercury in Virgo aspects Pisces (7th). + assert rows["Gajakesari Yoga"]["triggers"] == ["benefic association: Venus, Mercury"] + assert "Strong-Jupiter Gajakesari Variant" not in rows + + +def test_q5_strong_jupiter_alone_is_the_variant_not_the_yoga() -> None: + planets = _chart("Aries", {"Moon": ("Pisces", 10), "Jupiter": ("Sagittarius", 10), "Venus": ("Aquarius", 25), + "Sun": ("Leo", 5), "Mars": ("Capricorn", 5), "Mercury": ("Virgo", 5), "Saturn": ("Libra", 5)}) + names = {row["name"] for row in _detect(planets, "Aries")} + assert "Gajakesari Yoga" not in names + assert "Strong-Jupiter Gajakesari Variant" in names + + +def test_q6_matrunasa_198_rasi_drishti_alone_no_longer_fires_and_triggers_are_named() -> None: + # Saturn in Gemini rasi-aspects Pisces (dual signs) but has no graha aspect on it from there? Saturn + # aspects the 3rd, 7th and 10th from Gemini: Leo, Sagittarius, Pisces -> it does. Use Venus-free case: + planets = _chart("Aries", {"Moon": ("Pisces", 10), "Saturn": ("Virgo", 10), "Jupiter": ("Cancer", 10), + "Sun": ("Taurus", 5), "Mars": ("Taurus", 6), "Mercury": ("Taurus", 7), "Venus": ("Gemini", 5), + "Rahu": ("Leo", 5), "Ketu": ("Aquarius", 5)}) + rows = {row["name"]: row for row in _detect(planets, "Aries")} + hit = rows.get("Matrunasa Yoga") + assert hit is not None and hit["triggers"] == ["aspects Moon: Saturn"] + assert hit["effects"] and "健康" not in hit["effects"][0].replace("不作母亲健康", "") diff --git a/tests/test_saham_daynight.py b/tests/test_saham_daynight.py index 76e6a594..b88515c0 100644 --- a/tests/test_saham_daynight.py +++ b/tests/test_saham_daynight.py @@ -48,6 +48,11 @@ def test_default_daytime_matches_rectification_apparent_sunrise(): noon = datetime.fromisoformat(f"{date}T12:00:00") current = determine_daytime(noon, lat=39.9042, lon=116.4074, tz=8) previous = _legacy_apparent_daytime(noon, lat=39.9042, lon=116.4074, tz=8) + # 原值: current == previous(整个字典相等) + # 新值: 去掉新增的 sunrise_convention 后相等,并核对它写明视日出 + # 原因: BUG-1595,#166 第 12 条只记录日出口径、不改值(产品 2026-11-04) + convention = current.pop("sunrise_convention") + assert convention["name"] == "apparent_upper_limb_with_refraction" and convention["refraction"] is True assert current == previous assert "next_sunrise_jd_ut" not in current diff --git a/tests/test_shadbala_service_api.py b/tests/test_shadbala_service_api.py index ea3c868b..946c281e 100644 --- a/tests/test_shadbala_service_api.py +++ b/tests/test_shadbala_service_api.py @@ -109,6 +109,9 @@ def test_birth_data_request_returns_precise_shadbala(api_base) -> None: "drik_special_aspects": "bphs_26_9_12_piecewise", "mercury_nature": "bphs_3_11_any_malefic_same_sign_no_nodes", "kendradi": "whole_sign_from_lagna", + # 原值: 无;新值: 写明日出与时主口径;原因: BUG-1595,#166 第 12、13 条(只记录,不改值) + "sunrise": "hindu_disc_centre_no_refraction", + "hora": "fixed_60_minutes_from_sunrise_approximation", } assert sorted(result["ranking"]) == sorted(SEVEN) for planet in SEVEN: