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
412 lines
18 KiB
Python
412 lines
18 KiB
Python
"""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")
|