/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
247 lines
12 KiB
Python
247 lines
12 KiB
Python
"""`/api/chart_points` (issue #114): the chart page's data tables come from existing producers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SCRIPTS = ROOT / "scripts"
|
|
if str(SCRIPTS) not in sys.path:
|
|
sys.path.insert(0, str(SCRIPTS))
|
|
|
|
from chart_view_points.api import ChartPointsInputError, SIGNS, compute_chart_points # noqa: E402
|
|
|
|
# Real engine /api/chart output (1990-06-15 12:00 Beijing, raman), shared with the frontend tests.
|
|
GOLDEN = json.loads((ROOT / "frontend" / "tests" / "fixtures" / "chart-insight-golden.json").read_text(encoding="utf-8"))
|
|
BODY = {
|
|
"year": 1990, "month": 6, "day": 15, "hour": 12, "minute": 0, "second": 0,
|
|
"lat": 39.9042, "lon": 116.4074, "tz": 8, "ayanamsa": "raman",
|
|
"planets": GOLDEN["chart"]["planets"], "ascendant": GOLDEN["chart"]["ascendant"],
|
|
}
|
|
|
|
|
|
def _sign_index(longitude: float) -> int:
|
|
return int((longitude % 360) // 30)
|
|
|
|
|
|
def test_every_block_is_computed_from_the_engine_producers():
|
|
result = compute_chart_points(BODY)
|
|
assert result["success"] is True
|
|
assert result["upagrahas"]["status"] == "computed"
|
|
assert [row["key"] for row in result["upagrahas"]["rows"]][:5] == ["Dhuma", "Vyatipata", "Parivesha", "Indrachapa", "Upaketu"]
|
|
assert len(result["upagrahas"]["rows"]) == 11
|
|
assert result["special_lagnas"]["status"] == "computed"
|
|
assert len(result["special_lagnas"]["rows"]) == 8
|
|
assert [row["key"] for row in result["yogi_points"]["rows"]] == ["Yogi_Point", "Avayogi_Point"]
|
|
assert [row["key"] for row in result["arudha_padas"]["rows"]] == ["A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", "A11", "UL"]
|
|
assert len(result["friendship"]["rows"]) == 9
|
|
|
|
|
|
def test_points_carry_sign_and_house_counted_from_the_ascendant():
|
|
result = compute_chart_points(BODY)
|
|
asc = _sign_index(float(BODY["ascendant"]["lon"]))
|
|
for block in ("upagrahas", "special_lagnas", "yogi_points"):
|
|
for row in result[block]["rows"]:
|
|
sign = _sign_index(row["longitude"])
|
|
assert row["sign"] == SIGNS[sign]
|
|
assert row["house"] == (sign - asc) % 12 + 1
|
|
assert 0 <= row["degree_in_sign"] < 30
|
|
|
|
|
|
def test_solar_upagrahas_follow_the_sun_formula():
|
|
sun = float(BODY["planets"]["Sun"]["lon"])
|
|
rows = {row["key"]: row["longitude"] for row in compute_chart_points(BODY)["upagrahas"]["rows"]}
|
|
dhuma = (sun + 133 + 20 / 60) % 360
|
|
assert rows["Dhuma"] == pytest.approx(dhuma, abs=1e-3)
|
|
assert rows["Vyatipata"] == pytest.approx((360 - dhuma) % 360, abs=1e-3)
|
|
assert rows["Parivesha"] == pytest.approx((rows["Vyatipata"] + 180) % 360, abs=1e-3)
|
|
|
|
|
|
def test_avayogi_is_yogi_plus_186_40():
|
|
rows = {row["key"]: row["longitude"] for row in compute_chart_points(BODY)["yogi_points"]["rows"]}
|
|
assert (rows["Avayogi_Point"] - rows["Yogi_Point"]) % 360 == pytest.approx(186 + 40 / 60, abs=1e-3)
|
|
|
|
|
|
def test_friendship_compound_is_natural_plus_temporary():
|
|
rows = compute_chart_points(BODY)["friendship"]["rows"]
|
|
sun = next(row for row in rows if row["planet"] == "Sun")
|
|
for target in sun["compound"]["great_friends"]:
|
|
assert target in sun["natural"]["friends"] and target in sun["temporary"]["friends"]
|
|
for target in sun["compound"]["great_enemies"]:
|
|
assert target in sun["natural"]["enemies"] and target in sun["temporary"]["enemies"]
|
|
|
|
|
|
def test_chara_karakas_rank_the_seven_by_in_sign_degree():
|
|
result = compute_chart_points(BODY)["chara_karakas"]
|
|
assert result["status"] == "computed"
|
|
assert result["scheme"] == "chara_7"
|
|
rows = result["rows"]
|
|
assert [row["key"] for row in rows] == ["AK", "AmK", "BK", "MK", "PK", "GK", "DK"]
|
|
planets = BODY["planets"]
|
|
expected = sorted(
|
|
("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"),
|
|
key=lambda name: planets[name]["lon"] % 30,
|
|
reverse=True,
|
|
)
|
|
assert [row["planet"] for row in rows] == expected
|
|
|
|
|
|
def test_chara_karakas_mark_only_the_tied_roles():
|
|
planets = {name: dict(row) for name, row in BODY["planets"].items()}
|
|
planets["Moon"]["lon"] = planets["Sun"]["lon"] % 30 + 30 * int(planets["Moon"]["lon"] // 30)
|
|
result = compute_chart_points({**BODY, "planets": planets})["chara_karakas"]
|
|
# 原值: {"status": "blocked", "reason": "tied_degrees"},整列留空
|
|
# 新值: computed;只有 Sun / Moon 跨的两个角色标 tied,其余五个角色照常
|
|
# 原因: issue #163 占星师答复 2:同度不整列清空,只标「并列/角色待定」
|
|
assert result["status"] == "computed"
|
|
tied = [row for row in result["rows"] if row["tied"]]
|
|
assert [row["key"] for row in tied] == ["GK", "DK"]
|
|
assert all(row["planet"] is None and row["candidates"] == ["Sun", "Moon"] for row in tied)
|
|
assert result["ties"] == [{"planets": ["Sun", "Moon"], "roles": ["GK", "DK"], "sort_degree": pytest.approx(planets["Sun"]["lon"] % 30)}]
|
|
untied = [row for row in result["rows"] if not row["tied"]]
|
|
assert [row["planet"] for row in untied] == ["Venus", "Jupiter", "Mars", "Mercury", "Saturn"]
|
|
|
|
|
|
def test_chara_karakas_sort_by_unrounded_degree():
|
|
"""Equal at four decimals is not a tie; the old column blanked on rounded values."""
|
|
planets = {name: dict(row) for name, row in BODY["planets"].items()}
|
|
sign = 30 * int(planets["Moon"]["lon"] // 30)
|
|
planets["Moon"]["lon"] = sign + planets["Sun"]["lon"] % 30 + 0.00001
|
|
result = compute_chart_points({**BODY, "planets": planets})["chara_karakas"]
|
|
assert result["ties"] == []
|
|
assert [row["planet"] for row in result["rows"]][-2:] == ["Moon", "Sun"]
|
|
|
|
|
|
def test_karaka_scheme_parameter():
|
|
seven = compute_chart_points(BODY)
|
|
assert seven["chara_karakas"]["scheme"] == "chara_7"
|
|
assert "chara_karakas_8" not in seven
|
|
eight = compute_chart_points({**BODY, "karaka_scheme": "8"})["chara_karakas"]
|
|
assert eight["scheme"] == "chara_8"
|
|
assert [row["key"] for row in eight["rows"]] == ["AK", "AmK", "BK", "MK", "PiK", "PK", "GK", "DK"]
|
|
assert "Ketu" not in [row["planet"] for row in eight["rows"]]
|
|
rahu = next(row for row in eight["rows"] if row["planet"] == "Rahu")
|
|
assert rahu["sort_degree"] == pytest.approx(30 - BODY["planets"]["Rahu"]["lon"] % 30, abs=1e-6)
|
|
both = compute_chart_points({**BODY, "karaka_scheme": "both"})
|
|
assert both["chara_karakas"]["scheme"] == "chara_7"
|
|
assert both["chara_karakas_8"]["rows"] == eight["rows"]
|
|
with pytest.raises(ChartPointsInputError):
|
|
compute_chart_points({**BODY, "karaka_scheme": "9"})
|
|
|
|
|
|
def test_functional_roles_keep_profile_identities_and_pending_nodes():
|
|
result = compute_chart_points(BODY)["functional_roles"]
|
|
assert result["status"] == "computed"
|
|
assert result["ascendant"] == "Leo"
|
|
assert result["profile"] == "bphs_ch34_general_with_sign_exceptions_v2"
|
|
assert "jyotishvidya.com/ch34.htm" in result["translation"]
|
|
rows = {row["planet"]: row for row in result["rows"]}
|
|
assert list(rows) == ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"]
|
|
# Leo: Mars owns 4 and 9 (strict yogakaraka); fixed sign, so the Badhaka is the 9th lord, also Mars.
|
|
assert rows["Mars"]["functional"] == "benefic"
|
|
assert {"key": "yogakaraka"} in rows["Mars"]["identities"]
|
|
assert {"key": "badhaka", "house": 9} in rows["Mars"]["identities"]
|
|
# Venus (3, 10): natural benefic owning a kendra and no trine; conditions are not evaluated.
|
|
assert {"key": "kendradhipati", "status": "conditions_not_evaluated"} in rows["Venus"]["identities"]
|
|
assert rows["Venus"]["functional"] == "malefic" and rows["Venus"]["natural_functional_conflict"] is True
|
|
for node in ("Rahu", "Ketu"):
|
|
assert rows[node]["functional"] == "pending"
|
|
assert rows[node]["identities"] == []
|
|
assert rows["Moon"]["natural_condition"] == "lunar_phase_not_evaluated"
|
|
|
|
|
|
def test_sensitive_points_follow_the_chart_nodes_and_keep_sign_only_points():
|
|
result = compute_chart_points({**BODY, "node_mode": "mean"})["sensitive_points"]
|
|
assert result["status"] == "computed"
|
|
rows = {row["key"]: row for row in result["rows"]}
|
|
assert list(rows) == ["Bhrigu_Bindu", "Navamsa_64", "Drekkana_22"]
|
|
rahu = BODY["planets"]["Rahu"]["lon"]
|
|
moon = BODY["planets"]["Moon"]["lon"]
|
|
assert rows["Bhrigu_Bindu"]["longitude"] == pytest.approx((rahu + ((moon - rahu) % 360) / 2) % 360, abs=1e-3)
|
|
assert rows["Bhrigu_Bindu"]["reference"] == {"from": "Rahu", "to": "Moon", "arc": "forward_rahu_to_moon", "node_mode": "mean"}
|
|
for key, anchor in (("Navamsa_64", "Moon"), ("Drekkana_22", "Lagna")):
|
|
assert rows[key]["precision"] == "sign"
|
|
assert "longitude" not in rows[key] and "degree_in_sign" not in rows[key]
|
|
assert rows[key]["reference"]["from"] == anchor
|
|
assert rows[key]["lord"]
|
|
|
|
|
|
def test_sahams_list_the_nine_first_and_keep_every_computed_row():
|
|
result = compute_chart_points(BODY)["sahams"]
|
|
assert result["status"] == "computed"
|
|
assert result["is_daytime"] is True # noon in Beijing
|
|
keys = [row["key"] for row in result["rows"]]
|
|
assert keys[:9] == [
|
|
"Punya_Saham", "Vidya_Saham", "Yashas_Saham", "Mitra_Saham", "Vivaha_Saham",
|
|
"Artha_Saham", "Labha_Saham", "Karyasiddhi_Saham", "Paradesa_Saham",
|
|
]
|
|
assert [row["primary"] for row in result["rows"]].count(True) == 9
|
|
assert sum(row["rule_backed"] for row in result["rows"]) == 36
|
|
assert len(keys) == len(set(keys))
|
|
rows = {row["key"]: row for row in result["rows"]}
|
|
punya = rows["Punya_Saham"]
|
|
assert punya["formula_operands"] == ["Moon", "Sun", "Ascendant"] and punya["formula_variant"] == "day"
|
|
sun, moon, asc = (float(BODY["planets"]["Sun"]["lon"]), float(BODY["planets"]["Moon"]["lon"]), float(BODY["ascendant"]["lon"]))
|
|
expected = (moon - sun + asc) % 360 + (0 if (asc - sun) % 360 <= (moon - sun) % 360 else 30)
|
|
assert punya["longitude"] == pytest.approx(expected % 360, abs=1e-3)
|
|
assert rows["Karya_Saham"]["rule_backed"] is False
|
|
|
|
|
|
GOEL_SPLIT = {
|
|
"Sun": 102.29, "Moon": 123.1, "Mars": 90.57, "Mercury": 18.98, "Jupiter": 104.09,
|
|
"Venus": 127.86, "Saturn": 177.74, "Rahu": 120.14, "Ketu": 300.14,
|
|
}
|
|
|
|
|
|
def test_goel_points_default_to_the_printed_text_and_show_the_worked_example():
|
|
body = {**BODY, "planets": {name: {"lon": lon} for name, lon in GOEL_SPLIT.items()}, "ascendant": {"lon": 135.0}}
|
|
result = compute_chart_points(body)["goel_points"]
|
|
assert result["status"] == "computed"
|
|
assert (result["policy"], result["compare_policy"]) == ("include_nodes_text", "exclude_nodes_worked_example")
|
|
assert result["karaka_scheme"] == "chara_7"
|
|
rows = {row["point"]: row for row in result["rows"]}
|
|
brahma = rows["Brahma"]
|
|
assert (brahma["planet"], brahma["compare"]["planet"], brahma["differs"]) == ("Mercury", "Jupiter", True)
|
|
assert (brahma["sign"], brahma["house"]) == ("Aries", 9)
|
|
strength = result["lagna_seventh_strength"]
|
|
assert (strength["include_nodes_text"]["lagna"], strength["include_nodes_text"]["seventh"]) == (337.5, 352.5)
|
|
assert (strength["exclude_nodes_worked_example"]["lagna"], strength["exclude_nodes_worked_example"]["seventh"]) == (322.5, 292.5)
|
|
assert rows["Maheshwara"]["differs"] is False and rows["Rudra"]["differs"] is False
|
|
|
|
|
|
def test_goel_points_block_on_a_karaka_tie_and_say_which():
|
|
planets = {name: {"lon": lon} for name, lon in GOEL_SPLIT.items()}
|
|
planets["Moon"] = {"lon": 120 + GOEL_SPLIT["Sun"] % 30}
|
|
result = compute_chart_points({**BODY, "planets": planets, "ascendant": {"lon": 135.0}})["goel_points"]
|
|
assert result["status"] == "blocked"
|
|
assert result["karaka_ties"][0]["planets"] == ["Sun", "Moon"]
|
|
|
|
|
|
def test_one_failing_block_does_not_fail_the_response(monkeypatch):
|
|
import gulika
|
|
|
|
def broken(*_args, **_kwargs):
|
|
raise RuntimeError("boom")
|
|
|
|
monkeypatch.setattr(gulika, "calculate_upagrahas", broken)
|
|
result = compute_chart_points(BODY)
|
|
assert result["upagrahas"]["status"] == "blocked"
|
|
assert result["arudha_padas"]["status"] == "computed"
|
|
|
|
|
|
def test_missing_birth_or_chart_is_a_bad_request():
|
|
with pytest.raises(ChartPointsInputError):
|
|
compute_chart_points({**BODY, "planets": None})
|
|
with pytest.raises(ChartPointsInputError):
|
|
compute_chart_points({key: value for key, value in BODY.items() if key != "lat"})
|
|
|
|
|
|
def test_route_is_registered_thinly():
|
|
source = (SCRIPTS / "jyotish_api_server.py").read_text(encoding="utf-8")
|
|
assert "elif path == '/api/chart_points':" in source
|
|
assert "_load_local_module('chart_view_points.api')" in source
|