Files
Jyotisha/scripts/dasha_registry.py
T
ClaudeandClaude Opus 5.5 676b7d47cd fix: parity3 product-side defects and upstream lag (Vimsopaka, Narayana ages, asc precision, labels, Sade Sati)
Three-way parity audit 2026-11-07 (U ea2a9d65 / F frozen / P f70ae94e):
- Vimsopaka: look vargas up by "D{n}_" prefix (D60 key was misspelt, every
  D60 fell to Neutral) and score only the sign-owner relation (upstream
  ab5b8709, BPHS 7.21-27). All 16 varga scores now equal U on 3 charts.
- Narayana: age = total_seconds / (86400 x 365.25) keeping the birth time
  (upstream ab5b8709); Rath rows carry start_date / end_date from the
  unrounded ages; consult layer, report facts, API and calendar read them.
- Ascendant degree_raw / lon unrounded (engine and chart API).
- position_mode label: legacy is the apparent frame since BUG-1275; the
  profile and module labels now say "apparent" (legacy still accepted).
- Functional profile label v2 -> v3_166q4 (the table actually used).
- full-reading Sade Sati uses the dated transit Saturn (upstream e628118d):
  Jobs 2026-11-07 peak, owner / Obama not active, as U.
Consult evidence card golden recaptured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-07 19:01:10 +08:00

