Files
Jyotisha/scripts/functional_benefics.py
T
ClaudeandClaude Opus 5.5 4b223eb8ea 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-04 22:24:40 +08:00

175 lines
7.3 KiB
Python

#!/usr/bin/env python3
"""Functional benefic/malefic classification by ascendant."""
from __future__ import annotations
from typing import Any
SIGNS = [
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
]
SIGN_TO_INDEX = {name: idx for idx, name in enumerate(SIGNS)}
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",
}
PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
SOURCE = "strict_functional_benefic_malefic_v2"
# General ownership rules, with explicit sign-specific statements taking
# precedence. Neutral means mixed/association-dependent, not harmless.
PROFILE = "bphs_ch34_general_with_sign_exceptions_v2"
ROLE_OVERRIDES = {
# BPHS 34.20 (Aries) and 34.35 (Scorpio) name Venus (sita) a malefic
# (BUG-1570, product decision 2026-11-04; scoring-13).
"Aries": {"Mars": "neutral", "Venus": "malefic"},
"Taurus": {"Venus": "malefic", "Sun": "benefic"},
# #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"},
"Capricorn": {"Moon": "malefic"},
"Aquarius": {"Mars": "malefic", "Mercury": "neutral"},
"Pisces": {"Mercury": "malefic"},
}
# #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
lowered = value.strip().lower()
for sign in SIGNS:
if sign.lower() == lowered:
return sign
return None
def derive_functional_benefic_malefic(ascendant: Any) -> dict[str, Any]:
"""Return functional benefics, malefics and ownership roles for a Lagna."""
asc_sign = normalize_sign(ascendant)
if asc_sign is None:
return {
"status": "blocked",
"ascendant": ascendant if isinstance(ascendant, str) else None,
"functional_benefics": [],
"functional_malefics": [],
"functional_neutrals": [],
"yogakarakas": [],
"owned_houses": {},
"effect_on_confidence": "Functional layer blocked: unknown ascendant sign.",
"source": SOURCE,
}
asc_idx = SIGN_TO_INDEX[asc_sign]
owned_houses: dict[str, list[int]] = {}
for house_num in range(1, 13):
sign = SIGNS[(asc_idx + house_num - 1) % 12]
lord = SIGN_LORDS.get(sign)
if lord:
owned_houses.setdefault(lord, []).append(house_num)
trines = {1, 5, 9}
kendras = {1, 4, 7, 10}
challenging = {3, 6, 8, 11}
benefics: set[str] = set()
malefics: set[str] = set()
yogakarakas: set[str] = set()
neutrals: set[str] = set()
for planet in PLANETS:
houses = owned_houses.get(planet, [])
if not houses:
continue
owns_trine = any(house in trines for house in houses)
owns_kendra = any(house in kendras for house in houses)
owns_challenge = any(house in challenging for house in houses)
# House 1 remains both angular and trinal, but cannot be reused as
# two distinct lordships for this strict Yoga-karaka field.
if set(houses) & {4, 7, 10} and set(houses) & {5, 9}:
yogakarakas.add(planet)
benefics.add(planet)
elif owns_trine:
benefics.add(planet)
elif owns_challenge and 1 not in houses:
malefics.add(planet)
elif owns_kendra and planet in {"Jupiter", "Venus", "Mercury", "Moon"}:
neutrals.add(planet)
else:
neutrals.add(planet)
eighth_lord = next((planet for planet, houses in owned_houses.items() if 8 in houses), None)
if eighth_lord in {"Sun", "Moon"} and eighth_lord in malefics:
malefics.remove(eighth_lord)
neutrals.add(eighth_lord)
for planet, role in ROLE_OVERRIDES.get(asc_sign, {}).items():
benefics.discard(planet)
malefics.discard(planet)
neutrals.discard(planet)
{"benefic": benefics, "malefic": malefics, "neutral": neutrals}[role].add(planet)
return {
"status": "used",
"ascendant": asc_sign,
"functional_benefics": sorted(benefics),
"functional_malefics": sorted(malefics),
"functional_neutrals": sorted(neutrals - benefics - malefics),
"yogakarakas": sorted(yogakarakas),
"owned_houses": {planet: houses for planet, houses in sorted(owned_houses.items())},
"effect_on_confidence": (
"高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;"
"若功能属性与自然属性冲突,应降低置信度或显式标记冲突。"
),
"source": SOURCE,
"profile": PROFILE,
"lagna_lord": SIGN_LORDS[asc_sign],
"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"
for planet in PLANETS
},
"conditions": [
"Neutral/mixed is association-dependent, not an absence of risk.",
"Natural nature, strength, maraka roles and dasha activation remain separate.",
"Strict yogakarakas do not exhaust the text's broader yoga-producing associations.",
],
}