Files
Jyotisha/tests/test_annual_package.py
T
ClaudeandClaude Opus 5.5 32fa756bd4 refactor(annual): move the annual package and report facts into annual_service
Staging's backend gate (run 6338) failed three budgets: handler method
count 218 > 217, JyotishAPIHandler.__new__ sites 6 > 5, and the scripts/
root .py count. The package and facts code now live in
scripts/annual_service/ (package.py, report_facts.py, api.py with
compute_annual_package / compute_annual_report_facts and
AnnualServiceInputError). jyotish_api_server.py registers /api/annual
mode=package, /api/annual/package and /api/annual/report_facts thinly
through a module-level _annual_service helper, like /api/chart_points,
with no handler methods: back to 217. Tests call the module functions
and keep a thin-registration test; no cap or baseline changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-01 20:33:17 +08:00

454 lines
22 KiB
Python

"""Annual data package v1 (#152 ruling 2026-11-01; phase 1 of #153 / #154).
Birth data: the chart-insight golden profile (1990-06-15 12:00 Beijing,
Raman ayanamsa, mean node). Not a real person.
"""
from __future__ import annotations
import functools
import json
import os
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
from annual_service import api as annual_api # noqa: E402
from annual_service import package as annual_package # noqa: E402
BadRequest = annual_api.AnnualServiceInputError
VOCAB = {"computed", "blocked", "not_applicable"}
YEAR_CHART_BLOCKS = {
"chart", "muntha", "year_lord", "annual_strength", "sahams", "tajika_yogas",
"tripataki", "mudda_dasha", "patyayini", "monthly_charts",
}
def _compute(body: dict) -> dict:
"""POST /api/annual mode=package and /api/annual/package both call this function."""
return annual_api.compute_annual_package(body)
def _body(**overrides):
body = {
"mode": "package",
"year": 1990, "month": 6, "day": 15, "hour": 12, "minute": 0, "second": 0,
"lat": 39.9042, "lon": 116.4074, "tz": 8, "timezone_id": "Asia/Shanghai",
"ayanamsa": "raman", "node_mode": "mean",
"report_range": {"kind": "calendar_year", "year": 2026},
"observation_date": "2026-11-01",
}
body.update(overrides)
return body
@functools.cache
def _cached(key: str) -> dict:
return _compute(json.loads(key))
def _package(**overrides) -> dict:
"""Built once per distinct request; callers must not mutate the result."""
return _cached(json.dumps(_body(**overrides), sort_keys=True))["package"]
def _utc(boundary: dict) -> datetime:
return datetime.strptime(boundary["utc"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
def _assert_instant(boundary: dict) -> None:
assert set(boundary) == {"utc", "local", "utc_offset", "timezone_id", "jd_ut"}
local = datetime.fromisoformat(boundary["local"])
assert local.utcoffset() is not None
assert local.astimezone(UTC) == _utc(boundary)
# ── shape ──────────────────────────────────────────────────────────────────
def test_package_shape_and_version() -> None:
result = _cached(json.dumps(_body(), sort_keys=True))
assert result["success"] is True
assert result["mode"] == "package"
assert result["annual_package_version"] == 1
package = result["package"]
assert package["schema"] == "jyotish.annual_package.v1"
assert list(package) == [
"schema", "annual_package_version", "parameters", "report_range", "year_charts",
"natal_periods", "slow_transits", "independent_annual_views", "computation_boundaries",
"not_included", "block_index", "blocked_count",
]
json.dumps(package) # plain JSON, no datetime / circular references
for chart in package["year_charts"]:
assert chart["status"] in VOCAB
assert set(chart["blocks"]) == YEAR_CHART_BLOCKS
for name, item in chart["blocks"].items():
assert item["status"] in VOCAB, name
assert item["producer"], name
if item["status"] != "computed":
assert item["reason"], name
assert set(package["natal_periods"]) == {"vimshottari", "narayana"}
assert package["independent_annual_views"]["tithi_pravesha"]["status"] in VOCAB
assert package["block_index"], "every block is listed"
assert all(row["status"] in VOCAB for row in package["block_index"])
assert package["blocked_count"] == sum(1 for row in package["block_index"] if row["status"] == "blocked")
not_included = {row["item"] for row in package["not_included"]}
assert {"varsha_narayana", "candidate_windows", "real_case_calibration"} <= not_included
def test_no_interpretation_text_in_package() -> None:
assert '"interpretation"' not in json.dumps(_package(), ensure_ascii=False)
def test_core_tajika_blocks_are_computed_for_a_normal_chart() -> None:
chart = _package()["year_charts"][-1]
blocks = chart["blocks"]
for name in YEAR_CHART_BLOCKS:
assert blocks[name]["status"] == "computed", (name, blocks[name].get("reason"))
asc = blocks["chart"]["ascendant"]
assert blocks["chart"]["ascendant_lord"]["planet"] == annual_package.SIGN_LORDS[asc["sign"]]
muntha = blocks["muntha"]["data"]
assert muntha["muntha_lord"] == annual_package.SIGN_LORDS[muntha["muntha_sign"]]
assert muntha["muntha_lord_placement"]["planet"] == muntha["muntha_lord"]
assert 1 <= muntha["muntha_house_from_annual_ascendant"] <= 12
assert blocks["year_lord"]["data"]["year_lord"] in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
assert "not natal Shadbala" in blocks["annual_strength"]["scope"]
assert set(blocks["annual_strength"]["data"]) == {"harsha_bala", "panchavargiya_bala"}
assert blocks["mudda_dasha"]["independent_of_year_lord"] is True
def test_every_boundary_is_an_exact_instant_with_timezone() -> None:
package = _package()
_assert_instant(package["report_range"]["start"])
_assert_instant(package["report_range"]["end"])
for chart in package["year_charts"]:
_assert_instant(chart["validity"]["start"])
_assert_instant(chart["validity"]["end"])
for row in chart["blocks"]["mudda_dasha"]["periods"] + chart["blocks"]["patyayini"]["periods"]:
_assert_instant(row["start"])
_assert_instant(row["end"])
for row in chart["blocks"]["monthly_charts"]["rows"]:
_assert_instant(row["start"])
_assert_instant(row["end"])
for row in package["natal_periods"]["vimshottari"]["rows"]:
_assert_instant(row["start"])
for event in package["slow_transits"]["events"]:
_assert_instant(event["instant"])
assert package["report_range"]["start"]["local"] == "2026-01-01T00:00:00+08:00"
assert package["report_range"]["start"]["timezone_id"] == "Asia/Shanghai"
# ── parameters reach the producers ─────────────────────────────────────────
def _return_utc(package: dict, year: int) -> datetime:
chart = next(item for item in package["year_charts"] if item["target_year"] == year)
return _utc(chart["solar_return"]["instant"])
def test_birth_second_reaches_return_natal_and_dasha() -> None:
base, later = _package(), _package(second=59)
assert later["parameters"]["birth"]["second"] == 59
assert later["parameters"]["birth"]["instant"]["local"] == "1990-06-15T12:00:59+08:00"
shift = (_return_utc(later, 2026) - _return_utc(base, 2026)).total_seconds()
assert 40 <= shift <= 80 # the natal Sun moves with the birth clock
moon0 = base["parameters"]["natal_chart"]["longitudes"]["Moon"]
moon1 = later["parameters"]["natal_chart"]["longitudes"]["Moon"]
assert moon1 > moon0
pd0 = base["natal_periods"]["vimshottari"]["rows"][-1]["end"]["utc"]
pd1 = later["natal_periods"]["vimshottari"]["rows"][-1]["end"]["utc"]
assert pd0 != pd1
def test_node_mode_reaches_annual_chart_monthly_charts_and_transits() -> None:
mean, true = _package(), _package(node_mode="true")
assert true["parameters"]["node_mode"] == "true"
chart_mean = mean["year_charts"][-1]["blocks"]["chart"]
chart_true = true["year_charts"][-1]["blocks"]["chart"]
assert chart_true["node_mode_of_chart"] == "true"
assert chart_mean["planets"]["Rahu"]["longitude"] != chart_true["planets"]["Rahu"]["longitude"]
# Nodes only: the return instant and the seven planets do not move.
assert chart_mean["planets"]["Jupiter"]["longitude"] == chart_true["planets"]["Jupiter"]["longitude"]
month_mean = mean["year_charts"][-1]["blocks"]["monthly_charts"]["rows"][3]["planet_longitudes"]["Rahu"]
month_true = true["year_charts"][-1]["blocks"]["monthly_charts"]["rows"][3]["planet_longitudes"]["Rahu"]
assert month_mean != month_true
rahu_mean = mean["slow_transits"]["positions_at_range_start"]["Rahu"]["longitude"]
rahu_true = true["slow_transits"]["positions_at_range_start"]["Rahu"]["longitude"]
assert rahu_mean != rahu_true
assert true["slow_transits"]["node_mode"] == "true"
def test_ayanamsa_reaches_natal_chart() -> None:
raman, lahiri = _package(), _package(ayanamsa="lahiri")
assert lahiri["parameters"]["ayanamsa"] == "lahiri"
lagna_raman = raman["parameters"]["natal_chart"]["longitudes"]["Lagna"]
lagna_lahiri = lahiri["parameters"]["natal_chart"]["longitudes"]["Lagna"]
assert lagna_raman - lagna_lahiri == pytest.approx(1.45, abs=0.2)
def test_return_location_keeps_the_field_and_offers_birth_place_only() -> None:
location = _package()["parameters"]["return_location"]
assert location["mode"] == "birth_place"
assert location["options_v1"] == ["birth_place"]
with pytest.raises(BadRequest):
_compute(_body(return_location={"mode": "current_location"}))
# ── year chart validity ────────────────────────────────────────────────────
def test_year_chart_validity_runs_from_this_return_to_the_next() -> None:
charts = _package()["year_charts"]
assert [item["target_year"] for item in charts] == [2025, 2026]
for chart in charts:
validity = chart["validity"]
assert validity["start"] == chart["solar_return"]["instant"]
assert validity["end_source"] == "next_solar_return"
assert validity["end_exclusive"] is True
days = (_utc(validity["end"]) - _utc(validity["start"])).total_seconds() / 86400
assert 365.2 < days < 365.3
assert validity["duration_days"] == pytest.approx(days, abs=1e-4)
rows = chart["blocks"]["monthly_charts"]["rows"]
assert len(rows) == 12
assert rows[0]["start"]["utc"] == validity["start"]["utc"]
assert rows[-1]["end"] == validity["end"]
assert rows[-1]["end_source"] == "next_solar_return"
assert all(a["end"] == b["start"] for a, b in zip(rows, rows[1:], strict=False))
assert charts[0]["validity"]["end"] == charts[1]["validity"]["start"]
assert charts[0]["blocks"]["monthly_charts"]["step"] == "solar_30_degrees"
# ── report range ───────────────────────────────────────────────────────────
def test_calendar_year_crossing_the_birthday_uses_both_year_charts_and_clips() -> None:
package = _package()
report_range = package["report_range"]
assert report_range["status"] == "computed"
assert report_range["year_chart_years"] == [2025, 2026]
assert report_range["crosses_birthday"] is True
birthday = report_range["birthday_boundaries"][0]
old, new = package["year_charts"]
assert birthday == new["validity"]["start"]
assert old["in_report_range"] == {"start": report_range["start"], "end": birthday}
assert new["in_report_range"] == {"start": birthday, "end": report_range["end"]}
range_start, range_end = _utc(report_range["start"]), _utc(report_range["end"])
for chart in (old, new):
for row in chart["blocks"]["mudda_dasha"]["periods"]:
start, end = _utc(row["start"]), _utc(row["end"])
clipped = row["in_report_range"]
if end <= range_start or start >= range_end:
assert clipped is None
else:
assert _utc(clipped["start"]) == max(start, range_start)
assert _utc(clipped["end"]) == min(end, range_end)
def test_birthday_year_is_one_chart_validity() -> None:
package = _package(report_range={"kind": "birthday_year", "year": 2026})
report_range = package["report_range"]
assert report_range["year_chart_years"] == [2026]
chart = package["year_charts"][0]
assert report_range["start"] == chart["validity"]["start"]
assert report_range["end"] == chart["validity"]["end"]
assert report_range["crosses_birthday"] is False
def test_next_twelve_months_defaults_to_the_observation_date() -> None:
package = _package(report_range={"kind": "next_12_months"})
report_range = package["report_range"]
assert report_range["requested"]["start_date"] == "2026-11-01"
assert report_range["start"]["local"] == "2026-11-01T00:00:00+08:00"
assert report_range["end"]["local"] == "2027-11-01T00:00:00+08:00"
assert report_range["year_chart_years"] == [2026, 2027]
assert report_range["observation"]["position"] == "inside_range"
assert report_range["observation"]["lookback_start"]["local"] == "2026-08-01T00:00:00+08:00"
def test_bad_range_inputs_are_bad_requests() -> None:
for bad in ({"kind": "lunar_year"}, {"kind": "calendar_year", "year": "abc"},
{"kind": "calendar_year", "start_date": "2026-01-01"}, "2026"):
with pytest.raises(BadRequest):
_compute(_body(report_range=bad))
# ── each period system keeps its own year ──────────────────────────────────
def test_patyayini_keeps_365_25_days_and_is_clipped_not_stretched() -> None:
package = _package()
range_end = _utc(package["report_range"]["end"])
chart = package["year_charts"][-1]
patyayini = chart["blocks"]["patyayini"]
year = patyayini["year_length"]
assert year["days"] == 365.25
assert year["basis"] == "fixed_365_25_days"
assert year["start"] == chart["validity"]["start"]
assert year["end"]["jd_ut"] - year["start"]["jd_ut"] == pytest.approx(365.25, abs=1e-6)
# Not stretched to the next return (the solar return year is about 365.256 days).
assert year["end"]["jd_ut"] < chart["validity"]["end"]["jd_ut"]
periods = patyayini["periods"]
assert len(periods) == 8 # Lagna + seven planets
assert periods[0]["start"] == year["start"]
assert abs((_utc(periods[-1]["end"]) - _utc(year["end"])).total_seconds()) <= 1
straddling = [row for row in periods if _utc(row["start"]) < range_end < _utc(row["end"])]
assert len(straddling) == 1
assert straddling[0]["in_report_range"]["end"] == package["report_range"]["end"]
assert all(row["in_report_range"] is None for row in periods if _utc(row["start"]) >= range_end)
assert len(patyayini["inputs"]) == 8
assert {row["point"] for row in patyayini["inputs"]} == {"Lagna", "Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"}
def test_mudda_keeps_its_own_year_from_the_return() -> None:
chart = _package()["year_charts"][-1]
mudda = chart["blocks"]["mudda_dasha"]
assert mudda["year_length"]["days"] == 365.25
assert mudda["year_length"]["start"] == chart["validity"]["start"]
assert mudda["year_length"]["end"]["jd_ut"] - mudda["year_length"]["start"]["jd_ut"] == pytest.approx(365.25, abs=1e-6)
assert mudda["periods"][0]["start"] == chart["validity"]["start"]
assert mudda["starting_lord"] == mudda["periods"][0]["lord"]
def test_natal_periods_overlap_the_range() -> None:
package = _package()
range_start, range_end = _utc(package["report_range"]["start"]), _utc(package["report_range"]["end"])
vim = package["natal_periods"]["vimshottari"]
assert vim["status"] == "computed"
assert vim["year_length"]["days"] == 365.25
assert {row["level"] for row in vim["rows"]} == {"MD", "AD", "PD"}
for row in vim["rows"]:
assert _utc(row["start"]) < range_end and _utc(row["end"]) > range_start
pds = [row for row in vim["rows"] if row["level"] == "PD"]
assert _utc(pds[0]["start"]) <= range_start and _utc(pds[-1]["end"]) >= range_end
narayana = package["natal_periods"]["narayana"]
assert narayana["age_axis"]["year_days"] == 365.25
assert narayana["legacy"]["used_by_product_timeline"] is True
assert narayana["rath"]["used_by_product_timeline"] is False
for profile in ("legacy", "rath"):
if narayana[profile]["status"] == "computed":
assert {row["level"] for row in narayana[profile]["rows"]} == {"MD", "AD"}
def test_slow_transits_events_are_exact_and_inside_the_range() -> None:
package = _package()
transits = package["slow_transits"]
assert transits["status"] == "computed"
assert transits["bodies"] == ["Jupiter", "Saturn", "Rahu", "Ketu"]
range_start, range_end = _utc(package["report_range"]["start"]), _utc(package["report_range"]["end"])
jds = [event["instant"]["jd_ut"] for event in transits["events"]]
assert jds == sorted(jds)
for event in transits["events"]:
assert range_start <= _utc(event["instant"]) < range_end
assert event["kind"] in ("ingress", "station")
if event["kind"] == "ingress":
assert event["from_sign"] != event["to_sign"]
assert 1 <= event["to_house_from_lagna"] <= 12
assert 1 <= event["to_house_from_moon"] <= 12
for loop in transits["retrograde_loops"]:
assert loop["body"] in ("Jupiter", "Saturn")
assert _utc(loop["station_retrograde"]["instant"]) < _utc(loop["station_direct"]["instant"])
for contact in transits["natal_point_contacts"]:
assert contact["count"] == len(contact["crossings"])
# ── blocked blocks do not fail the response ────────────────────────────────
def _fresh(**overrides) -> dict:
return _compute(_body(**overrides))
def test_blocked_patyayini_and_rath_do_not_fail_the_response(monkeypatch) -> None:
import narayana_rath
from annual_native import patyayini
def broken(**_kwargs):
raise ValueError("forced failure")
def broken_rath(*_args, **_kwargs):
raise RuntimeError("forced rath failure")
monkeypatch.setattr(patyayini, "build_native_patyayini", broken)
monkeypatch.setattr(narayana_rath, "calc_narayana_rath", broken_rath)
result = _fresh(report_range={"kind": "birthday_year", "year": 2026})
assert result["success"] is True
package = result["package"]
blocks = package["year_charts"][0]["blocks"]
assert blocks["patyayini"]["status"] == "blocked"
assert "forced failure" in blocks["patyayini"]["reason"]
assert blocks["mudda_dasha"]["status"] == "computed"
assert package["natal_periods"]["narayana"]["rath"]["status"] == "blocked"
assert package["natal_periods"]["narayana"]["legacy"]["status"] == "computed"
paths = {row["path"] for row in package["block_index"] if row["status"] == "blocked"}
assert "year_charts[0].blocks.patyayini" in paths
assert package["blocked_count"] >= 2
def test_blocked_slow_transits_do_not_fail_the_response(monkeypatch) -> None:
import jyotish_engine
def broken(*_args, **_kwargs):
raise RuntimeError("ephemeris unavailable")
monkeypatch.setattr(jyotish_engine, "_calc_sidereal_planets_for_jd", broken)
result = _fresh(report_range={"kind": "birthday_year", "year": 2026})
assert result["success"] is True
transits = result["package"]["slow_transits"]
assert transits["status"] == "blocked"
assert "ephemeris unavailable" in transits["reason"]
assert result["package"]["year_charts"][0]["status"] == "computed"
# ── thin registration; the default /api/annual shape is covered by test_api_server_security ──
def test_route_is_registered_thinly() -> None:
source = (Path(SCRIPTS) / "jyotish_api_server.py").read_text(encoding="utf-8")
assert "elif path == '/api/annual/package':" in source
assert "elif path == '/api/annual/report_facts':" in source
assert "_load_local_module('annual_service.api')" in source
assert "_annual_service('compute_annual_package', body)" in source
assert "def _compute_annual_package" not in source
assert "def _compute_annual_report_facts" not in source
def test_solar_return_node_mode_default_stays_mean() -> None:
import solar_return
args = (1990, 6, 15, 12, 0, 39.9042, 116.4074, 8.0, 2026)
default = solar_return.calc_solar_return_chart(*args, ayanamsa_name="raman")
true = solar_return.calc_solar_return_chart(*args, ayanamsa_name="raman", node_mode="true")
assert default["node_mode"] == "mean"
assert true["node_mode"] == "true"
assert default["planet_lons"]["Rahu"] != true["planet_lons"]["Rahu"]
assert default["solar_return"]["jd_ut"] == true["solar_return"]["jd_ut"]
assert default["planet_lons"]["Saturn"] == true["planet_lons"]["Saturn"]
# ── pure helpers ───────────────────────────────────────────────────────────
def test_instant_uses_the_zone_offset_at_that_moment() -> None:
zone = annual_package._zone("America/New_York", -5)
summer = annual_package.instant(datetime(2026, 7, 1, 12, tzinfo=UTC), zone)
winter = annual_package.instant(datetime(2026, 1, 1, 12, tzinfo=UTC), zone)
assert summer["utc_offset"] == "-04:00"
assert winter["utc_offset"] == "-05:00"
fixed = annual_package.instant(datetime(2026, 7, 1, 12, tzinfo=UTC), annual_package._zone(None, 5.5))
assert fixed["local"] == "2026-07-01T17:30:00+05:30"
assert fixed["timezone_id"] is None
def test_elapsed_mapper_counts_days_across_daylight_saving() -> None:
anchor_utc = datetime(2026, 3, 1, 12, tzinfo=UTC)
convert = annual_package._elapsed_mapper(datetime(2026, 3, 1, 7), anchor_utc)
assert convert("2026-04-01 07:00:00") == anchor_utc + timedelta(days=31)
assert convert(None) is None
def test_add_months_clamps_to_month_end() -> None:
from datetime import date
assert annual_package._add_months(date(2028, 2, 29), 12) == date(2029, 2, 28)
assert annual_package._add_months(date(2026, 11, 1), -3) == date(2026, 8, 1)
assert annual_package._add_months(date(2026, 1, 31), 1) == date(2026, 2, 28)