/api/chart_points: - karaka_scheme 7 (default) / 8 / both; shared ranking in chart_view_points/karaka_rank.py (unrounded in-sign degree, Rahu reversed in the 8 scheme, Ketu out). Bodies equal at full precision mark only the roles they span as tied; Goel uses the same ranking (BUG-1512). - functional_roles: v2 BPHS ch.34 profile laid out per body, nodes pending, Yogakaraka / Badhaka / Kendradhipati (conditions not evaluated) as separate labels. - sensitive_points (Bhrigu Bindu per chart node_mode, 64th Navamsa, 22nd Drekkana sign-only), sahams (PVR Table 74, nine first, all kept), goel_points (printed-text policy, worked example beside it). 盘面数据: 行星 gets Chara Karaka (AK · 自我, 七星/八星 switch), 自然属性, 管辖宫位, 功能角色, 补充身份; 特殊点 splits into 特殊上升 / 敏感点 / Saham / Goel 特殊点 and the board's 特殊点 layer follows the open group. jaimini.py, functional_benefics.py and varga.py are untouched (rectification frozen identity). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
370 lines
16 KiB
Python
370 lines
16 KiB
Python
"""`/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_v2``
|
||
- 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),
|
||
}
|