当年和后两年写出月返照、原生 Patyayini 和 Tithi Pravesha。已有年主、Muntha、Mudda、Sahams、Tajika 不改写。出生地八年总览会算,72 年生命周期年返先不做。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-23 10:17:13 +08:00
co-authored by Grok (agent loop)
parent 9b2e6aa0b4
commit 4c405d2a51
11 changed files with 1045 additions and 27 deletions
+10
View File
@@ -1,5 +1,15 @@
# 印度占星 Skill 更新日志
## 2026-10-23 — 完整数据版补上一年里的月返照和 Tithi
- 当年和后两年各自列出月返照、原生 Patyayini 和 Tithi Pravesha。已有的年主、Muntha、Mudda、Sahams、Tajika 数值不改写。出生地八年总览会算;72 年的生命周期年返这一版先不做,避免一次生成几十张年返盘。
- Skill 版本不变。
## 2026-10-22 — 完整数据版标准大运表补上已有规则的子运
- Narayana、Ashtottari、Yogini,以及已经按出处拆好子运的体系,标准大运表会列出子运。没有出处规则的体系写「本体系未提供子运」。Kalachakra 的子运和次小运标「简化模型」,主运仍是原来的星座年数。
- Skill 版本不变。
## 2026-10-22 — 完整数据版写出 Jaimini Yogi 与 Ava Yogi 坐标
- 这一节以前因为找不到计算模块,每张盘都是 blocked。现在用本命太阳、月亮黄经按 93°20′ 和 186°40′ 算出两点,中英附录各有一张表。空的 Tajika 候选改成一句「本年没有候选」;Goel 星座强度里没有落入行星时写「无」。
+367
View File
@@ -0,0 +1,367 @@
"""Three-year annual pages on top of an already built Tajika pack.
The report year's Muntha, year lord, Sahams and Tajika objects are left as
they were. Extra years call the same solar-return report. New longitudes
come from that report's chart.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
try:
from annual_native.monthly_charts import build_monthly_charts
from annual_native.patyayini import build_native_patyayini
from annual_native.tithi_pravesh import build_tithi_pravesh
except ImportError: # pragma: no cover
from scripts.annual_native.monthly_charts import build_monthly_charts
from scripts.annual_native.patyayini import build_native_patyayini
from scripts.annual_native.tithi_pravesh import build_tithi_pravesh
def attach_annual_expansion(pack: dict, payload: dict, args: Any, *, include_overview: bool = False) -> dict:
"""Add one year's monthly returns, Patyayini and Tithi Pravesha.
Later years are separate annual packs. This does not recompute the year
lord, Muntha, Mudda, Sahams or Tajika already stored on ``pack``.
The 72-year lifetime table is not built here: one report would calculate
dozens of extra charts and can miss the 180 second wait.
"""
if not isinstance(pack, dict):
raise TypeError("annual pack must be a dict")
year = int(payload["target_year"])
place = _place(payload, args)
solar = _data(pack.get("solar_return"))
houses = pack.get("annual_houses") if isinstance(pack.get("annual_houses"), dict) else {}
mudda = _data(pack.get("mudda_dasha"))
muntha = _data(pack.get("muntha"))
year_lord = _data(pack.get("year_lord"))
sahams = _data(pack.get("sahams"))
jd = solar.get("jd_ut")
if not isinstance(jd, (int, float)) or isinstance(jd, bool):
raise ValueError(f"solar return jd_ut missing for {year}")
longitudes = houses.get("planet_longitudes") if isinstance(houses.get("planet_longitudes"), dict) else {}
annual_sun = longitudes.get("Sun")
if not isinstance(annual_sun, (int, float)) or isinstance(annual_sun, bool):
raise ValueError(f"annual Sun longitude missing for {year}")
asc = houses.get("ascendant_longitude")
if not isinstance(asc, (int, float)) or isinstance(asc, bool):
raise ValueError(f"annual ascendant longitude missing for {year}")
start = _parse_dt(solar.get("dt_local"))
if start is None:
raise ValueError(f"solar return local time missing for {year}")
monthly = build_monthly_charts(
annual_jd=float(jd),
birth_sun_longitude=float(annual_sun),
latitude=place["latitude"],
longitude=place["longitude"],
tz_hours=place["tz"],
ayanamsa=place["ayanamsa"],
node_mode=place["node_mode"],
target_year=year,
timezone_id=place["timezone_id"],
)
monthly_rows = monthly["rows"]
patyayini = build_native_patyayini(
ascendant_longitude=float(asc),
planet_longitudes=longitudes,
start=start,
year_days=place["year_days"],
)
patyayini_rows = [{**row, "target_year": year} for row in patyayini["antardasha_periods"]]
tithi = build_tithi_pravesh({**payload, "target_year": year})
mudda_rows = []
dasha_rows = []
for period in _periods(mudda):
mudda_rows.append({
"target_year": year,
"balance_profile": "Mudda",
"lord": period.get("lord"),
"start": period.get("start") or period.get("start_date"),
"end": period.get("end") or period.get("end_date"),
"boundary_status": "dasha_ending_dates",
})
dasha_rows.append({
"order": period.get("order") or len(dasha_rows) + 1,
"lord": period.get("lord"),
"annual_whole_sign_house": _house(longitudes.get(str(period.get("lord"))), asc),
"start": period.get("start") or period.get("start_date"),
"end": period.get("end") or period.get("end_date"),
"themes": {
"zh": "可能突出这颗星所主的事务",
"en": "This lord may stand out",
},
})
pack["monthly_chart_pack"] = {
"schema": "jyotish.tajika_monthly_chart_pack.v1",
"status": "partial_verified" if len(monthly_rows) == 12 else "blocked",
"reason": None if len(monthly_rows) == 12 else f"expected 12 monthly rows, got {len(monthly_rows)}",
"rows": monthly_rows,
}
pack["tithi_pravesh_years"] = [{
"target_year": year,
"status": tithi.get("status"),
"reason": tithi.get("reason"),
"natal_phase_degrees": tithi.get("natal_phase_degrees"),
"rows": tithi.get("rows") or [],
}]
pack["native_patyayini"] = {"status": "parameter_sensitive", "model": "degree_within_sign", "rows": patyayini_rows}
normalized = pack.get("normalized_tables")
if not isinstance(normalized, dict):
normalized = {}
pack["normalized_tables"] = normalized
if patyayini_rows and not normalized.get("patyayini_rows"):
normalized["patyayini_rows"] = patyayini_rows
# The primary year still lets the language-specific projector fill Mudda,
# including antardasha rows. Later years are not projected.
if mudda_rows and not include_overview and not normalized.get("mudda_rows"):
normalized["mudda_rows"] = mudda_rows
if dasha_rows and not pack.get("pl9_annual_dasha_results"):
pack["pl9_annual_dasha_results"] = {"status": "partial_verified", "rows": dasha_rows}
pack["annual_year_pages"] = [{
"target_year": year,
"muntha_sign": muntha.get("muntha_sign") or _nested(muntha, "muntha_sign"),
"year_lord": year_lord.get("year_lord") or year_lord.get("lord") or _nested(year_lord, "year_lord"),
"ascendant_longitude": asc,
"planet_longitudes": {
name: longitudes.get(name)
for name in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
if isinstance(longitudes.get(name), (int, float)) and not isinstance(longitudes.get(name), bool)
},
"saham_count": sum(
1 for item in sahams.values()
if isinstance(item, dict) and isinstance(item.get("longitude"), (int, float)) and not isinstance(item.get("longitude"), bool)
),
"monthly_count": len(monthly_rows),
"tithi_count": len(tithi.get("rows") or []),
"local_datetime": start.strftime("%Y-%m-%d %H:%M:%S"),
}]
if include_overview:
try:
pack["annual_chart_series_pack"] = _eight_year(args, payload, place, year)
except Exception as exc:
import logging
logging.getLogger(__name__).warning("eight-year overview failed: %s", exc)
pack["annual_chart_series_pack"] = {
"status": "blocked",
"reason": f"{exc.__class__.__name__}: {exc}",
}
return pack
def _birth_place(args, place) -> dict:
located = dict(place)
located["latitude"] = float(args.lat)
located["longitude"] = float(args.lon)
located["tz"] = float(args.tz)
return located
def _lifetime(args, place) -> dict:
located = _birth_place(args, place)
birth_sun = _natal_sun(args, located)
rows = []
for age in range(72):
row = _return_row(args, located, birth_sun, int(args.year) + age)
row["completed_age"] = age
row["local_datetime"] = row.get("solar_return_local")
rows.append(row)
return {"status": "partial_verified", "pages": [{"rows": rows}]}
def _eight_year(args, payload, place, base_year: int) -> dict:
"""Birthplace overview for eight years. Current residence only when supplied."""
located = _birth_place(args, place)
birth_sun = _natal_sun(args, located)
rows = [_return_row(args, located, birth_sun, base_year + offset) for offset in range(8)]
series = {
"birth_place_eight_year_overview": {
"status": "partial_verified",
"rows": rows,
}
}
current = (payload.get("settings") or {}).get("current_location")
if isinstance(current, dict) and current.get("latitude") is not None and current.get("longitude") is not None:
local_place = dict(place)
local_place["latitude"] = float(current["latitude"])
local_place["longitude"] = float(current["longitude"])
if current.get("utc_offset") is not None:
local_place["tz"] = _offset_hours(current.get("utc_offset"))
series["local_eight_year_overview"] = {
"status": "partial_verified",
"rows": [_return_row(args, local_place, birth_sun, base_year + offset) for offset in range(8)],
}
return series
def _return_row(args, place, birth_sun: float, year: int) -> dict:
try:
from solar_return import _datetime_to_jd_ut, _localize_return_datetime
from jyotish_engine import compute_chart_data
from annual_native.sidereal_bodies import sun_moon_longitudes
except ImportError:
from scripts.solar_return import _datetime_to_jd_ut, _localize_return_datetime
from scripts.jyotish_engine import compute_chart_data
from scripts.annual_native.sidereal_bodies import sun_moon_longitudes
guess = _datetime_to_jd_ut(datetime(int(year), int(args.month), int(args.day), int(args.hour), int(args.minute)))
jd = guess
for _ in range(24):
sun = sun_moon_longitudes(jd, node_mode=place["node_mode"], ayanamsa_name=place["ayanamsa"])[0]
difference = ((sun - birth_sun + 180.0) % 360.0) - 180.0
if abs(difference) < 0.0001:
break
future = sun_moon_longitudes(jd + 1.0 / 24.0, node_mode=place["node_mode"], ayanamsa_name=place["ayanamsa"])[0]
speed = (((future - sun + 180.0) % 360.0) - 180.0) * 24.0
jd -= difference / speed if abs(speed) > 0.01 else difference
utc = datetime(2000, 1, 1) + timedelta(days=jd - 2451544.5)
local = _localize_return_datetime(utc, place["tz"], place["timezone_id"])
chart, *_rest = compute_chart_data(
utc.year, utc.month, utc.day, utc.hour, utc.minute,
place["latitude"], place["longitude"], 0,
place["node_mode"], second=utc.second, ayanamsa_name=place["ayanamsa"],
)
if not isinstance(chart, dict):
raise RuntimeError(f"compute_chart_data returned no chart for return year {year}")
asc = chart.get("ascendant") if isinstance(chart.get("ascendant"), dict) else {}
planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {}
return {
"target_year": year,
"age": int(year) - int(args.year),
"solar_return_local": local.strftime("%Y-%m-%d %H:%M:%S"),
"annual_ascendant": asc.get("sign"),
"ascendant": {
"sign": asc.get("sign"),
"degree_in_sign": asc.get("degree_in_sign", asc.get("degree")),
},
"planet_longitudes": {
name: data.get("degree_raw")
for name, data in planets.items()
if isinstance(data, dict) and isinstance(data.get("degree_raw"), (int, float)) and not isinstance(data.get("degree_raw"), bool)
},
}
def _natal_sun(args, place) -> float:
try:
from jyotish_engine import compute_chart_data
except ImportError:
from scripts.jyotish_engine import compute_chart_data
chart, *_rest = compute_chart_data(
int(args.year), int(args.month), int(args.day), int(args.hour), int(args.minute),
float(args.lat), float(args.lon), float(args.tz),
place["node_mode"], second=int(getattr(args, "second", 0) or 0), ayanamsa_name=place["ayanamsa"],
)
return float(chart["planets"]["Sun"]["degree_raw"]) % 360.0
def _solar_report(args, year: int) -> dict:
try:
from solar_return import solar_return_full_report
from jyotish_engine import _arg_second, _current_ayanamsa_name, _solar_return_location_kwargs
except ImportError:
from scripts.solar_return import solar_return_full_report
from scripts.jyotish_engine import _arg_second, _current_ayanamsa_name, _solar_return_location_kwargs
report = solar_return_full_report(
args.year, args.month, args.day, args.hour, args.minute,
args.lat, args.lon, args.tz, year,
ayanamsa_name=_current_ayanamsa_name(args),
birth_second=_arg_second(args),
**_solar_return_location_kwargs(args),
)
if not isinstance(report, dict) or report.get("error"):
raise RuntimeError(f"solar return {year} failed: {report.get('error') if isinstance(report, dict) else type(report).__name__}")
return report
def _place(payload: dict, args: Any) -> dict:
settings = payload.get("settings") if isinstance(payload.get("settings"), dict) else {}
birth = payload.get("birth") if isinstance(payload.get("birth"), dict) else {}
latitude = float(getattr(args, "lat", birth.get("latitude")))
longitude = float(getattr(args, "lon", birth.get("longitude")))
tz = float(getattr(args, "tz", 0.0) or 0.0)
if settings.get("solar_return_location_mode") == "current_location":
current = settings.get("current_location") if isinstance(settings.get("current_location"), dict) else {}
latitude = float(current["latitude"])
longitude = float(current["longitude"])
tz = _offset_hours(current.get("utc_offset"))
try:
year_days = float(settings.get("dasha_year_days") or 365.25)
except (TypeError, ValueError) as exc:
raise ValueError(f"dasha_year_days is not a number: {settings.get('dasha_year_days')}") from exc
return {
"latitude": latitude,
"longitude": longitude,
"tz": tz,
"ayanamsa": str(settings.get("ayanamsa") or "raman"),
"node_mode": str(settings.get("node_mode") or "mean"),
"timezone_id": birth.get("timezone_id") if isinstance(birth.get("timezone_id"), str) else None,
"year_days": year_days,
}
def _data(field: Any) -> dict:
if isinstance(field, dict) and isinstance(field.get("data"), dict):
return field["data"]
return field if isinstance(field, dict) else {}
def _periods(mudda: dict) -> list:
periods = mudda.get("periods")
if isinstance(periods, list) and periods:
return [row for row in periods if isinstance(row, dict)]
major = mudda.get("major")
if isinstance(major, list):
return [row for row in major if isinstance(row, dict)]
return []
def _nested(payload: dict, key: str):
data = payload.get("data") if isinstance(payload.get("data"), dict) else {}
return data.get(key)
def _house(longitude, ascendant) -> int | None:
if not isinstance(longitude, (int, float)) or isinstance(longitude, bool):
return None
if not isinstance(ascendant, (int, float)) or isinstance(ascendant, bool):
return None
return ((int(float(longitude) // 30) - int(float(ascendant) // 30)) % 12) + 1
def _saham_rows(sahams: dict) -> list:
rows = []
for key, item in sahams.items():
if key == "formula_traces" or not isinstance(item, dict):
continue
if not isinstance(item.get("longitude"), (int, float)) or isinstance(item.get("longitude"), bool):
continue
rows.append({
"name": item.get("name") or key,
"sign": item.get("sign"),
"degree_in_sign": item.get("degree_in_sign"),
})
return rows
def _parse_dt(value: Any) -> datetime | None:
if isinstance(value, datetime):
return value.replace(tzinfo=None)
if not isinstance(value, str) or not value.strip():
return None
text = value.strip().replace("T", " ")
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
try:
return datetime.strptime(text[:19], fmt)
except ValueError:
continue
return None
def _offset_hours(value: Any) -> float:
if isinstance(value, (int, float)) and not isinstance(value, bool):
return float(value)
text = str(value or "+00:00").strip()
sign = -1 if text.startswith("-") else 1
hours, minutes = [int(item) for item in text.lstrip("+-").split(":")[:2]]
return sign * (hours + minutes / 60.0)
+8 -2
View File
@@ -41,6 +41,7 @@ def build_annual_whole_sign_houses(chart: dict | None) -> dict[str, Any]:
asc_index = int(asc_lon // 30.0) % 12
placed: dict[int, list[str]] = {index: [] for index in range(12)}
source_longitudes = {}
planet_longitudes = {}
for name in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"):
data = planets.get(name)
if not isinstance(data, dict):
@@ -50,8 +51,11 @@ def build_annual_whole_sign_houses(chart: dict | None) -> dict[str, Any]:
longitude = _longitude(data.get("degree"))
if longitude is None:
continue
if name in ("Sun", "Moon") and data.get("degree_raw") is not None and not isinstance(data.get("degree_raw"), bool):
source_longitudes[name] = float(data["degree_raw"]) % 360.0
if data.get("degree_raw") is not None and not isinstance(data.get("degree_raw"), bool):
raw = float(data["degree_raw"]) % 360.0
planet_longitudes[name] = raw
if name in ("Sun", "Moon"):
source_longitudes[name] = raw
placed[int(longitude // 30.0) % 12].append(name)
houses = []
for house in range(1, 13):
@@ -69,5 +73,7 @@ def build_annual_whole_sign_houses(chart: dict | None) -> dict[str, Any]:
"status": "partial_verified",
"house_system": "whole_sign",
"houses": houses,
"ascendant_longitude": asc_lon,
"planet_longitudes": planet_longitudes,
"source_longitudes": source_longitudes,
}
+125
View File
@@ -0,0 +1,125 @@
"""Twelve monthly charts measured from an annual solar-return instant.
The Sun search uses this repository's sidereal Sun. Each row's planets come
from ``compute_chart_data``. No apparent-position flag is passed.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
try:
from annual_native.sidereal_bodies import sun_moon_longitudes
except ImportError: # pragma: no cover - repo root is on sys.path
from scripts.annual_native.sidereal_bodies import sun_moon_longitudes
SCHEMA = "jyotish.tajika_monthly_chart_pack.v1"
TROPICAL_YEAR_DAYS = 365.24219
_PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
def build_monthly_charts(
*,
annual_jd: float,
birth_sun_longitude: float,
latitude: float,
longitude: float,
tz_hours: float,
ayanamsa: str,
node_mode: str,
target_year: int,
timezone_id: str | None = None,
) -> dict[str, Any]:
rows = []
for month_index in range(1, 13):
target_sun = (float(birth_sun_longitude) + 30.0 * (month_index - 1)) % 360.0
guess = float(annual_jd) + TROPICAL_YEAR_DAYS * (month_index - 1) / 12.0
monthly_jd = _find_sun(guess, target_sun, ayanamsa, node_mode)
rows.append(_chart_row(
month_index, monthly_jd, target_sun, target_year,
latitude, longitude, tz_hours, ayanamsa, node_mode, timezone_id,
))
ready = [row for row in rows if row.get("status") == "partial_verified"]
return {
"schema": SCHEMA,
"status": "partial_verified" if len(ready) == 12 else "blocked",
"reason": None if len(ready) == 12 else "monthly_root_incomplete",
"target_year": target_year,
"rows": rows,
}
def _find_sun(guess: float, target: float, ayanamsa: str, node_mode: str) -> float | None:
jd = guess
for _ in range(24):
longitude = sun_moon_longitudes(jd, node_mode=node_mode, ayanamsa_name=ayanamsa)[0]
difference = ((longitude - target + 180.0) % 360.0) - 180.0
if abs(difference) < 0.0001:
return jd
future = sun_moon_longitudes(jd + 1.0 / 24.0, node_mode=node_mode, ayanamsa_name=ayanamsa)[0]
speed = (((future - longitude + 180.0) % 360.0) - 180.0) * 24.0
jd -= difference / speed if abs(speed) > 0.01 else difference
final = sun_moon_longitudes(jd, node_mode=node_mode, ayanamsa_name=ayanamsa)[0]
if abs(((final - target + 180.0) % 360.0) - 180.0) < 0.001:
return jd
return None
def _chart_row(
month_index: int,
jd: float | None,
target_sun: float,
target_year: int,
latitude: float,
longitude: float,
tz_hours: float,
ayanamsa: str,
node_mode: str,
timezone_id: str | None,
) -> dict[str, Any]:
if jd is None:
return {
"month_index": month_index,
"target_year": target_year,
"status": "blocked",
"reason": "solar_longitude_root_failed",
}
try:
from solar_return import _localize_return_datetime
from jyotish_engine import compute_chart_data
except ImportError:
from scripts.solar_return import _localize_return_datetime
from scripts.jyotish_engine import compute_chart_data
utc = datetime(2000, 1, 1) + timedelta(days=jd - 2451544.5)
local = _localize_return_datetime(utc, tz_hours, timezone_id)
chart, _asc, _chart_jd, _ayan = compute_chart_data(
utc.year, utc.month, utc.day, utc.hour, utc.minute,
float(latitude), float(longitude), 0,
node_mode, second=utc.second, ayanamsa_name=ayanamsa,
)
if not isinstance(chart, dict):
raise RuntimeError(f"compute_chart_data returned no chart for monthly jd {jd}")
planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {}
sun = planets.get("Sun") if isinstance(planets.get("Sun"), dict) else {}
ascendant = chart.get("ascendant") if isinstance(chart.get("ascendant"), dict) else {}
return {
"month_index": month_index,
"target_year": target_year,
"jd_ut": jd,
"local_datetime": local.strftime("%Y-%m-%d %H:%M:%S"),
"utc_datetime": utc.strftime("%Y-%m-%d %H:%M:%S"),
"target_sun_longitude": target_sun,
"sun_longitude": sun.get("degree_raw"),
"ascendant": {
"sign": ascendant.get("sign"),
"degree_in_sign": ascendant.get("degree_in_sign", ascendant.get("degree")),
"longitude": ascendant.get("degree_raw", ascendant.get("lon")),
},
"planet_longitudes": {
name: planets[name].get("degree_raw")
for name in _PLANETS
if isinstance(planets.get(name), dict) and planets[name].get("degree_raw") is not None
},
"status": "partial_verified",
}
+96
View File
@@ -0,0 +1,96 @@
"""Native Patyayini from an annual Lagna and the seven classical planets.
Durations follow the degree-within-sign arcs. The longitudes passed in are
not recomputed here.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
SIGNS = (
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
)
_TIE = ("Jupiter", "Moon", "Mercury", "Saturn", "Lagna", "Sun", "Mars", "Venus")
def build_native_patyayini(
*,
ascendant_longitude: float,
planet_longitudes: dict[str, float],
start: datetime,
year_days: float = 365.25,
) -> dict[str, Any]:
points = {"Lagna": float(ascendant_longitude) % 360.0}
missing = []
for lord in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"):
value = planet_longitudes.get(lord)
if isinstance(value, (int, float)) and not isinstance(value, bool):
points[lord] = float(value) % 360.0
else:
missing.append(lord)
if missing:
raise ValueError("patyayini missing longitudes: " + ", ".join(missing))
ordered = sorted(
(
{
"lord": lord,
"longitude": longitude,
"sign": SIGNS[int(longitude // 30) % 12],
"degree_in_sign": longitude % 30.0,
}
for lord, longitude in points.items()
),
key=lambda item: (item["degree_in_sign"], _TIE.index(item["lord"])),
)
denominator = ordered[-1]["degree_in_sign"]
if denominator <= 0.0:
raise ValueError("patyayini denominator is zero")
arcs = []
previous = 0.0
for item in ordered:
arc = max(0.0, float(item["degree_in_sign"]) - previous)
arcs.append(arc)
previous = float(item["degree_in_sign"])
periods = []
antardasha = []
cursor = start
for index, (item, arc) in enumerate(zip(ordered, arcs), start=1):
end = cursor + timedelta(days=float(year_days) * arc / denominator)
periods.append({
**item,
"order": index,
"start": _format(cursor),
"end": _format(end),
})
sub_cursor = cursor
cycle = ordered[index - 1:] + ordered[:index - 1]
cycle_arcs = arcs[index - 1:] + arcs[:index - 1]
main_seconds = (end - cursor).total_seconds()
for sub_index, (sub_item, sub_arc) in enumerate(zip(cycle, cycle_arcs), start=1):
sub_end = sub_cursor + timedelta(seconds=main_seconds * sub_arc / denominator)
antardasha.append({
"main": item["lord"],
"sub": sub_item["lord"],
"main_order": index,
"sub_order": sub_index,
"start": _format(sub_cursor),
"end": _format(sub_end),
"boundary": _format(sub_end),
"boundary_status": "dasha_ending_dates",
})
sub_cursor = sub_end
cursor = end
return {
"schema": "jyotish.patyayini_patyamsha_dasha.v1",
"status": "parameter_sensitive",
"model": "degree_within_sign",
"periods": periods,
"antardasha_periods": antardasha,
}
def _format(value: datetime) -> str:
return value.strftime("%Y-%m-%d %H:%M:%S")
+30
View File
@@ -0,0 +1,30 @@
"""Sun and Moon on the same sidereal frame as the chart function.
The flags match ``_calc_sidereal_planets_for_jd``: legacy true-precession
profile, then subtract ayanamsa. This is not the apparent-position path.
"""
from __future__ import annotations
def sun_moon_longitudes(jd: float, *, node_mode: str = "mean", ayanamsa_name: str | None = None) -> tuple[float, float]:
try:
from jyotish_engine import _apply_ayanamsa, _sidereal_calculation_profile
import swisseph as swe
except ImportError:
from scripts.jyotish_engine import _apply_ayanamsa, _sidereal_calculation_profile
import swisseph as swe
if ayanamsa_name:
_apply_ayanamsa(ayanamsa_name)
_mode, calc_flags, ayanamsa = _sidereal_calculation_profile(jd, "legacy")
flags = calc_flags | getattr(swe, "FLG_SPEED", 256)
sun = _body(swe, jd, swe.SUN, flags, ayanamsa)
moon = _body(swe, jd, swe.MOON, flags, ayanamsa)
return sun, moon
def _body(swe, jd: float, body: int, flags: int, ayanamsa: float) -> float:
pos, ret = swe.calc_ut(jd, body, flags)
if ret < 0:
raise RuntimeError(f"swisseph calc_ut failed for body {body} at jd {jd}: code {ret}")
return (float(pos[0]) - float(ayanamsa)) % 360.0
+172
View File
@@ -0,0 +1,172 @@
"""Tithi Pravesha instants from our sidereal Sun and Moon.
A missing body raises. Import errors are not stored as status=blocked.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
try:
from annual_native.sidereal_bodies import sun_moon_longitudes
except ImportError: # pragma: no cover
from scripts.annual_native.sidereal_bodies import sun_moon_longitudes
SCHEMA = "jyotish.tithi_pravesh.v1"
_NAMES = (
"Pratipada", "Dvitiya", "Tritiya", "Chaturthi", "Panchami",
"Shashthi", "Saptami", "Ashtami", "Navami", "Dashami",
"Ekadashi", "Dvadashi", "Trayodashi", "Chaturdashi", "Purnima/Amavasya",
)
def build_tithi_pravesh(payload: dict[str, Any]) -> dict[str, Any]:
birth = dict(payload["birth"])
settings = dict(payload.get("settings") or {})
target_year = int(payload["target_year"])
year, month, day = [int(value) for value in str(birth["date"]).split("-")]
time_parts = [int(value) for value in str(birth.get("time") or "00:00:00").split(":")]
while len(time_parts) < 3:
time_parts.append(0)
tz = _offset_hours(birth.get("utc_offset"))
ayanamsa = str(settings.get("ayanamsa") or "lahiri")
node_mode = str(settings.get("node_mode") or "mean")
birth_jd = _jd_local(year, month, day, time_parts[0] + time_parts[1] / 60 + time_parts[2] / 3600, tz)
birth_sun, birth_moon = sun_moon_longitudes(birth_jd, node_mode=node_mode, ayanamsa_name=ayanamsa)
target_phase = (birth_moon - birth_sun) % 360
target_solar_sign = int(birth_sun // 30)
candidate_jds = _find_candidates(
target_year, month, day, tz, target_phase, target_solar_sign, ayanamsa, node_mode,
)
birth_place = _place(birth, tz)
birth_rows = [
_chart_row(jd, birth_place, node_mode, target_phase, ayanamsa) for jd in candidate_jds
]
return {
"schema": SCHEMA,
"status": "partial_verified" if birth_rows else "blocked",
"reason": None if birth_rows else "no_tithi_pravesh_candidate",
"target_year": target_year,
"natal_phase_degrees": target_phase,
"natal_sun_longitude": birth_sun,
"natal_moon_longitude": birth_moon,
"rows": birth_rows,
}
def _find_candidates(target_year, birth_month, birth_day, tz, target_phase, target_solar_sign, ayanamsa, node_mode) -> list[float]:
try:
anchor = datetime(target_year, birth_month, birth_day)
except ValueError:
anchor = datetime(target_year, birth_month, 1)
start_jd = _datetime_to_jd(anchor - timedelta(days=42), tz)
end_jd = _datetime_to_jd(anchor + timedelta(days=82), tz)
roots: list[float] = []
previous_jd = start_jd
previous_value = _phase_delta(previous_jd, target_phase, ayanamsa, node_mode)
while previous_jd < end_jd:
current_jd = min(previous_jd + 0.125, end_jd)
current_value = _phase_delta(current_jd, target_phase, ayanamsa, node_mode)
crossed = previous_value * current_value <= 0 and abs(previous_value) < 170 and abs(current_value) < 170
if crossed:
root = _bisect(previous_jd, current_jd, target_phase, ayanamsa, node_mode)
if root is not None:
sun, _moon = sun_moon_longitudes(root, node_mode=node_mode, ayanamsa_name=ayanamsa)
if int(sun // 30) == target_solar_sign and all(abs(root - existing) > 0.02 for existing in roots):
roots.append(root)
previous_jd, previous_value = current_jd, current_value
return roots
def _bisect(low, high, target_phase, ayanamsa, node_mode) -> float | None:
low_value = _phase_delta(low, target_phase, ayanamsa, node_mode)
for _ in range(40):
mid = (low + high) / 2
mid_value = _phase_delta(mid, target_phase, ayanamsa, node_mode)
if abs(mid_value) < 1e-7:
return mid
if low_value * mid_value <= 0:
high = mid
else:
low, low_value = mid, mid_value
root = (low + high) / 2
if abs(_phase_delta(root, target_phase, ayanamsa, node_mode)) >= 1e-4:
return None
return root
def _phase_delta(jd, target_phase, ayanamsa, node_mode) -> float:
sun, moon = sun_moon_longitudes(jd, node_mode=node_mode, ayanamsa_name=ayanamsa)
return ((moon - sun - target_phase + 180.0) % 360.0) - 180.0
def _chart_row(jd, place, node_mode, target_phase, ayanamsa) -> dict[str, Any]:
try:
from jyotish_engine import compute_chart_data
except ImportError:
from scripts.jyotish_engine import compute_chart_data
local = datetime(2000, 1, 1) + timedelta(days=jd - 2451544.5, hours=float(place["tz"]))
chart, *_rest = compute_chart_data(
local.year, local.month, local.day, local.hour, local.minute,
float(place["latitude"]), float(place["longitude"]), float(place["tz"]),
node_mode, second=local.second, ayanamsa_name=ayanamsa,
)
if not isinstance(chart, dict):
raise RuntimeError(f"compute_chart_data returned no chart for tithi jd {jd}")
planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {}
sun = float(planets["Sun"]["degree_raw"]) % 360.0
moon = float(planets["Moon"]["degree_raw"]) % 360.0
phase = (moon - sun) % 360.0
return {
"jd_ut": jd,
"local_datetime": local.strftime("%Y-%m-%d %H:%M:%S"),
"tithi": _tithi_payload(sun, moon),
"phase_degrees": phase,
"phase_delta_degrees": ((phase - target_phase + 180.0) % 360.0) - 180.0,
"planet_longitudes": {
name: planets[name].get("degree_raw")
for name in ("Sun", "Moon")
if isinstance(planets.get(name), dict)
},
"status": "partial_verified",
}
def _tithi_payload(sun: float, moon: float) -> dict[str, Any]:
phase = (moon - sun) % 360.0
number = min(30, int(phase // 12) + 1)
paksha = "Shukla" if number <= 15 else "Krishna"
within = number if number <= 15 else number - 15
return {
"tithi_num": number,
"paksha": paksha,
"full_name": f"{paksha} {_NAMES[within - 1]}",
"moon_sun_diff": phase,
}
def _place(data: dict, fallback_tz: float) -> dict[str, Any]:
return {
"latitude": float(data["latitude"]),
"longitude": float(data["longitude"]),
"tz": _offset_hours(data.get("utc_offset")) if data.get("utc_offset") is not None else fallback_tz,
}
def _offset_hours(value: Any) -> float:
if isinstance(value, (int, float)) and not isinstance(value, bool):
return float(value)
text = str(value or "+00:00").strip()
sign = -1 if text.startswith("-") else 1
hours, minutes = [int(item) for item in text.lstrip("+-").split(":")[:2]]
return sign * (hours + minutes / 60.0)
def _jd_local(year: int, month: int, day: int, local_hours: float, tz: float) -> float:
import swisseph as swe
return float(swe.julday(year, month, day, local_hours - tz))
def _datetime_to_jd(value: datetime, tz: float) -> float:
return _jd_local(value.year, value.month, value.day, value.hour + value.minute / 60 + value.second / 3600, tz)
+21 -4
View File
@@ -1784,7 +1784,7 @@ def _attach_full_report_pack(packet: dict, args) -> dict:
}
annual = timing.get('annual_tajika_pack') if isinstance(timing.get('annual_tajika_pack'), dict) else {}
if not annual:
annual = _build_pl9_full_annual_section(args, profile)
annual = _build_pl9_full_annual_section(args, profile, include_overview=True)
dasha = timing.get('dasha_master_pack') if isinstance(timing.get('dasha_master_pack'), dict) else {}
if not dasha:
dasha = _build_pl9_full_dasha_section(packet, modules)
@@ -2274,6 +2274,7 @@ def _attach_annual_tajika_pack(packet: dict, args) -> dict:
_build_pl9_full_annual_section(
args,
packet.get('calculation_profile') or {},
include_overview=True,
),
args=args,
)
@@ -2299,6 +2300,7 @@ def _attach_annual_tajika_pack(packet: dict, args) -> dict:
_build_pl9_full_annual_section(
annual_args,
packet.get('calculation_profile') or {},
include_overview=False,
)
)
except Exception as exc:
@@ -3063,7 +3065,10 @@ def _annual_series_surface(annual_pack: dict) -> dict:
keep_keys = (
'schema', 'status', 'reason', 'profile', 'audit', 'report_sections',
'solar_return', 'annual_chart', 'year_lord', 'external_engine_comparison',
'patyayini_dasha', 'sahams', 'tajika_yogas', 'muntha',
'patyayini_dasha', 'sahams', 'tajika_yogas', 'muntha', 'mudda_dasha',
'monthly_chart_pack', 'tithi_pravesh_years', 'native_patyayini',
'pl9_annual_dasha_results', 'annual_houses', 'tripataki_chakra',
'annual_year_pages', 'normalized_tables',
)
surface = {
key: _json_safe_report_snapshot(annual_pack.get(key))
@@ -3262,7 +3267,7 @@ def _attach_profile_id_to_dasha_section(dasha: dict, profile: dict) -> dict:
return dasha
def _build_pl9_full_annual_section(args, profile: dict | None = None) -> dict:
def _build_pl9_full_annual_section(args, profile: dict | None = None, *, include_overview: bool = False) -> dict:
target_year = getattr(args, 'target_year', None)
if target_year is None and getattr(args, 'today', None):
try:
@@ -3301,9 +3306,21 @@ def _build_pl9_full_annual_section(args, profile: dict | None = None) -> dict:
place=place,
age=age,
)
return build_annual_tajika_pack(payload, pyjhora_replay=replay)
pack = build_annual_tajika_pack(payload, pyjhora_replay=replay)
except Exception as exc:
return {'schema': 'jyotish.annual_tajika_pack.v1', 'status': 'blocked', 'reason': str(exc)}
try:
from annual_native.annual_expansion import attach_annual_expansion
except ImportError as exc:
logger.warning('annual expansion import failed: %s', exc)
pack['annual_expansion_error'] = f'ImportError: {exc}'
return pack
try:
attach_annual_expansion(pack, payload, args, include_overview=include_overview)
except Exception as exc:
logger.warning('annual expansion failed: %s', exc)
pack['annual_expansion_error'] = f'{exc.__class__.__name__}: {exc}'
return pack
def _annual_payload_from_args(args, profile: dict | None = None) -> dict:
+103 -21
View File
@@ -843,6 +843,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
'match': '一致',
'mismatch': '需校对',
'not_applicable': '不适用',
'dasha_ending_dates': '结束日',
'pyjhora_behavior_only': '外部行为参考',
'pyjhora_behavior_only / not_multiengine_parity': '外部行为参考',
'blocked_external_callable': '外部回放待校对',
@@ -856,6 +857,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
'match': 'matches',
'mismatch': 'needs review',
'not_applicable': 'not applicable',
'dasha_ending_dates': 'ending date',
'pyjhora_behavior_only': 'external behavior note',
'pyjhora_behavior_only / not_multiengine_parity': 'external behavior note',
'blocked_external_callable': 'external replay still to review',
@@ -2411,20 +2413,33 @@ def render_pl9_parity_markdown(packet: dict) -> str:
if not isinstance(row, dict):
continue
boundary = f"{row.get('start')} - {row.get('end')}" if row.get('start') and row.get('end') else row.get('start') or '-'
themes = row.get('themes')
if isinstance(themes, dict):
themes = themes.get(_lang) or themes.get('en') or '[translation missing: themes]'
else:
themes = _brief(themes, 2)
lines.append(
f"| {_cell(row.get('order'))} | {_cell(_token(row.get('lord')))} | {_cell(row.get('annual_whole_sign_house'))} | "
f"{_cell(boundary)} | {_cell(_brief(row.get('themes'), 2))} |"
f"{_cell(boundary)} | {_cell(themes)} |"
)
lines.append('')
normalized = _dict(annual.get('normalized_tables'))
mudda_rows = _list(normalized.get('mudda_rows'))
if mudda_rows:
lines.extend([
'### p132-p135 Mudda / Patyayini 年度 Dasha 表',
'',
'| Balance profile | Lord | Start | End | Boundary |' if _lang == 'en' else '| balance profile | 主星 | 开始 | 结束 | 边界口径 |',
'|-----------------|------|-------|-----|----------|' if _lang == 'en' else '|-----------------|------|------|------|----------|',
])
if _lang == 'en':
lines.extend([
'### Mudda annual dasha',
'',
'| Balance profile | Lord | Start | End | Boundary |',
'|-----------------|------|-------|-----|----------|',
])
else:
lines.extend([
'### Mudda 年度大运',
'',
'| 余额口径 | 主星 | 开始 | 结束 | 边界口径 |',
'|----------|------|------|------|----------|',
])
for row in mudda_rows:
if not isinstance(row, dict):
continue
@@ -2449,12 +2464,20 @@ def render_pl9_parity_markdown(packet: dict) -> str:
lines.append('')
patyayini_rows = _list(normalized.get('patyayini_rows'))
if patyayini_rows:
lines.extend([
'### p134 Patyayini / Patyamsha ending-date 表',
'',
'| 主层 | 子层 | 边界 | 边界语义 |',
'|------|------|------|----------|',
])
if _lang == 'en':
lines.extend([
'### Patyayini ending dates',
'',
'| Main | Sub | Boundary | Boundary meaning |',
'|------|-----|----------|------------------|',
])
else:
lines.extend([
'### Patyayini 结束日',
'',
'| 主层 | 子层 | 边界 | 边界语义 |',
'|------|------|------|----------|',
])
for row in patyayini_rows:
if not isinstance(row, dict):
continue
@@ -2661,32 +2684,65 @@ def render_pl9_parity_markdown(packet: dict) -> str:
lines.extend([
'### Monthly returns within the year',
'',
'| Month | Local time | Annual ascendant | Ascendant degree | Planet longitudes (deg) |',
'|-------|------------|------------------|------------------|--------------------------|',
'| Year | Month | Local time | Annual ascendant | Ascendant degree | Planet longitudes (deg) |',
'|------|-------|------------|------------------|------------------|--------------------------|',
])
else:
lines.extend([
'### 年度月返照图盘数据',
'',
'| 月份 | 本地时刻 | 年盘上升 | 上升度数 | 行星黄经(度) |',
'|------|----------|----------|----------|-----------------|',
'| 年份 | 月份 | 本地时刻 | 年盘上升 | 上升度数 | 行星黄经(度) |',
'|------|------|----------|----------|----------|-----------------|',
])
joiner = ', ' if _lang == 'en' else '、'
for row in monthly_rows:
if not isinstance(row, dict):
continue
asc = _dict(row.get('ascendant'))
longitudes = _dict(row.get('planet_longitudes'))
longitude_text = ';'.join(
longitude_text = joiner.join(
f"{_token(planet)} {_degree(value)}"
for planet, value in longitudes.items()
if isinstance(value, (int, float))
)
lines.append(
f"| {_cell(row.get('month_index'))} | {_cell(row.get('local_datetime'))} | "
f"| {_cell(row.get('target_year'))} | {_cell(row.get('month_index'))} | {_cell(row.get('local_datetime'))} | "
f"{_cell(_token(asc.get('sign')))} | {_cell(_degree(asc.get('degree_in_sign') or asc.get('degree')))} | "
f"{_cell(longitude_text)} |"
)
lines.append('')
tithi_years = _list(annual.get('tithi_pravesh_years'))
tithi_rows = [
row for year in tithi_years if isinstance(year, dict)
for row in _list(year.get('rows')) if isinstance(row, dict)
]
if tithi_rows:
if _lang == 'en':
lines.extend([
'### Tithi Pravesha',
'',
'| Year | Local time | Tithi | Phase (deg) |',
'|------|------------|-------|-------------|',
])
else:
lines.extend([
'### Tithi Pravesha',
'',
'| 年份 | 本地时刻 | Tithi | 月相角(度) |',
'|------|----------|-------|-------------|',
])
for year in tithi_years:
if not isinstance(year, dict):
continue
for row in _list(year.get('rows')):
if not isinstance(row, dict):
continue
tithi = _dict(row.get('tithi'))
lines.append(
f"| {_cell(year.get('target_year'))} | {_cell(row.get('local_datetime'))} | "
f"{_cell(tithi.get('full_name'))} | {_cell(_degree(row.get('phase_degrees')))} |"
)
lines.append('')
lifetime_pack = _dict(annual.get('lifetime_annual_chart_pages'))
lifetime_pages = _list(lifetime_pack.get('pages'))
lifetime_rows = [
@@ -2708,10 +2764,11 @@ def render_pl9_parity_markdown(packet: dict) -> str:
'| 年龄 | 年份 | 年返时刻 | 上升 | 上升度数 | 行星黄经(度) |',
'|------|------|----------|------|----------|-----------------|',
])
lifetime_joiner = ', ' if _lang == 'en' else '、'
for row in lifetime_rows:
asc = _dict(row.get('ascendant'))
longitudes = _dict(row.get('planet_longitudes'))
longitude_text = ';'.join(
longitude_text = lifetime_joiner.join(
f"{_token(planet)} {_degree(value)}"
for planet, value in longitudes.items()
if isinstance(value, (int, float))
@@ -2898,9 +2955,14 @@ def render_pl9_parity_markdown(packet: dict) -> str:
if not isinstance(row, dict):
continue
boundary = f"{row.get('start')} - {row.get('end')}" if row.get('start') and row.get('end') else row.get('start') or '-'
themes = row.get('themes')
if isinstance(themes, dict):
themes = themes.get(_lang) or themes.get('en') or '[translation missing: themes]'
else:
themes = _brief(themes, 2)
lines.append(
f"| {_cell(row.get('order'))} | {_cell(_token(row.get('lord')))} | {_cell(row.get('annual_whole_sign_house'))} | "
f"{_cell(boundary)} | {_cell(_brief(row.get('themes'), 2))} |"
f"{_cell(boundary)} | {_cell(themes)} |"
)
lines.append('')
normalized = _dict(annual_pack.get('normalized_tables'))
@@ -3002,6 +3064,26 @@ def render_pl9_parity_markdown(packet: dict) -> str:
f"{_cell(_token(asc.get('sign')))} | {_cell(_degree(asc.get('degree_in_sign') or asc.get('degree')))} |"
)
lines.append('')
tithi_years = _list(annual_pack.get('tithi_pravesh_years'))
if any(isinstance(year_pack, dict) and _list(year_pack.get('rows')) for year_pack in tithi_years):
lines.extend([
'#### Tithi Pravesha' if _lang == 'en' else '#### Tithi Pravesha',
'',
'| Local time | Tithi | Phase (deg) |' if _lang == 'en' else '| 本地时刻 | Tithi | 月相角(度) |',
'|------------|-------|-------------|' if _lang == 'en' else '|----------|-------|-------------|',
])
for year_pack in tithi_years:
if not isinstance(year_pack, dict):
continue
for row in _list(year_pack.get('rows')):
if not isinstance(row, dict):
continue
tithi = _dict(row.get('tithi'))
lines.append(
f"| {_cell(row.get('local_datetime'))} | {_cell(tithi.get('full_name'))} | "
f"{_cell(_degree(row.get('phase_degrees')))} |"
)
lines.append('')
d1_sheet = worksheets.get('d1_rasi_bhava') if isinstance(worksheets.get('d1_rasi_bhava'), dict) else {}
d1_houses = _dict(d1_sheet.get('houses')) or _dict(core_chart.get('houses'))
d1_planets = _dict(d1_sheet.get('planets')) or _dict(core_chart.get('planets'))
+78
View File
@@ -0,0 +1,78 @@
"""Annual expansion adds rows without replacing the year lord, Muntha, Mudda source, Sahams or Tajika."""
from __future__ import annotations
from types import SimpleNamespace
from scripts.annual_native.annual_expansion import attach_annual_expansion
_PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
def _pack() -> dict:
return {
"year_lord": {"year_lord": "Mars", "marker": "keep"},
"muntha": {"muntha_sign": "Leo"},
"mudda_dasha": {"periods": [{"lord": "Jupiter", "start": "2026-01-01", "end": "2026-03-01"}]},
"sahams": {"vivah_saham": {"longitude": 12.5, "name": "Vivah"}},
"tajika_yogas": {"status": "partial_verified", "items": ["keep-me"]},
"solar_return": {"jd_ut": 2460676.5, "dt_local": "2025-01-15 08:00:00"},
"annual_houses": {
"ascendant_longitude": 15.0,
"planet_longitudes": {name: float((index + 1) * 17) for index, name in enumerate(_PLANETS)},
},
}
def _payload() -> dict:
return {
"target_year": 2026,
"birth": {"latitude": 39.9, "longitude": 116.4, "utc_offset": "+08:00"},
"settings": {"ayanamsa": "lahiri", "node_mode": "mean", "dasha_year_days": 365.25},
}
def _args() -> SimpleNamespace:
return SimpleNamespace(year=1985, month=6, day=15, hour=8, minute=30, lat=39.9, lon=116.4, tz=8.0, second=0)
def _quiet(monkeypatch) -> None:
monkeypatch.setattr(
"scripts.annual_native.annual_expansion.build_monthly_charts",
lambda **_kwargs: {"rows": [{"month_index": index, "status": "partial_verified"} for index in range(1, 13)]},
)
monkeypatch.setattr(
"scripts.annual_native.annual_expansion.build_tithi_pravesh",
lambda _payload: {"status": "partial_verified", "natal_phase_degrees": 12.0, "rows": [{"local_datetime": "2026-02-01 00:00:00"}]},
)
def test_later_year_expansion_keeps_the_original_annual_facts(monkeypatch) -> None:
_quiet(monkeypatch)
pack = _pack()
attach_annual_expansion(pack, _payload(), _args(), include_overview=False)
assert pack["year_lord"] == {"year_lord": "Mars", "marker": "keep"}
assert pack["muntha"]["muntha_sign"] == "Leo"
assert pack["mudda_dasha"]["periods"][0]["lord"] == "Jupiter"
assert pack["sahams"]["vivah_saham"]["longitude"] == 12.5
assert pack["tajika_yogas"]["items"] == ["keep-me"]
assert len(pack["monthly_chart_pack"]["rows"]) == 12
assert pack["monthly_chart_pack"]["status"] == "partial_verified"
assert pack["normalized_tables"]["mudda_rows"][0]["lord"] == "Jupiter"
assert pack["normalized_tables"]["patyayini_rows"]
assert pack["tithi_pravesh_years"][0]["target_year"] == 2026
assert "annual_chart_series_pack" not in pack
def test_primary_year_leaves_mudda_rows_for_the_language_projector(monkeypatch) -> None:
_quiet(monkeypatch)
monkeypatch.setattr(
"scripts.annual_native.annual_expansion._eight_year",
lambda *_args, **_kwargs: {"birth_place_eight_year_overview": {"status": "partial_verified", "rows": []}},
)
pack = _pack()
attach_annual_expansion(pack, _payload(), _args(), include_overview=True)
assert "mudda_rows" not in pack["normalized_tables"]
assert pack["normalized_tables"]["patyayini_rows"]
assert "birth_place_eight_year_overview" in pack["annual_chart_series_pack"]
assert "lifetime_annual_chart_pages" not in pack
+35
View File
@@ -6,6 +6,7 @@ import json
from datetime import datetime
from scripts.annual_native.annual_houses import build_annual_whole_sign_houses
from scripts.annual_native.monthly_charts import build_monthly_charts
from scripts.annual_native.tripataki_chakra import build_tripataki_chakra
from scripts.jyotish_engine import compute_chart_data
from scripts.report_render.parity_chapters import render_parity_chapters
@@ -53,6 +54,40 @@ def test_new_longitudes_match_the_chart() -> None:
assert _delta(sun["longitude"], raw["Sun"]) <= LIMIT
def test_monthly_row_sun_matches_the_chart_function() -> None:
import swisseph as swe
from scripts.annual_native.sidereal_bodies import sun_moon_longitudes
birth, chart = _chart()
hour = birth["hour"] + birth["minute"] / 60.0
jd = swe.julday(birth["year"], birth["month"], birth["day"], hour - birth["tz"])
sun, moon = sun_moon_longitudes(jd, node_mode=birth["node_mode"], ayanamsa_name=birth["ayanamsa"])
assert _delta(sun, float(chart["planets"]["Sun"]["degree_raw"])) <= LIMIT
assert _delta(moon, float(chart["planets"]["Moon"]["degree_raw"])) <= LIMIT
monthly = build_monthly_charts(
annual_jd=jd,
birth_sun_longitude=sun,
latitude=birth["lat"],
longitude=birth["lon"],
tz_hours=birth["tz"],
ayanamsa=birth["ayanamsa"],
node_mode=birth["node_mode"],
target_year=birth["year"],
)
assert len(monthly["rows"]) == 12
first = monthly["rows"][0]
assert first["status"] == "partial_verified"
again, *_rest = compute_chart_data(
int(first["utc_datetime"][0:4]), int(first["utc_datetime"][5:7]), int(first["utc_datetime"][8:10]),
int(first["utc_datetime"][11:13]), int(first["utc_datetime"][14:16]),
birth["lat"], birth["lon"], 0,
node_mode=birth["node_mode"], second=int(first["utc_datetime"][17:19]),
ayanamsa_name=birth["ayanamsa"],
)
assert _delta(float(first["planet_longitudes"]["Sun"]), float(again["planets"]["Sun"]["degree_raw"])) <= LIMIT
assert _delta(float(first["planet_longitudes"]["Moon"]), float(again["planets"]["Moon"]["degree_raw"])) <= LIMIT
def test_saham_traces_keep_the_stored_longitude() -> None:
birth, chart = _chart()
asc = chart["ascendant"]