Files
Jyotisha/scripts/chart_view_points/api.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

370 lines
16 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.
"""`/api/chart_points`: the chart page's data tables and point layers (issue #114).
Everything here is an existing engine producer called directly, so the chart
page does not have to run the full reading:
- upagrahas: ``gulika.calculate_upagrahas`` (solar five + six time-segment points)
- special lagnas: ``native_special_lagnas.native_special_lagna_rows`` (Swiss
Ephemeris sunrise; not ``jaimini.calc_sunrise_utc_hours``, BUG-1462)
- Yogi / Avayogi: ``annual_native.yogi_sphuta.build_yogi_sphuta_local``
- arudha padas A1–A11 and UL: ``jaimini.calc_arudha_padas``
- friendship (natural, temporary, compound): ``jyotish_engine._build_planetary_friendship_snapshot``
- Chara karakas: ``chart_view_points.karaka_rank.rank_chara_karakas`` (the ranking Goel
uses too), seven-karaka by default; ``karaka_scheme`` ``"8"`` gives the
eight-karaka table (Rahu reversed, Ketu out) and ``"both"`` adds it as
``chara_karakas_8``. Bodies equal at full precision mark only the roles they
span as tied; the other roles keep their planet (issue #163)
- functional roles: ``chart_view_points.functional_roles.functional_role_table`` over
``functional_benefics`` profile ``bphs_ch34_general_with_sign_exceptions_v3_166q4``
- sensitive points: Bhrigu Bindu from ``jyotish_engine._calc_sensitive_points``
(the chart's own Rahu, so it follows ``node_mode``), 64th Navamsa from the
Moon and 22nd Drekkana from the Lagna (``varga``); the last two are a sign
and its lord only
- Sahams: ``tajika.calc_all_sahams`` (PVR §28.8.1 Table 74; day or night from
the birth time and place, ``saham_daynight`` Hindu rising)
- Goel special points: ``goel_jaimini_strength.build_goel_chart_evidence``
under the printed-text node policy, with the worked-example policy beside it
Each block fails on its own (``status: blocked`` + reason); one failure never
fails the response.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Callable
SIGNS = (
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
)
class ChartPointsInputError(ValueError):
"""The request is missing the birth moment or the natal chart."""
def _number(body: dict[str, Any], key: str) -> float:
value = body.get(key)
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ChartPointsInputError(f"{key} must be a number")
return float(value)
def _guard(build: Callable[[], Any]) -> Any:
try:
return build()
except Exception as exc: # each block is independent
return {"status": "blocked", "reason": f"{exc.__class__.__name__}: {exc}"}
def _longitude(planet: Any) -> float | None:
if not isinstance(planet, dict):
return None
for key in ("lon", "degree_raw", "longitude"):
value = planet.get(key)
if isinstance(value, (int, float)) and not isinstance(value, bool):
return float(value) % 360.0
return None
def _nakshatra(longitude: float) -> dict[str, Any]:
from jyotish_engine import _nakshatra_fields
return _nakshatra_fields(longitude)
def _point(key: str, longitude: float, asc_sign: int, **extra: Any) -> dict[str, Any]:
longitude = longitude % 360.0
sign = int(longitude // 30) % 12
return {
**_nakshatra(longitude),
"key": key,
"longitude": round(longitude, 4),
"sign": SIGNS[sign],
"degree_in_sign": round(longitude % 30, 4),
"house": (sign - asc_sign) % 12 + 1,
**extra,
}
UPAGRAHA_ORDER = (
"Dhuma", "Vyatipata", "Parivesha", "Indrachapa", "Upaketu",
"Kaala", "Mrityu", "Artha_Praharaka", "Yama_Ghantaka", "Gulika", "Maandi",
)
SPECIAL_ORDER = (
"Bhava_Lagna", "Hora_Lagna", "Ghati_Lagna", "ViGhati_Lagna",
"Pranapada_Lagna", "Sree_Lagna", "Indu_Lagna", "Varnada_Lagna",
)
SEVEN = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
NINE = SEVEN + ("Rahu", "Ketu")
ARUDHA_ORDER = ("A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", "A11", "UL")
# Interface order for the Saham group: these nine first, the rest under 「全部」.
SAHAM_PRIMARY = (
"Punya_Saham", "Vidya_Saham", "Yashas_Saham", "Mitra_Saham", "Vivaha_Saham",
"Artha_Saham", "Labha_Saham", "Karyasiddhi_Saham", "Paradesa_Saham",
)
GOEL_POINTS = ("Brahma", "Maheshwara", "Rudra")
GOEL_POLICIES = ("include_nodes_text", "exclude_nodes_worked_example")
def _karaka_scheme(body: dict[str, Any]) -> str:
value = str(body.get("karaka_scheme") or "7").strip()
if value not in ("7", "8", "both"):
raise ChartPointsInputError("karaka_scheme must be 7, 8 or both")
return value
def compute_chart_points(body: dict[str, Any]) -> dict[str, Any]:
if not isinstance(body, dict):
raise ChartPointsInputError("request body must be an object")
planets = body.get("planets")
ascendant = body.get("ascendant")
if not isinstance(planets, dict) or not isinstance(ascendant, dict):
raise ChartPointsInputError("planets and ascendant from /api/chart are required")
asc_lon = _longitude(ascendant)
sun_lon = _longitude(planets.get("Sun"))
if asc_lon is None or sun_lon is None:
raise ChartPointsInputError("ascendant and Sun longitudes are required")
year, month, day = int(_number(body, "year")), int(_number(body, "month")), int(_number(body, "day"))
hour, minute = int(_number(body, "hour")), int(_number(body, "minute"))
second = int(body.get("second") or 0)
lat, lon, tz = _number(body, "lat"), _number(body, "lon"), _number(body, "tz")
ayanamsa = str(body.get("ayanamsa") or "raman")
node_mode = str(body.get("node_mode") or "unspecified")
karaka_scheme = _karaka_scheme(body)
longitudes = {name: value for name in NINE if (value := _longitude(planets.get(name))) is not None}
birth = datetime(year, month, day, hour, minute, second)
asc_sign = int(asc_lon // 30) % 12
def upagrahas() -> dict[str, Any]:
from gulika import calculate_upagrahas
packet = calculate_upagrahas(birth, lat=lat, lon=lon, tz=tz, sun_longitude=sun_lon, ayanamsa=ayanamsa)
raw = packet.get("raw") or {}
rows = [
_point(name, float(raw[name]["longitude"]), asc_sign,
kind="solar" if raw[name].get("source") == "solar_longitude_formula" else "time_segment")
for name in UPAGRAHA_ORDER if isinstance(raw.get(name), dict) and "longitude" in raw[name]
]
return {"status": "computed", "rows": rows, "boundary": packet.get("boundary")}
def special() -> dict[str, Any]:
from native_special_lagnas import native_special_lagna_rows
rows = native_special_lagna_rows(birth, lat, lon, tz, asc_lon, ayanamsa)
out = [
_point(key, float(rows[key]["longitude"]), asc_sign)
for key in SPECIAL_ORDER
if isinstance(rows.get(key), dict) and rows[key].get("status") == "computed"
]
if not out:
reasons = {row.get("reason") for row in rows.values() if isinstance(row, dict)}
return {"status": "blocked", "reason": ";".join(sorted(str(r) for r in reasons if r))}
return {"status": "computed", "rows": out}
def yogi() -> dict[str, Any]:
from annual_native.yogi_sphuta import build_yogi_sphuta_local
packet = build_yogi_sphuta_local({"planets": planets, "ascendant": ascendant})
rows = []
for key, name in (("yogi", "Yogi_Point"), ("ava_yogi", "Avayogi_Point")):
part = packet.get(key)
if isinstance(part, dict) and isinstance(part.get("longitude"), (int, float)):
rows.append(_point(name, float(part["longitude"]), asc_sign))
if not rows:
return {"status": "blocked", "reason": str(packet.get("reason") or "yogi_points_missing")}
return {"status": "computed", "rows": rows, "formula": packet.get("formula")}
def arudha() -> dict[str, Any]:
from jaimini import calc_arudha_padas
longitudes = {name: value for name, value in ((n, _longitude(p)) for n, p in planets.items()) if value is not None}
padas = calc_arudha_padas(asc_sign, longitudes).get("padas") or {}
rows = []
for key in ARUDHA_ORDER:
pada = padas.get(key)
if not isinstance(pada, dict) or pada.get("sign") not in SIGNS:
continue
sign = SIGNS.index(pada["sign"])
rows.append({
"key": key,
"sign": pada["sign"],
"house": (sign - asc_sign) % 12 + 1,
"source_house": int(pada.get("house_num") or ARUDHA_ORDER.index(key) + 1),
"source_lord": pada.get("source_house_lord"),
"exception": bool(pada.get("exception_triggered")),
})
return {"status": "computed", "rows": rows}
def karakas(scheme: int) -> dict[str, Any]:
from chart_view_points.karaka_rank import rank_chara_karakas
ranking = rank_chara_karakas({name: lon % 30 for name, lon in longitudes.items()}, scheme)
if ranking["status"] != "computed":
return {"status": "blocked", "reason": ranking["reason"], "scheme": f"chara_{scheme}"}
rows = [
{
"key": row["key"], "name": row["name"], "rank": row["rank"], "planet": row["planet"],
"tied": row["tied"], "candidates": row["candidates"],
"degree_in_sign": round(row["degree_in_sign"], 6), "sort_degree": round(row["sort_degree"], 6),
}
for row in ranking["rows"]
]
return {
"status": "computed",
"scheme": f"chara_{scheme}",
"rows": rows,
"ties": ranking["ties"],
"sort_basis": ranking["sort_basis"],
"tie_tolerance": ranking["tie_tolerance"],
}
def functional() -> dict[str, Any]:
from chart_view_points.functional_roles import functional_role_table
return functional_role_table(SIGNS[asc_sign])
def sensitive() -> dict[str, Any]:
from jyotish_engine import _calc_sensitive_points
from varga import calc_22nd_drekkana, calc_64th_navamsa
rows = []
bhrigu = _calc_sensitive_points(planets).get("bhrigu_bindu")
if isinstance(bhrigu, dict) and isinstance(bhrigu.get("longitude"), (int, float)):
rows.append(_point(
"Bhrigu_Bindu", float(bhrigu["longitude"]), asc_sign,
precision="longitude", lord=bhrigu.get("lord"),
reference={"from": "Rahu", "to": "Moon", "arc": bhrigu.get("arc_mode"), "node_mode": node_mode},
))
moon = longitudes.get("Moon")
if moon is not None:
n64 = calc_64th_navamsa(moon)
sign = SIGNS.index(n64["sign"])
rows.append({
"key": "Navamsa_64", "sign": n64["sign"], "house": (sign - asc_sign) % 12 + 1,
"lord": n64["lord"], "precision": "sign",
"reference": {"from": "Moon", "base_sign": n64["base_navamsa_sign"], "division": "D9", "count": 64},
})
d22 = calc_22nd_drekkana(asc_lon)
sign = SIGNS.index(d22["sign"])
rows.append({
"key": "Drekkana_22", "sign": d22["sign"], "house": (sign - asc_sign) % 12 + 1,
"lord": d22["lord"], "precision": "sign",
"reference": {"from": "Lagna", "base_sign": d22["base_drekkana_sign"], "division": "D3", "count": 22},
})
return {"status": "computed", "rows": rows, "node_mode": node_mode}
def sahams() -> dict[str, Any]:
from tajika import calc_all_sahams
if len(longitudes) < len(NINE):
return {"status": "blocked", "reason": "planets_missing"}
packet = calc_all_sahams(dict(longitudes), asc_lon, birth, "natal", lat=lat, lon=lon, tz=tz)
if packet.get("status") == "blocked":
return {"status": "blocked", "reason": str(packet.get("reason"))}
rows = []
for value in packet.values():
if not isinstance(value, dict) or not isinstance(value.get("longitude"), (int, float)):
continue
rule = value.get("formula_rule")
key = rule or str(value.get("name") or "").replace(" ", "_")
extra: dict[str, Any] = {
"name": value.get("name"),
"rule_backed": bool(rule),
"primary": key in SAHAM_PRIMARY,
}
if rule:
extra.update({
"formula_operands": value.get("formula_operands"),
"formula_variant": value.get("formula_variant"),
"plus_30_applied": value.get("zodiacal_order_exception_applied"),
})
rows.append(_point(key, float(value["longitude"]), asc_sign, **extra))
order = {key: index for index, key in enumerate(SAHAM_PRIMARY)}
rows.sort(key=lambda row: (order.get(row["key"], len(order)), not row["rule_backed"]))
daynight = packet.get("daynight_evidence") or {}
return {
"status": "computed",
"rows": rows,
"is_daytime": daynight.get("is_daytime"),
"daynight_method": daynight.get("method"),
"source": "PVR Narasimha Rao, Vedic Astrology: An Integrated Approach, §28.8.1 Table 74",
}
def goel() -> dict[str, Any]:
try:
from scripts.goel_jaimini_strength import PROFILE, SOURCE, build_goel_chart_evidence
except ImportError:
from goel_jaimini_strength import PROFILE, SOURCE, build_goel_chart_evidence
if len(longitudes) < len(NINE):
return {"status": "blocked", "reason": "planets_missing"}
runs = {
policy: build_goel_chart_evidence(dict(longitudes), asc_lon, node_occupancy_policy=policy)
for policy in GOEL_POLICIES
}
main, compare = (runs[policy] for policy in GOEL_POLICIES)
if main["strength"].get("status") != "executed":
return {
"status": "blocked",
"reason": str(main["strength"].get("reason")),
"karaka_ties": main["strength"].get("karaka_ties"),
"profile": PROFILE,
}
rows = []
for name in GOEL_POINTS:
point, other = main["special_points"].get(name) or {}, compare["special_points"].get(name) or {}
planet = point.get("value") if point.get("status") == "executed" else None
row: dict[str, Any] = {
"key": f"Goel_{name}",
"point": name,
"status": point.get("status"),
"planet": planet,
"rule": point.get("rule"),
"reason": point.get("reason"),
"candidates": point.get("candidates"),
"compare": {
"status": other.get("status"),
"planet": other.get("value") if other.get("status") == "executed" else None,
"reason": other.get("reason"),
},
}
row["differs"] = (row["status"], planet) != (row["compare"]["status"], row["compare"]["planet"])
if planet in longitudes:
sign = int(longitudes[planet] // 30) % 12
row.update({"sign": SIGNS[sign], "house": (sign - asc_sign) % 12 + 1})
rows.append(row)
return {
"status": "computed",
"rows": rows,
"policy": GOEL_POLICIES[0],
"compare_policy": GOEL_POLICIES[1],
"lagna_seventh_strength": {policy: runs[policy].get("lagna_seventh_strength") for policy in GOEL_POLICIES},
"karaka_scheme": "chara_7",
"profile": PROFILE,
"source": SOURCE,
}
def friendship() -> dict[str, Any]:
from jyotish_engine import _build_planetary_friendship_snapshot
return _build_planetary_friendship_snapshot(planets)
return {
"success": True,
"endpoint": "chart_points",
"ascendant_sign": SIGNS[asc_sign],
"upagrahas": _guard(upagrahas),
"special_lagnas": _guard(special),
"yogi_points": _guard(yogi),
"arudha_padas": _guard(arudha),
"friendship": _guard(friendship),
"chara_karakas": _guard(lambda: karakas(8 if karaka_scheme == "8" else 7)),
**({"chara_karakas_8": _guard(lambda: karakas(8))} if karaka_scheme == "both" else {}),
"functional_roles": _guard(functional),
"sensitive_points": _guard(sensitive),
"sahams": _guard(sahams),
"goel_points": _guard(goel),
}