412 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""HTTP dasha registry.
`DASHA_REGISTRY_METADATA` is the old `extended_dashas.DASHA_REGISTRY` moved
verbatim so `/api/chart` `available_dashas` stays byte-identical.
`compute_dasha` calls the same modules `cmd_full_reading` calls, with the same
birth-info fields. Systems without that module return "not implemented"
instead of a fabricated timeline.
This module must not import `jyotish_api_server` or PyJHora.
"""
from __future__ import annotations
import copy
from datetime import datetime
from typing import Any
try:
from source_bounded_child_periods import PYJHORA_PARENT_SEEDED_EQUAL_SPLIT_PROFILE
except ModuleNotFoundError: # pragma: no cover - package import
from scripts.source_bounded_child_periods import PYJHORA_PARENT_SEEDED_EQUAL_SPLIT_PROFILE
DASHA_REGISTRY_METADATA = {
"vimshottari": {"name": "Vimshottari", "years": 120, "type": "nakshatra"},
"ashtottari": {"name": "Ashtottari", "years": 108, "type": "nakshatra"},
"yogini": {"name": "Yogini", "years": 36, "type": "nakshatra"},
"kalachakra": {"name": "Kalachakra", "years": 94, "type": "nakshatra"},
"tara": {"name": "Tara Dasha", "years": 81, "type": "nakshatra"},
"shodasottari": {"name": "Shodasottari", "years": 128, "type": "nakshatra"},
"panchottari": {"name": "Panchottari", "years": 105, "type": "nakshatra"},
"satabdika": {"name": "Satabdika", "years": 100, "type": "nakshatra"},
"chara": {"name": "Chara (Jaimini)", "years": 36, "type": "rasi"},
"narayana": {"name": "Narayana", "years": 36, "type": "rasi"},
"shoola": {"name": "Shoola Dasha", "years": 9, "type": "rasi"},
"sudasa": {"name": "Sudasa", "years": 36, "type": "rasi"},
"drig": {"name": "Drig Dasha", "years": 36, "type": "rasi"},
"sthira": {"name": "Sthira Dasha", "years": 36, "type": "rasi"},
"mandooka": {"name": "Mandooka Dasha", "years": 36, "type": "rasi"},
"lagnamsaka": {"name": "Lagnamsaka Dasha", "years": 36, "type": "rasi"},
"yogardha": {"name": "Yogardha Dasha", "years": 36, "type": "rasi"},
"brahma": {"name": "Brahma Dasha", "years": 36, "type": "rasi"},
"dwisaptati": {"name": "Dwisaptati Sama", "years": 72, "type": "conditional"},
"shattrimsa": {"name": "Shattrimsa Sama", "years": 36, "type": "conditional"},
"dwadashottari": {"name": "Dwadashottari", "years": 112, "type": "conditional"},
"shasti_hayani": {"name": "Shasti-Hayani", "years": 60, "type": "conditional"},
"chaturaaseeti": {"name": "Chaturaaseeti Sama", "years": 84, "type": "conditional"},
"tithi_ashtottari": {"name": "Tithi Ashtottari", "years": 108, "type": "conditional"},
"tithi_yogini": {"name": "Tithi Yogini", "years": 36, "type": "conditional"},
"patyayini": {"name": "Patyayini", "years": 36, "type": "conditional"},
"navamsa": {"name": "Navamsa Dasha", "years": 36, "type": "varga"},
"kendradi": {"name": "Kendradi", "years": 38, "type": "bhav"},
"paryaaya": {"name": "Paryaaya Dasha", "years": 36, "type": "varga"},
"rashmi": {"name": "Rashmi Dasha", "years": 36, "type": "special"},
"naisargika": {"name": "Naisargika Dasha", "years": 120, "type": "special"},
"aayu": {"name": "Aayu Dasha", "years": 36, "type": "special"},
"karaka": {"name": "Karaka Dasha", "years": 36, "type": "special"},
"panchasvara": {"name": "Panchasvara Dasha", "years": 36, "type": "special"},
"sudarsana": {"name": "Sudarsana Chakra Dasha", "years": 36, "type": "special"},
# Ported from the astrologer's upstream skill (yinduzhanxing 5fb8379c).
"kaala": {"name": "Kaala Dasha", "years": 120, "type": "special"},
"padanadhamsa": {"name": "Padanadhamsa Dasha", "years": 36, "type": "rasi"},
}
# Served by scripts/upstream_technique_layers.py (upstream producers). Karaka,
# Tara, Mandooka and Paryaaya fail closed there, as upstream does, until a
# source profile for the missing input is admitted.
_UPSTREAM_DASHA_KEYS = frozenset((
"naisargika", "kaala", "karaka", "tara", "lagnamsaka", "yogardha",
"mandooka", "paryaaya", "padanadhamsa",
))
_CONDITIONAL_CALLS = {
"shodasottari": ("shodashottari_dasha", "calculate_shodashottari_dasha", {}),
"dwadashottari": (
"dwadashottari_dasha",
"calculate_dwadashottari_dasha",
{"sukramsaka_profile": "navamsa_lagna_taurus_or_libra"},
),
"dwisaptati": ("dwisaptatisama_dasha", "calculate_dwisaptatisama_dasha", {}),
"shattrimsa": ("shattrimshatsama_dasha", "calculate_shattrimshatsama_dasha", {}),
"panchottari": ("panchottari_dasha", "calculate_panchottari_dasha", {}),
"satabdika": ("satabdika_dasha", "calculate_satabdika_dasha", {}),
"chaturaaseeti": ("chaturshitisama_dasha", "calculate_chaturshitisama_dasha", {}),
}
SIGNS = [
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
]
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",
}
class DashaNotImplemented(Exception):
"""The registry key exists, but there is no engine module behind it."""
def available_dasha_rows() -> list[dict[str, Any]]:
"""`/api/chart` `available_dashas` rows, same order and fields as before."""
return [
{"key": key, "name": info["name"], "years": info["years"], "type": info["type"]}
for key, info in DASHA_REGISTRY_METADATA.items()
]
def _load(module_name: str):
try:
return __import__(module_name)
except ModuleNotFoundError: # pragma: no cover - package import
return __import__(f"scripts.{module_name}", fromlist=[module_name])
def _longitude(value: Any) -> float | None:
if isinstance(value, (int, float)):
return float(value) % 360.0
if not isinstance(value, dict):
return None
for key in ("lon", "longitude", "degree"):
raw = value.get(key)
if isinstance(raw, (int, float)):
return float(raw) % 360.0
sign_idx = value.get("sign_idx")
if sign_idx is None and value.get("sign") in SIGNS:
sign_idx = SIGNS.index(value["sign"])
degree = value.get("degree_in_sign", value.get("degree", 0))
try:
if sign_idx is None:
return None
return (int(sign_idx) * 30 + float(degree or 0)) % 360.0
except (TypeError, ValueError):
return None
def _planet_longitudes(planets: dict) -> dict[str, float]:
longitudes = {}
if not isinstance(planets, dict):
return longitudes
for name, data in planets.items():
longitude = _longitude(data)
if longitude is not None:
longitudes[str(name)] = longitude
return longitudes
def _optional_float(body: dict, *keys: str) -> float | None:
for key in keys:
raw = body.get(key)
if isinstance(raw, (int, float)):
return float(raw)
return None
def _reference_datetime(birth_dt: datetime, body: dict) -> datetime:
raw = body.get("today") or body.get("current_date")
if isinstance(raw, datetime):
return raw
if isinstance(raw, str) and raw.strip():
return datetime.strptime(raw.strip()[:10], "%Y-%m-%d")
return datetime.now()
def build_reading_birth_info(
birth_dt: datetime,
planets: dict,
ascendant: dict | None,
body: dict | None = None,
) -> tuple[dict[str, Any], dict[str, Any], dict[str, float]]:
"""Same fields `cmd_full_reading` puts in `conditional_birth_info`.
Returns `(alt_dasha_info, conditional_birth_info, planet_longitudes)`.
Kept here, not extracted from the engine, so the full-reading dict
construction and its output stay untouched.
"""
body = body or {}
ascendant = ascendant or {}
planet_lons = _planet_longitudes(planets)
moon_lon = planet_lons.get("Moon", _longitude(body.get("moon_lon")) or 0.0)
sun_lon = planet_lons.get("Sun", _longitude(body.get("sun_lon")) or 0.0)
asc_lon = _longitude(ascendant)
if asc_lon is None:
asc_lon = _longitude(body.get("ascendant_longitude"))
if asc_lon is None:
sign_idx = ascendant.get("sign_idx", body.get("asc_sign_idx", 0))
try:
asc_idx = int(sign_idx) % 12
except (TypeError, ValueError):
asc_idx = 0
asc_lon = float(asc_idx * 30)
asc_idx = int(float(asc_lon) / 30) % 12
moon_nakshatra_index = int(moon_lon / (360 / 27)) % 27
moon_pada = int((moon_lon % (360 / 27)) / (360 / 108)) + 1
tithi_number = int(((moon_lon - sun_lon) % 360) / 12) + 1
lagna_lord_name = SIGN_LORDS[SIGNS[asc_idx]]
tenth_lord_name = SIGN_LORDS[SIGNS[(asc_idx + 9) % 12]]
try:
varga = _load("varga")
navamsa_ascendant_sign_index = int(varga.calc_varga(asc_lon, 9).get("sign_idx", 0)) % 12
venus_navamsa_sign_index = int(varga.calc_varga(float(planet_lons.get("Venus", 0)), 9).get("sign_idx", 0)) % 12
except Exception:
navamsa_ascendant_sign_index = None
venus_navamsa_sign_index = None
degree_in_sign = float(asc_lon) % 30.0
ascendant_hora_lord = (
"Sun"
if (asc_idx % 2 == 0 and degree_in_sign < 15.0)
or (asc_idx % 2 == 1 and degree_in_sign >= 15.0)
else "Moon"
)
birth_is_daytime = None
lat = _optional_float(body, "lat", "latitude")
lon = _optional_float(body, "lon", "longitude")
tz = _optional_float(body, "tz", "timezone")
if lat is not None and lon is not None and tz is not None:
try:
daynight = _load("saham_daynight")
birth_is_daytime = bool(daynight.determine_daytime(birth_dt, lat=lat, lon=lon, tz=tz).get("is_daytime"))
except Exception:
birth_is_daytime = None
applicability_rule_family = (
"rahu_from_lagna_lord_kendra_trikona_plus_paksha_daynight"
if birth_is_daytime is not None
else "moon_nakshatra_plus_paksha"
)
# Upstream ab5b8709 (engine sync 2026-11-07): the conditional families read
# the observation date and the year length from here; without them they
# used the wall clock and their 365.25636 module constant.
year_days = _optional_float(body, "dasha_year_days", "dashaYearDays") or 365.25
as_of = body.get("as_of") or body.get("transit_date") or body.get("today")
alt = {
"birth_datetime": birth_dt,
**({"as_of": as_of} if as_of else {}),
"dasha_year_days": year_days,
"moon_nakshatra_index": moon_nakshatra_index,
"moon_pada": moon_pada,
"is_shukla_paksha": 1 <= tithi_number <= 15,
"lagna_rashi_index": asc_idx,
}
conditional = {
**alt,
"ascendant_longitude": asc_lon,
"sun_longitude": sun_lon,
"moon_longitude": moon_lon,
"birth_is_daytime": birth_is_daytime,
"ascendant_sign_index": asc_idx,
"ascendant_hora_lord": ascendant_hora_lord,
"navamsa_ascendant_sign_index": navamsa_ascendant_sign_index,
"venus_rasi_sign_index": int(float(planet_lons.get("Venus", 0)) / 30) % 12,
"venus_navamsa_sign_index": venus_navamsa_sign_index,
"tenth_lord_sign_index": int(float(planet_lons.get(tenth_lord_name, 0)) / 30) % 12,
"rahu_sign_index": int(float(planet_lons.get("Rahu", 0)) / 30) % 12,
"lagna_lord_sign_index": int(float(planet_lons.get(lagna_lord_name, 0)) / 30) % 12,
"applicability_rule_family": applicability_rule_family,
}
return alt, conditional, planet_lons
def _major_rows(payload: Any) -> list:
if isinstance(payload, dict):
majors = payload.get("major")
if isinstance(majors, list):
return majors
periods = payload.get("periods")
if isinstance(periods, list):
return periods
sequence = payload.get("mahadasha_sequence")
if isinstance(sequence, list):
return sequence
return []
if isinstance(payload, list):
return payload
return []
def _conditional_payload(key: str, conditional: dict) -> dict:
module_name, function_name, extra = _CONDITIONAL_CALLS[key]
module = _load(module_name)
birth_info = {
**conditional,
**extra,
"child_period_profile": PYJHORA_PARENT_SEEDED_EQUAL_SPLIT_PROFILE,
}
return getattr(module, function_name)(birth_info)
def compute_dasha(
key: str,
*,
birth_dt: datetime,
planets: dict,
ascendant: dict | None,
body: dict | None = None,
) -> dict[str, Any]:
"""Return engine major periods for one registry key.
Raises `DashaNotImplemented` when the key has no engine module. Vimshottari
stays on the handler's existing canonical branch and is not computed here.
"""
if key not in DASHA_REGISTRY_METADATA:
raise DashaNotImplemented("Unknown dasha system")
if key == "chara":
raise DashaNotImplemented("chara dasha is served at /api/dasha/chara")
body = body or {}
alt, conditional, planet_lons = build_reading_birth_info(birth_dt, planets or {}, ascendant, body)
if key == "yogini":
module = _load("yogini_dasha")
payload = module.calculate_yogini_dasha({**alt, "moon_longitude": conditional["moon_longitude"]})
year_days = 365.25
if isinstance(payload, dict) and payload.get("dasha_year_days") is not None:
year_days = float(payload["dasha_year_days"])
periods = []
for row in _major_rows(payload):
copied = copy.deepcopy(row)
if isinstance(copied, dict):
copied["antardasha"] = module.yogini_antardashas(
copied,
birth_dt=birth_dt,
year_days=year_days,
)
periods.append(copied)
return {"periods": periods}
if key == "ashtottari":
# BUG-1676: same strict gate as the full reading (astrologer 2026-11-07).
payload = _load("ashtottari_dasha").calculate_ashtottari_dasha({
**conditional,
"applicability_rule_family": "bphs_rahu_from_lagna_lord_excluding_lagna_v1",
})
return {"periods": _major_rows(payload)}
if key == "kalachakra":
payload = _load("kalachakra_dasha").calculate_kalachakra_dasha({
**alt,
"moon_longitude": conditional["moon_longitude"],
})
return {"periods": _major_rows(payload)}
if key == "shasti_hayani":
birth_info = dict(conditional)
birth_info["child_period_profile"] = PYJHORA_PARENT_SEEDED_EQUAL_SPLIT_PROFILE
payload = _load("shastihayani_dasha").calculate_shastihayani_dasha(birth_info)
return {"periods": _major_rows(payload)}
if key in _CONDITIONAL_CALLS:
return {"periods": _major_rows(_conditional_payload(key, conditional))}
if key == "narayana":
asc_idx = int(conditional["ascendant_sign_index"])
reference = _reference_datetime(birth_dt, body)
label = _load("narayana_legacy_label")
current_age = label.narayana_age(birth_dt, reference) # 原值 .days / 365.25(upstream ab5b8709)
birth_year = int(body.get("year") or birth_dt.year)
narayana = _load("narayana_dasha")
report = narayana.narayana_dasha_full_report(
lagna_sign_idx=asc_idx,
planet_lons=planet_lons,
current_age=current_age,
birth_year=birth_year,
)
# BUG-1601 (#166 Q9): the periods are the Sanjay Rath MDs; the legacy
# rows travel under `legacy` (labelled, comparison only). No fallback
# to legacy rows when Rath is blocked.
product = label.product_narayana(
report,
asc_idx,
planet_lons,
current_age=current_age,
birth_year=birth_year,
birth_datetime=birth_dt,
)
legacy = product.get("legacy") if isinstance(product.get("legacy"), dict) else {}
return {
"periods": list(product.get("mahadasha") or []),
"algorithm_label": product.get("algorithm_label"),
"profile": product.get("profile"),
"status": product.get("status"),
"reason": product.get("reason") if not product.get("mahadasha") else None,
"blocked_note": product.get("blocked_note"),
"legacy": {
"algorithm_label": legacy.get("algorithm_label"),
"algorithm_note": legacy.get("algorithm_note"),
"profile": legacy.get("profile"),
"periods": _major_rows(legacy),
} if legacy else None,
}
if key == "shoola":
rashi = _load("rashi_dashas_native")
try:
section = rashi.calculate_rashi_dashas(
birth_dt.date(), conditional["ascendant_longitude"], planet_lons
)["shoola"]
except ValueError as exc:
raise DashaNotImplemented(str(exc)) from exc
return {"periods": _major_rows(section)}
if key in _UPSTREAM_DASHA_KEYS:
ascendant_lon = _longitude(ascendant or {})
try:
section = _load("upstream_technique_layers").compute_registry_dasha(
key,
birth_dt=birth_dt,
planets={name: {"lon": value} for name, value in planet_lons.items()},
ascendant={"lon": ascendant_lon if ascendant_lon is not None else conditional["ascendant_longitude"]},
body=body,
)
except ValueError as exc:
raise DashaNotImplemented(f"{key} dasha blocked: {exc}") from exc
return {
"periods": section["periods"],
"profile": section.get("profile"),
"status": section.get("evidence_status") or section.get("status"),
"algorithm_note": "; ".join(section.get("limits") or []) or None,
}
raise DashaNotImplemented("dasha system not implemented")