Files
732642856 f83db2fac1 Enhance Jyotish validation and Jaimini modules
- add external validation reports and open-source comparison references

- integrate Jaimini arudha/graha pada, enhanced argala, and additional synastry kutas

- update skill docs and capability matrices

- add smoke tests for open-source integrations
2026-06-10 20:50:52 +08:00

560 lines
22 KiB
Python

from .constants import ZODIAC_SIGNS, SIGN_LORDS, EXALTATION, OWN_SIGNS
KENDRA_HOUSES = {1, 4, 7, 10}
TRIKONA_HOUSES = {1, 5, 9}
DUSTHANA_HOUSES = {6, 8, 12}
BENEFICS = {"Jupiter", "Venus", "Mercury"}
MAHAPURUSHA_PLANETS = {"Mars", "Mercury", "Jupiter", "Venus", "Saturn"}
MAHAPURUSHA_NAMES = {
"Mars": "Ruchaka Yoga",
"Mercury": "Bhadra Yoga",
"Jupiter": "Hamsa Yoga",
"Venus": "Malavya Yoga",
"Saturn": "Shasha Yoga",
}
# Gandanta junctions: last nakshatra of water sign → first nakshatra of fire sign
# Water signs: Cancer(3), Scorpio(7), Pisces(11) Fire signs: Leo(4), Sagittarius(8), Aries(0)
# Gandanta zones: last 3°20' of water sign + first 3°20' of fire sign
GANDANTA_JUNCTIONS = [
(3, 4), # Cancer → Leo
(7, 8), # Scorpio → Sagittarius
(11, 0), # Pisces → Aries
]
GANDANTA_ORB = 3.3333 # 3°20' = one pada
def _house_from(base_sign_idx, planet_sign_idx):
return ((planet_sign_idx - base_sign_idx) % 12) + 1
def _sign_idx(sign_name):
return ZODIAC_SIGNS.index(sign_name)
def _lord_of_house(lagna_sign_idx, house_num):
sign_idx = (lagna_sign_idx + house_num - 1) % 12
return SIGN_LORDS[ZODIAC_SIGNS[sign_idx]]
def _is_exalted_or_own(planet_name, sign):
if planet_name in EXALTATION and EXALTATION[planet_name][0] == sign:
return True
if planet_name in OWN_SIGNS and sign in OWN_SIGNS[planet_name]:
return True
return False
def detect_yogas(planets, lagna_sign):
"""
Detect key Vedic yogas from chart data.
Parameters
----------
planets : dict
{planet_name: {"sign": str, "sign_idx": int, "house": int, "dignity": str, ...}}
lagna_sign : str
Returns
-------
list of dict: [{"name": str, "formed_by": list, "description": str}, ...]
"""
yogas = []
lagna_idx = _sign_idx(lagna_sign)
moon_data = planets.get("Moon", {})
moon_idx = moon_data.get("sign_idx", 0)
# --- Pancha Mahapurusha Yogas ---
for p in MAHAPURUSHA_PLANETS:
pd = planets.get(p)
if not pd:
continue
if pd.get("house") in KENDRA_HOUSES and _is_exalted_or_own(p, pd["sign"]):
yogas.append({
"name": MAHAPURUSHA_NAMES[p],
"formed_by": [p],
"description": f"{p} in own/exalted sign in house {pd['house']} from Lagna.",
})
# --- Gajakesari Yoga: Jupiter in kendra from Moon ---
jup = planets.get("Jupiter")
if jup and moon_data:
house_from_moon = _house_from(moon_idx, jup["sign_idx"])
if house_from_moon in KENDRA_HOUSES:
yogas.append({
"name": "Gajakesari Yoga",
"formed_by": ["Jupiter", "Moon"],
"description": f"Jupiter in house {house_from_moon} from Moon (kendra).",
})
# --- Budhaditya Yoga: Sun + Mercury in same sign ---
sun_d = planets.get("Sun")
mer_d = planets.get("Mercury")
if sun_d and mer_d and sun_d["sign"] == mer_d["sign"]:
if mer_d.get("dignity") != "debilitated" and not mer_d.get("is_combust"):
yogas.append({
"name": "Budhaditya Yoga",
"formed_by": ["Sun", "Mercury"],
"description": f"Sun and Mercury conjoined in {sun_d['sign']}.",
})
# --- Chandra-Mangal Yoga: Moon + Mars in same sign ---
mars_d = planets.get("Mars")
if moon_data and mars_d and moon_data["sign"] == mars_d["sign"]:
yogas.append({
"name": "Chandra-Mangal Yoga",
"formed_by": ["Moon", "Mars"],
"description": f"Moon and Mars conjoined in {moon_data['sign']}.",
})
# --- Kemadruma Yoga: No planet in 2nd or 12th from Moon ---
if moon_data:
sign_2nd = (moon_idx + 1) % 12
sign_12th = (moon_idx - 1) % 12
has_support = False
for p_name, pd in planets.items():
if p_name in ("Sun", "Moon", "Rahu", "Ketu"):
continue
if pd["sign_idx"] in (sign_2nd, sign_12th):
has_support = True
break
if not has_support:
yogas.append({
"name": "Kemadruma Yoga",
"formed_by": ["Moon"],
"description": "No planet (except Sun/nodes) in 2nd or 12th from Moon.",
})
# --- Adhi Yoga: Benefics in 6th, 7th, 8th from Moon ---
if moon_data:
target_houses = {6, 7, 8}
adhi_planets = []
for p_name in ("Mercury", "Jupiter", "Venus"):
pd = planets.get(p_name)
if pd:
h = _house_from(moon_idx, pd["sign_idx"])
if h in target_houses:
adhi_planets.append(p_name)
if len(adhi_planets) >= 2:
yogas.append({
"name": "Adhi Yoga",
"formed_by": adhi_planets,
"description": f"Benefics ({', '.join(adhi_planets)}) in 6/7/8 from Moon.",
})
# --- Raj Yoga: Lord of kendra + Lord of trikona conjoined ---
kendra_lords = set()
trikona_lords = set()
for h in KENDRA_HOUSES:
kendra_lords.add(_lord_of_house(lagna_idx, h))
for h in TRIKONA_HOUSES:
trikona_lords.add(_lord_of_house(lagna_idx, h))
dual_lords = kendra_lords & trikona_lords
for lord_name in dual_lords:
pd = planets.get(lord_name)
if pd and pd.get("house") in KENDRA_HOUSES | TRIKONA_HOUSES:
yogas.append({
"name": "Raj Yoga",
"formed_by": [lord_name],
"description": f"{lord_name} is lord of both kendra and trikona, placed in house {pd['house']}.",
})
pure_kendra = kendra_lords - dual_lords
pure_trikona = trikona_lords - dual_lords
for kl in pure_kendra:
for tl in pure_trikona:
kl_data = planets.get(kl)
tl_data = planets.get(tl)
if kl_data and tl_data and kl_data["sign"] == tl_data["sign"]:
yogas.append({
"name": "Raj Yoga",
"formed_by": [kl, tl],
"description": f"Kendra lord {kl} conjoined with trikona lord {tl} in {kl_data['sign']}.",
})
# --- Viparita Raj Yoga: Lord of 6/8/12 in another dusthana ---
dusthana_lords = {}
for h in DUSTHANA_HOUSES:
lord = _lord_of_house(lagna_idx, h)
dusthana_lords[h] = lord
for h, lord in dusthana_lords.items():
pd = planets.get(lord)
if pd and pd.get("house") in DUSTHANA_HOUSES and pd["house"] != h:
yogas.append({
"name": "Viparita Raj Yoga",
"formed_by": [lord],
"description": f"Lord of house {h} ({lord}) placed in house {pd['house']} (dusthana in dusthana).",
})
# --- Neecha Bhanga Raja Yoga ---
for p_name, pd in planets.items():
if pd.get("dignity") != "debilitated":
continue
sign = pd["sign"]
cancellation = False
cancel_reason = ""
dispositor = SIGN_LORDS.get(sign)
if dispositor:
disp_data = planets.get(dispositor)
if disp_data:
if disp_data.get("house") in KENDRA_HOUSES:
cancellation = True
cancel_reason = f"Dispositor {dispositor} in kendra from Lagna."
elif moon_data and _house_from(moon_idx, disp_data["sign_idx"]) in KENDRA_HOUSES:
cancellation = True
cancel_reason = f"Dispositor {dispositor} in kendra from Moon."
if not cancellation and p_name in EXALTATION:
exalt_sign = EXALTATION[p_name][0]
exalt_lord = SIGN_LORDS.get(exalt_sign)
if exalt_lord:
el_data = planets.get(exalt_lord)
if el_data:
if el_data.get("house") in KENDRA_HOUSES:
cancellation = True
cancel_reason = f"Lord of exaltation sign ({exalt_lord}) in kendra from Lagna."
elif moon_data and _house_from(moon_idx, el_data["sign_idx"]) in KENDRA_HOUSES:
cancellation = True
cancel_reason = f"Lord of exaltation sign ({exalt_lord}) in kendra from Moon."
if cancellation:
yogas.append({
"name": "Neecha Bhanga Raja Yoga",
"formed_by": [p_name],
"description": f"Debilitated {p_name} in {sign} with cancellation: {cancel_reason}",
})
# --- Parivartana Yoga: Mutual exchange of signs ---
checked_pairs = set()
for p1, d1 in planets.items():
if p1 in ("Rahu", "Ketu"):
continue
lord_of_p1_sign = SIGN_LORDS.get(d1["sign"])
if lord_of_p1_sign and lord_of_p1_sign != p1:
d2 = planets.get(lord_of_p1_sign)
if d2:
lord_of_p2_sign = SIGN_LORDS.get(d2["sign"])
if lord_of_p2_sign == p1:
pair = tuple(sorted([p1, lord_of_p1_sign]))
if pair not in checked_pairs:
checked_pairs.add(pair)
h1, h2 = d1["house"], d2["house"]
# Classify exchange type
if {h1, h2} <= DUSTHANA_HOUSES:
ptype = "Dainya"
elif {h1, h2} & DUSTHANA_HOUSES:
ptype = "Khala"
else:
ptype = "Maha"
yogas.append({
"name": f"Parivartana Yoga ({ptype})",
"formed_by": list(pair),
"description": f"{pair[0]} in {d1['sign'] if pair[0] == p1 else d2['sign']} and "
f"{pair[1]} in {d2['sign'] if pair[1] == lord_of_p1_sign else d1['sign']} — mutual sign exchange.",
})
# --- Dhana Yogas: Wealth combinations ---
lord_2 = _lord_of_house(lagna_idx, 2)
lord_5 = _lord_of_house(lagna_idx, 5)
lord_9 = _lord_of_house(lagna_idx, 9)
lord_11 = _lord_of_house(lagna_idx, 11)
good_houses = KENDRA_HOUSES | TRIKONA_HOUSES
# Lord of 2nd and 11th both in kendra/trikona
l2d = planets.get(lord_2)
l11d = planets.get(lord_11)
if l2d and l11d and l2d.get("house") in good_houses and l11d.get("house") in good_houses:
yogas.append({
"name": "Dhana Yoga",
"formed_by": [lord_2, lord_11],
"description": f"Lord of 2nd ({lord_2}) in house {l2d['house']} and lord of 11th ({lord_11}) in house {l11d['house']}.",
})
# Lord of 5th and 9th conjoined or in mutual kendra
l5d = planets.get(lord_5)
l9d = planets.get(lord_9)
if l5d and l9d:
if l5d["sign"] == l9d["sign"]:
yogas.append({
"name": "Dhana Yoga",
"formed_by": [lord_5, lord_9],
"description": f"Lord of 5th ({lord_5}) and lord of 9th ({lord_9}) conjoined in {l5d['sign']}.",
})
elif _house_from(l5d["sign_idx"], l9d["sign_idx"]) in KENDRA_HOUSES:
yogas.append({
"name": "Dhana Yoga",
"formed_by": [lord_5, lord_9],
"description": f"Lord of 5th ({lord_5}) and lord of 9th ({lord_9}) in mutual kendra.",
})
# --- Sunapha / Anapha / Durudhura Yogas (Moon-based) ---
if moon_data:
sign_2nd_m = (moon_idx + 1) % 12
sign_12th_m = (moon_idx - 1) % 12
sunapha_planets = []
anapha_planets = []
for p_name, pd in planets.items():
if p_name in ("Sun", "Moon", "Rahu", "Ketu"):
continue
if pd["sign_idx"] == sign_2nd_m:
sunapha_planets.append(p_name)
if pd["sign_idx"] == sign_12th_m:
anapha_planets.append(p_name)
if sunapha_planets and anapha_planets:
yogas.append({
"name": "Durudhura Yoga",
"formed_by": sunapha_planets + anapha_planets,
"description": f"Planets in 2nd ({', '.join(sunapha_planets)}) and 12th ({', '.join(anapha_planets)}) from Moon — wealth and fame.",
})
elif sunapha_planets:
yogas.append({
"name": "Sunapha Yoga",
"formed_by": sunapha_planets,
"description": f"{', '.join(sunapha_planets)} in 2nd from Moon — self-made wealth.",
})
elif anapha_planets:
yogas.append({
"name": "Anapha Yoga",
"formed_by": anapha_planets,
"description": f"{', '.join(anapha_planets)} in 12th from Moon — good character and comfort.",
})
# --- Amala Yoga: Natural benefic in 10th from Lagna or Moon ---
for base_name, base_idx in [("Lagna", lagna_idx), ("Moon", moon_idx)]:
for b_name in BENEFICS:
bd = planets.get(b_name)
if bd and _house_from(base_idx, bd["sign_idx"]) == 10:
yogas.append({
"name": "Amala Yoga",
"formed_by": [b_name],
"description": f"{b_name} in 10th from {base_name} — virtuous deeds and lasting fame.",
})
break # one benefic is enough per base
# --- Saraswati Yoga: Jupiter, Venus, Mercury in kendra/trikona/2nd ---
saraswati_houses = KENDRA_HOUSES | TRIKONA_HOUSES | {2}
sara_ok = []
for p_name in ("Jupiter", "Venus", "Mercury"):
pd = planets.get(p_name)
if pd and pd.get("house") in saraswati_houses:
sara_ok.append(p_name)
if len(sara_ok) == 3:
jup_d = planets.get("Jupiter")
if jup_d and (jup_d.get("dignity") in ("own_sign", "exalted", "mooltrikona") or jup_d.get("house") in KENDRA_HOUSES):
yogas.append({
"name": "Saraswati Yoga",
"formed_by": sara_ok,
"description": "Jupiter, Venus, Mercury in kendra/trikona/2nd with strong Jupiter — learning and wisdom.",
})
# --- Lakshmi Yoga: Lord of 9th in own/exalted + Venus in own/exalted kendra ---
ven_d = planets.get("Venus")
if l9d and ven_d:
lord9_strong = l9d.get("dignity") in ("own_sign", "exalted", "mooltrikona")
venus_strong = (ven_d.get("house") in KENDRA_HOUSES and
_is_exalted_or_own("Venus", ven_d["sign"]))
if lord9_strong and venus_strong:
yogas.append({
"name": "Lakshmi Yoga",
"formed_by": [lord_9, "Venus"],
"description": f"Lord of 9th ({lord_9}) in dignity and Venus in own/exalted kendra — great wealth.",
})
# --- Voshi Yoga / Veshi Yoga: Planet in 2nd/12th from Sun ---
if sun_d:
sun_idx = sun_d["sign_idx"]
sign_2nd_s = (sun_idx + 1) % 12
sign_12th_s = (sun_idx - 1) % 12
voshi_planets = [] # 12th from Sun
veshi_planets = [] # 2nd from Sun
for p_name, pd in planets.items():
if p_name in ("Sun", "Moon", "Rahu", "Ketu"):
continue
if pd["sign_idx"] == sign_2nd_s:
veshi_planets.append(p_name)
if pd["sign_idx"] == sign_12th_s:
voshi_planets.append(p_name)
if veshi_planets and voshi_planets:
yogas.append({
"name": "Ubhayachari Yoga",
"formed_by": veshi_planets + voshi_planets,
"description": f"Planets in 2nd ({', '.join(veshi_planets)}) and 12th ({', '.join(voshi_planets)}) from Sun — balanced fame.",
})
elif veshi_planets:
yogas.append({
"name": "Veshi Yoga",
"formed_by": veshi_planets,
"description": f"{', '.join(veshi_planets)} in 2nd from Sun — industrious nature.",
})
elif voshi_planets:
yogas.append({
"name": "Voshi Yoga",
"formed_by": voshi_planets,
"description": f"{', '.join(voshi_planets)} in 12th from Sun — charitable nature.",
})
return yogas
def detect_kaal_sarpa(raw_planets):
"""
Detect Kaal Sarpa Dosha: all 7 planets hemmed between Rahu-Ketu axis.
Parameters
----------
raw_planets : dict — raw planet data with 'sign_idx' for each planet
Returns
-------
dict or None — dosha details if present, None otherwise
"""
rahu_idx = raw_planets["Rahu"]["sign_idx"]
ketu_idx = raw_planets["Ketu"]["sign_idx"]
# The 7 planets (Sun through Saturn) must all be on one side of the Rahu-Ketu axis
seven = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"]
# Check if all planets fall in the arc from Rahu to Ketu (going forward)
def _in_arc(planet_idx, start_idx, end_idx):
"""Check if planet_idx is in the arc from start_idx to end_idx (exclusive of nodes)."""
if start_idx == end_idx:
return False
if start_idx < end_idx:
return start_idx < planet_idx < end_idx
else: # wraps around
return planet_idx > start_idx or planet_idx < end_idx
# Arc from Rahu to Ketu
all_rahu_to_ketu = all(_in_arc(raw_planets[p]["sign_idx"], rahu_idx, ketu_idx) for p in seven)
# Arc from Ketu to Rahu
all_ketu_to_rahu = all(_in_arc(raw_planets[p]["sign_idx"], ketu_idx, rahu_idx) for p in seven)
if all_rahu_to_ketu or all_ketu_to_rahu:
# Determine type: Ascending (Rahu leads) or Descending (Ketu leads)
if all_rahu_to_ketu:
kaal_type = "Ascending (planets move toward Ketu)"
else:
kaal_type = "Descending (planets move toward Rahu)"
return {
"present": True,
"type": kaal_type,
"rahu_sign": ZODIAC_SIGNS[rahu_idx],
"ketu_sign": ZODIAC_SIGNS[ketu_idx],
"description": "All 7 planets hemmed between Rahu-Ketu axis — karmic restriction pattern affecting life direction.",
}
# Check partial Kaal Sarpa (one planet outside — still significant)
for direction, checker in [("Rahu→Ketu", lambda p: _in_arc(raw_planets[p]["sign_idx"], rahu_idx, ketu_idx)),
("Ketu→Rahu", lambda p: _in_arc(raw_planets[p]["sign_idx"], ketu_idx, rahu_idx))]:
outside = [p for p in seven if not checker(p)]
if len(outside) == 1:
return {
"present": True,
"type": f"Partial ({outside[0]} outside)",
"rahu_sign": ZODIAC_SIGNS[rahu_idx],
"ketu_sign": ZODIAC_SIGNS[ketu_idx],
"description": f"Near-complete Kaal Sarpa — only {outside[0]} escapes the nodal axis. Karmic themes still dominant.",
}
return None
def detect_graha_yuddha(raw_planets):
"""
Detect Planetary War (Graha Yuddha): two planets within 1° of each other.
Only applies to Mars, Mercury, Jupiter, Venus, Saturn (not Sun, Moon, Rahu, Ketu).
The planet with higher longitude wins; the loser is weakened.
Returns
-------
list of dict — each war detected
"""
war_planets = ["Mars", "Mercury", "Jupiter", "Venus", "Saturn"]
wars = []
for i in range(len(war_planets)):
for j in range(i + 1, len(war_planets)):
p1, p2 = war_planets[i], war_planets[j]
lon1 = raw_planets[p1]["lon"]
lon2 = raw_planets[p2]["lon"]
# Angular separation (handle wrap-around at 360°)
diff = abs(lon1 - lon2)
if diff > 180:
diff = 360 - diff
if diff <= 1.0:
# Planet with higher latitude wins (simplified: brighter/larger planet wins)
# Traditional: planet with higher longitude in the same sign wins
# Simplified: we report both and let interpretation handle it
winner = p1 if lon1 > lon2 else p2
loser = p2 if winner == p1 else p1
wars.append({
"planet1": p1,
"planet2": p2,
"separation_degrees": round(diff, 4),
"winner": winner,
"loser": loser,
"description": f"{p1} and {p2} in planetary war ({diff:.2f}° apart) — {loser} is weakened, {winner} gains strength.",
})
return wars
def detect_gandanta(raw_planets, asc_lon=None):
"""
Detect Gandanta: planets or Lagna at water-fire sign junctions (last/first 3°20').
These are inauspicious knot points where nakshatra and sign boundaries overlap.
Returns
-------
list of dict — each gandanta point detected
"""
gandanta_points = []
def _check_gandanta(name, longitude):
sign_idx = int(longitude / 30) % 12
degree = longitude % 30
for water_idx, fire_idx in GANDANTA_JUNCTIONS:
# Last 3°20' of water sign
if sign_idx == water_idx and degree >= (30 - GANDANTA_ORB):
return {
"planet": name,
"sign": ZODIAC_SIGNS[sign_idx],
"degree": round(degree, 2),
"junction": f"{ZODIAC_SIGNS[water_idx]}-{ZODIAC_SIGNS[fire_idx]}",
"position": "end_of_water_sign",
"description": f"{name} at {degree:.1f}° {ZODIAC_SIGNS[sign_idx]} — Gandanta zone (karmic knot, spiritual transformation).",
}
# First 3°20' of fire sign
if sign_idx == fire_idx and degree <= GANDANTA_ORB:
return {
"planet": name,
"sign": ZODIAC_SIGNS[sign_idx],
"degree": round(degree, 2),
"junction": f"{ZODIAC_SIGNS[water_idx]}-{ZODIAC_SIGNS[fire_idx]}",
"position": "start_of_fire_sign",
"description": f"{name} at {degree:.1f}° {ZODIAC_SIGNS[sign_idx]} — Gandanta zone (karmic knot, spiritual transformation).",
}
return None
for name, rp in raw_planets.items():
result = _check_gandanta(name, rp["lon"])
if result:
gandanta_points.append(result)
if asc_lon is not None:
result = _check_gandanta("Lagna", asc_lon)
if result:
gandanta_points.append(result)
return gandanta_points