diff --git a/CHANGELOG.md b/CHANGELOG.md index e74a6156..dd1a9b3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-10-23 — 完整数据版补上三年逐月星历 + +- 从报告日期所在月起 36 个月,报告口径和 KP 岁差口径各有一张九星表,另有五级大运的月采样。黄经走本系统恒星计算,不用视位置。 +- Skill 版本不变。 + ## 2026-10-23 — 完整数据版补上一年里的月返照和 Tithi - 当年和后两年各自列出月返照、原生 Patyayini 和 Tithi Pravesha。已有的年主、Muntha、Mudda、Sahams、Tajika 数值不改写。出生地八年总览会算;72 年的生命周期年返这一版先不做,避免一次生成几十张年返盘。 diff --git a/scripts/annual_native/report_ephemeris.py b/scripts/annual_native/report_ephemeris.py new file mode 100644 index 00000000..483b5070 --- /dev/null +++ b/scripts/annual_native/report_ephemeris.py @@ -0,0 +1,181 @@ +"""36-month ephemeris from the report month, on this repository's sidereal frame. + +Longitudes come from ``_calc_sidereal_planets_for_jd`` (true precession, not +apparent position). The KP table uses the same function with the KP ayanamsa. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + + +_PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu") +SCHEMA = "jyotish.report_month_ephemeris.v1" + + +def attach_report_month_ephemeris(args: Any, annual: dict | None = None) -> dict: + today = getattr(args, "today", None) + if isinstance(today, str) and len(today) >= 7 and today[4] == "-": + start_year = int(today[0:4]) + start_month = int(today[5:7]) + start_source = "report_date" + else: + profile = annual.get("profile") if isinstance(annual, dict) and isinstance(annual.get("profile"), dict) else {} + target = profile.get("target_year", getattr(args, "target_year", None)) + start_year = int(target if target not in (None, "") else args.year) + start_month = 1 + start_source = "target_january_report_date_missing" + settings = {} + if isinstance(annual, dict) and isinstance(annual.get("profile"), dict): + settings = annual["profile"] + ayanamsa = str(getattr(args, "ayanamsa", None) or settings.get("ayanamsa") or "lahiri") + node_mode = str(getattr(args, "node_mode", None) or settings.get("node_mode") or "mean") + return build_report_ephemeris( + birth_year=int(args.year), + birth_month=int(args.month), + birth_day=int(args.day), + birth_hour=int(args.hour), + birth_minute=int(getattr(args, "minute", 0) or 0), + birth_second=int(getattr(args, "second", 0) or 0), + latitude=float(args.lat), + longitude=float(args.lon), + tz_hours=float(args.tz), + ayanamsa=ayanamsa, + node_mode=node_mode, + start_year=start_year, + start_month=start_month, + start_source=start_source, + ) + + +def build_report_ephemeris( + *, + birth_year: int, + birth_month: int, + birth_day: int, + birth_hour: int, + birth_minute: int, + birth_second: int, + latitude: float, + longitude: float, + tz_hours: float, + ayanamsa: str, + node_mode: str, + start_year: int, + start_month: int, + start_source: str, + month_count: int = 36, +) -> dict: + if month_count < 1: + raise ValueError("month_count must be at least 1") + if not 1 <= int(start_month) <= 12: + raise ValueError(f"start_month out of range: {start_month}") + natal_jd = _jd(birth_year, birth_month, birth_day, birth_hour + birth_minute / 60 + birth_second / 3600, tz_hours) + natal = _bodies(natal_jd, node_mode, ayanamsa) + moon_body = natal.get("Moon") if isinstance(natal.get("Moon"), dict) else {} + moon = moon_body.get("degree_raw") + if not isinstance(moon, (int, float)) or isinstance(moon, bool): + raise RuntimeError("natal Moon longitude missing for the monthly Vimshottari sample") + report_rows = [] + kp_rows = [] + level_rows = [] + level_error = None + for offset in range(month_count): + year, month = _add_months(start_year, start_month, offset) + jd = _jd(year, month, 1, 12.0, tz_hours) + month_text = f"{year:04d}-{month:02d}" + report_rows.extend(_planet_rows(month_text, _bodies(jd, node_mode, ayanamsa))) + kp_rows.extend(_planet_rows(month_text, _bodies(jd, node_mode, "kp"))) + if level_error is None: + try: + level_rows.append(_levels(birth_year, birth_month, birth_day, birth_hour, birth_minute, birth_second, moon, year, month)) + except Exception as exc: + level_error = f"{exc.__class__.__name__}: {exc}" + return { + "schema": SCHEMA, + "status": "partial_verified", + "start_source": start_source, + "start_month": f"{int(start_year):04d}-{int(start_month):02d}", + "month_count": month_count, + "ayanamsa": ayanamsa, + "kp_ayanamsa": "kp", + "node_mode": node_mode, + "anchor": "local_month_start_noon", + "report_rows": report_rows, + "kp_rows": kp_rows, + "vimshottari_rows": level_rows, + "vimshottari_reason": level_error, + "sample": { + "month": report_rows[0]["month"] if report_rows else None, + "jd_ut": _jd(start_year, start_month, 1, 12.0, tz_hours), + "moon_degree_raw": next(row["degree_raw"] for row in report_rows if row["planet"] == "Moon"), + }, + } + + +def _planet_rows(month_text: str, bodies: dict) -> list[dict]: + rows = [] + for name in _PLANETS: + body = bodies.get(name) + if not isinstance(body, dict) or not isinstance(body.get("degree_raw"), (int, float)): + raise RuntimeError(f"{name} longitude missing for {month_text}") + rows.append({ + "month": month_text, + "planet": name, + "sign": body.get("sign"), + "longitude": round(float(body["degree_raw"]), 6), + "degree_raw": float(body["degree_raw"]), + }) + return rows + + +def _bodies(jd: float, node_mode: str, ayanamsa: str) -> dict: + try: + from jyotish_engine import _calc_sidereal_planets_for_jd + except ImportError: + from scripts.jyotish_engine import _calc_sidereal_planets_for_jd + data, _ayan = _calc_sidereal_planets_for_jd(jd, node_mode=node_mode, ayanamsa_name=ayanamsa) + if not isinstance(data, dict) or "Moon" not in data: + raise RuntimeError(f"sidereal planets missing at jd {jd}") + return data + + +def _levels(year, month, day, hour, minute, second, moon_lon: float, anchor_year: int, anchor_month: int) -> dict: + try: + from kp_monthly_vimshottari import build_monthly_vimshottari_snapshot + except ImportError: + from scripts.kp_monthly_vimshottari import build_monthly_vimshottari_snapshot + snapshot = build_monthly_vimshottari_snapshot( + birth_dt=datetime(year, month, day, hour, minute, second), + moon_lon=float(moon_lon), + anchor_dt=datetime(anchor_year, anchor_month, 1, 12, 0, 0), + ) + levels = snapshot.get("levels") if isinstance(snapshot.get("levels"), dict) else {} + return { + "month": f"{anchor_year:04d}-{anchor_month:02d}", + "mahadasha": _lord(levels.get("mahadasha")), + "antardasha": _lord(levels.get("antardasha")), + "pratyantardasha": _lord(levels.get("pratyantardasha")), + "sookshma": _lord(levels.get("sookshma")), + "prana": _lord(levels.get("prana")), + } + + +def _lord(value: Any) -> str | None: + if isinstance(value, dict): + lord = value.get("lord") or value.get("planet") + return str(lord) if lord else None + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _jd(year: int, month: int, day: int, local_hours: float, tz_hours: float) -> float: + import swisseph as swe + return float(swe.julday(int(year), int(month), int(day), float(local_hours) - float(tz_hours))) + + +def _add_months(year: int, month: int, offset: int) -> tuple[int, int]: + index = int(year) * 12 + (int(month) - 1) + int(offset) + return index // 12, index % 12 + 1 diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 569e99c8..8855964e 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -1810,6 +1810,19 @@ def _attach_full_report_pack(packet: dict, args) -> dict: profile=profile, ) timing['three_year_predictive_ephemeris_pack'] = three_year_predictive_ephemeris_pack + try: + from annual_native.report_ephemeris import attach_report_month_ephemeris + except ImportError: + from scripts.annual_native.report_ephemeris import attach_report_month_ephemeris + try: + timing['report_month_ephemeris'] = attach_report_month_ephemeris(args, annual if isinstance(annual, dict) else {}) + except Exception as exc: + logger.warning('report month ephemeris failed: %s', exc) + timing['report_month_ephemeris'] = { + 'schema': 'jyotish.report_month_ephemeris.v1', + 'status': 'blocked', + 'reason': f'{exc.__class__.__name__}: {exc}', + } sections = { 'base': { 'status': 'partial_verified' if worksheets.get('birth_and_parameters') or worksheets.get('d1_rasi_bhava') else 'blocked', diff --git a/scripts/report_render/parity_chapters.py b/scripts/report_render/parity_chapters.py index 095fe154..975f1851 100644 --- a/scripts/report_render/parity_chapters.py +++ b/scripts/report_render/parity_chapters.py @@ -57,6 +57,13 @@ LABELS: dict[str, tuple[str, str]] = { "neutral": ("Natural neutral", "天然中性"), "temporary_friends": ("Temporary friends", "临时友"), "temporary_enemies": ("Temporary enemies", "临时敌"), + "year": ("Year", "年份"), + "month": ("Month", "月份"), + "mahadasha": ("Mahadasha", "大运"), + "antardasha": ("Antardasha", "小运"), + "pratyantardasha": ("Pratyantardasha", "次小运"), + "sookshma": ("Sookshma", "细运"), + "prana": ("Prana", "微运"), "house": ("House", "宫位"), "item": ("Item", "项目"), "karaka": ("Karaka", "卡拉卡"), @@ -172,6 +179,7 @@ SECTIONS: tuple[tuple[str, str], ...] = ( ("Tripataki Chakra Detail", "Tripataki 轮盘明细"), ("Annual Tajika Strength", "年度 Tajika 力量"), ("Annual Saham Formula Trace", "年度特殊点公式轨迹"), + ("Annual Chart Numeric Data", "年度图盘数值数据"), ("Shadbala Subcomponent", "六维力量分项"), ("Shadbala Result", "六维力量结果"), ("House Components and Physical Coordinates", "宫位分量与坐标"), @@ -219,6 +227,7 @@ SECTIONS: tuple[tuple[str, str], ...] = ( ("Multi-Reference Transit", "多参考点行运"), ("Double Transit PAC", "木土双重行运 PAC"), ("Lagna / 7th Lord Transit", "上升主与七宫主行运"), + ("Three-Year Predictive Ephemeris", "三年逐月星历"), ("Natal Calculation Conventions", "本命计算口径"), ("KP Calculation Conventions", "KP 计算口径"), ("D9 Dignity", "D9 尊贵"), @@ -229,11 +238,9 @@ SECTIONS: tuple[tuple[str, str], ...] = ( ) FORBIDDEN_TITLES = ( - "Annual Chart Numeric Data", "Bhava Bala Relative Score Components", "Score Extremes", "独立年度方法参考", - "三年逐月星历", "D81", "D108", "D144", @@ -933,6 +940,48 @@ def _trace_table(traces: Any, language: str) -> str: return _rows_from(rows, language, [("name", "text"), ("rule", "text"), ("phase", "text"), ("longitude", "text")]) +def _annual_numeric(packet: dict, language: str) -> str: + rows = [] + seen = set() + packs = [] + primary = _first(packet, ("timing_and_predictive_systems", "annual_tajika_pack")) or {} + if isinstance(primary, dict): + packs.append(primary) + series = _first(packet, ("timing_and_predictive_systems", "annual_tajika_series")) or {} + years = series.get("years") if isinstance(series, dict) else None + if isinstance(years, dict): + for year in sorted(years, key=str): + pack = years.get(year) + if isinstance(pack, dict): + packs.append(pack) + for pack in packs: + pages = pack.get("annual_year_pages") if isinstance(pack.get("annual_year_pages"), list) else [] + if not pages: + chart = pack.get("annual_chart") if isinstance(pack.get("annual_chart"), dict) else {} + data = chart.get("data") if isinstance(chart.get("data"), dict) else chart + longitudes = data.get("planet_longitudes") if isinstance(data.get("planet_longitudes"), dict) else {} + profile = pack.get("profile") if isinstance(pack.get("profile"), dict) else {} + pages = [{"target_year": profile.get("target_year"), "planet_longitudes": longitudes}] if longitudes else [] + for page in pages: + if not isinstance(page, dict): + continue + longitudes = page.get("planet_longitudes") if isinstance(page.get("planet_longitudes"), dict) else {} + for planet, longitude in longitudes.items(): + if not isinstance(longitude, (int, float)) or isinstance(longitude, bool): + continue + key = (page.get("target_year"), planet) + if key in seen: + continue + seen.add(key) + rows.append({ + "year": page.get("target_year"), + "planet": planet, + "sign": SIGNS[int(float(longitude) // 30) % 12], + "longitude": round(float(longitude), 6), + }) + return _rows_from(rows, language, [("year", "text"), ("planet", "planet"), ("sign", "sign"), ("longitude", "text")]) + + def _annual_saham_trace(packet: dict, language: str) -> str: pack = _first(packet, ("timing_and_predictive_systems", "annual_tajika_pack")) or {} sahams = _pack_data(pack.get("sahams") if isinstance(pack, dict) else None) @@ -2500,18 +2549,61 @@ _BUILDERS = { "Tripataki Chakra Detail": _tripataki, "Annual Tajika Strength": _tajika_strength, "Annual Saham Formula Trace": _annual_saham_trace, + "Annual Chart Numeric Data": _annual_numeric, } +def _ephemeris(packet: dict, language: str) -> str: + pack = _first(packet, ("timing_and_predictive_systems", "report_month_ephemeris")) or {} + if not isinstance(pack, dict) or not pack.get("report_rows"): + return _blank(language, ["month", "planet", "sign", "longitude"]) + columns = [("month", "text"), ("planet", "planet"), ("sign", "sign"), ("longitude", "text")] + report_rows = pack.get("report_rows") or [] + kp_rows = pack.get("kp_rows") or [] + if language == "zh": + report_note = f"报告口径,{len(report_rows)} 行。首月是报告日期所在月。" + kp_note = f"KP 岁差口径,{len(kp_rows)} 行。黄经仍走本系统恒星计算,不用视位置。" + else: + report_note = f"Report frame, {len(report_rows)} rows. The first month is the report month." + kp_note = f"KP ayanamsa frame, {len(kp_rows)} rows. Longitudes still use this system's sidereal calculation, not apparent position." + parts = [ + report_note, + _rows_from(report_rows, language, columns), + kp_note, + _rows_from(kp_rows, language, columns), + ] + levels = pack.get("vimshottari_rows") if isinstance(pack.get("vimshottari_rows"), list) else [] + if levels: + parts.append(_rows_from( + levels, + language, + [ + ("month", "text"), + ("mahadasha", "planet"), + ("antardasha", "planet"), + ("pratyantardasha", "planet"), + ("sookshma", "planet"), + ("prana", "planet"), + ], + )) + elif pack.get("vimshottari_reason"): + parts.append( + "五级大运月采样没有算出。" + if language == "zh" + else "The five-level monthly sample was not calculated." + ) + return "\n\n".join(part for part in parts if part) + + def _chapter_sections() -> list[list[str]]: order = [title for title, _zh in SECTIONS] groups = [ order[:14], - order[14:20], - order[20:35], - order[35:46], - order[46:63], - order[63:], + order[14:21], + order[21:36], + order[36:47], + order[47:65], + order[65:], ] return groups @@ -2568,6 +2660,7 @@ def _render_named(packet: dict, language: str, title: str) -> str: "Multi-Reference Transit": lambda: _multi_transit(packet, language), "Double Transit PAC": lambda: _double_transit(packet, language), "Lagna / 7th Lord Transit": lambda: _lord_transit(packet, language), + "Three-Year Predictive Ephemeris": lambda: _ephemeris(packet, language), "Natal Calculation Conventions": lambda: _settings(packet, language), "KP Calculation Conventions": lambda: _kp(packet, language), "D9 Dignity": lambda: _d9(packet, language), diff --git a/tests/test_report_ephemeris.py b/tests/test_report_ephemeris.py new file mode 100644 index 00000000..6e52821e --- /dev/null +++ b/tests/test_report_ephemeris.py @@ -0,0 +1,53 @@ +"""Report-month ephemeris uses the same sidereal longitudes as the chart function.""" + +from __future__ import annotations + +from scripts.annual_native.report_ephemeris import build_report_ephemeris +from scripts.jyotish_engine import compute_chart_data +from scripts.report_render.parity_chapters import render_parity_chapters +from tests.report_english_fictional import fictional_case + +LIMIT = 1e-6 + + +def _delta(left: float, right: float) -> float: + return abs(((left - right + 180.0) % 360.0) - 180.0) + + +def test_report_frame_moon_matches_the_chart_function() -> None: + birth = fictional_case(0) + pack = build_report_ephemeris( + birth_year=birth["year"], + birth_month=birth["month"], + birth_day=birth["day"], + birth_hour=birth["hour"], + birth_minute=birth["minute"], + birth_second=birth["second"], + latitude=birth["lat"], + longitude=birth["lon"], + tz_hours=birth["tz"], + ayanamsa=birth["ayanamsa"], + node_mode=birth["node_mode"], + start_year=2026, + start_month=4, + start_source="report_date", + month_count=1, + ) + assert pack["start_month"] == "2026-04" + assert len(pack["report_rows"]) == 9 + assert len(pack["kp_rows"]) == 9 + assert len(pack["vimshottari_rows"]) == 1 + moon = next(row for row in pack["report_rows"] if row["planet"] == "Moon") + again, *_rest = compute_chart_data( + 2026, 4, 1, 12, 0, + birth["lat"], birth["lon"], birth["tz"], + node_mode=birth["node_mode"], second=0, ayanamsa_name=birth["ayanamsa"], + ) + assert _delta(float(moon["degree_raw"]), float(again["planets"]["Moon"]["degree_raw"])) <= LIMIT + text = render_parity_chapters( + {"worksheets": {"timing_and_predictive_systems": {"report_month_ephemeris": pack}}}, + "en", + ) + assert "Three-Year Predictive Ephemeris" in text + assert "Report frame, 9 rows." in text + assert "KP ayanamsa frame, 9 rows." in text