Files
Jyotisha/scripts/chart_view_points/api.py
T
ClaudeandClaude Opus 5.5 578a361460 feat(chart): functional roles, 7/8 Chara Karaka and three new 特殊点 groups (#163 1-3)
/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
2026-11-02 20:35:31 +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_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),
}