diff --git a/CHANGELOG.md b/CHANGELOG.md index d922f42a..e74a6156 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 星座强度里没有落入行星时写「无」。 diff --git a/scripts/annual_native/annual_expansion.py b/scripts/annual_native/annual_expansion.py new file mode 100644 index 00000000..2eea5cec --- /dev/null +++ b/scripts/annual_native/annual_expansion.py @@ -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) diff --git a/scripts/annual_native/annual_houses.py b/scripts/annual_native/annual_houses.py index 4141a714..30e91a23 100644 --- a/scripts/annual_native/annual_houses.py +++ b/scripts/annual_native/annual_houses.py @@ -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, } diff --git a/scripts/annual_native/monthly_charts.py b/scripts/annual_native/monthly_charts.py new file mode 100644 index 00000000..15be936d --- /dev/null +++ b/scripts/annual_native/monthly_charts.py @@ -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", + } diff --git a/scripts/annual_native/patyayini.py b/scripts/annual_native/patyayini.py new file mode 100644 index 00000000..4d9aac7d --- /dev/null +++ b/scripts/annual_native/patyayini.py @@ -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") diff --git a/scripts/annual_native/sidereal_bodies.py b/scripts/annual_native/sidereal_bodies.py new file mode 100644 index 00000000..a06e2980 --- /dev/null +++ b/scripts/annual_native/sidereal_bodies.py @@ -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 diff --git a/scripts/annual_native/tithi_pravesh.py b/scripts/annual_native/tithi_pravesh.py new file mode 100644 index 00000000..c4acc286 --- /dev/null +++ b/scripts/annual_native/tithi_pravesh.py @@ -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) diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index b7120e4c..569e99c8 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -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: diff --git a/scripts/pl9_reader_export.py b/scripts/pl9_reader_export.py index 2ec89e6d..21c19ec9 100644 --- a/scripts/pl9_reader_export.py +++ b/scripts/pl9_reader_export.py @@ -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')) diff --git a/tests/test_annual_expansion.py b/tests/test_annual_expansion.py new file mode 100644 index 00000000..b8b241ab --- /dev/null +++ b/tests/test_annual_expansion.py @@ -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 diff --git a/tests/test_annual_native_geometry.py b/tests/test_annual_native_geometry.py index 5be18a91..374fa09a 100644 --- a/tests/test_annual_native_geometry.py +++ b/tests/test_annual_native_geometry.py @@ -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"]