From 1c60926a13012a8cdf3de7477003a2a1199b26c7 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Thu, 22 Oct 2026 23:00:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=95=B0=E6=8D=AE=E7=89=88?= =?UTF-8?q?=E7=9A=84=20Yogi=20/=20Ava=20Yogi=20=E4=BB=A5=E5=89=8D=E5=9B=A0?= =?UTF-8?q?=E7=BC=BA=E5=B0=91=E6=A8=A1=E5=9D=97=E8=A2=AB=E8=AE=B0=E6=88=90?= =?UTF-8?q?=20blocked=E3=80=82=E6=94=B9=E4=B8=BA=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E6=9C=AC=E5=91=BD=E6=97=A5=E6=9C=88=E9=BB=84=E7=BB=8F=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=EF=BC=8C=E7=A9=BA=E5=80=99=E9=80=89=E5=92=8C=E7=A9=BA?= =?UTF-8?q?=E8=90=BD=E5=85=A5=E8=A1=8C=E6=98=9F=E4=B8=8D=E5=86=8D=E5=8D=B0?= =?UTF-8?q?=E4=B8=80=E6=95=B4=E8=A1=8C=E6=9C=AA=E5=88=A4=E5=AE=9A=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor --- CHANGELOG.md | 5 + docs/BUG_HISTORY.md | 16 ++ scripts/annual_native/__init__.py | 5 + scripts/annual_native/yogi_sphuta.py | 247 +++++++++++++++++++++++ scripts/jyotish_engine.py | 65 ++++-- scripts/report_render/parity_chapters.py | 51 ++++- tests/test_full_data_parity_chapters.py | 56 +++++ tests/test_yogi_sphuta_native.py | 86 ++++++++ 8 files changed, 506 insertions(+), 25 deletions(-) create mode 100644 scripts/annual_native/__init__.py create mode 100644 scripts/annual_native/yogi_sphuta.py create mode 100644 tests/test_yogi_sphuta_native.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a9bd01a..d922f42a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-10-22 — 完整数据版写出 Jaimini Yogi 与 Ava Yogi 坐标 + +- 这一节以前因为找不到计算模块,每张盘都是 blocked。现在用本命太阳、月亮黄经按 93°20′ 和 186°40′ 算出两点,中英附录各有一张表。空的 Tajika 候选改成一句「本年没有候选」;Goel 星座强度里没有落入行星时写「无」。 +- Skill 版本不变(BUG-1420)。 + ## 2026-10-22 — 星盘大运右栏可以切到 Yogini - 大运页左栏仍是 Vimshottari。右栏默认 Chara,可以切到 Yogini。点开当前大运能看到小运。Yogini 的小运下面写「小运未经外部软件核对。」 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 4cf26672..8f7dd65c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -37275,3 +37275,19 @@ - 相关记录:BUG-1416。issue #91。 - 复发自:无(#91 A 段第 1 次打回的残留)。 - 修复版本:分支 `codex/full-data-parity-1-fix2-20261022`。 + +## BUG-1420 | Yogi / Ava Yogi 每张盘都是 blocked + +- 状态:resolved +- 首次发现:2026-10-21 +- 最近更新:2026-10-22 +- 影响面:完整数据版「Jaimini Yogi 与 Ava Yogi」;`modules.jaimini.yogi_avayogi_local`。 +- 用户现象:这一节不出坐标。资料包里该点的状态一直是 blocked。 +- 触发条件:任意本命盘走 full-reading 组装 Jaimini。 +- 根因:`jyotish_engine` 执行 `from yogi_sphuta import build_yogi_sphuta_local`,仓库里没有这个模块。`ImportError` 被包成 `status: blocked`、`reason: yogi_sphuta_local_unavailable:ModuleNotFoundError`。计算本身只需要盘上已经算好的日、月黄经,不需要再排一次盘。 +- 修复:模块放进 `scripts/annual_native/yogi_sphuta.py`,读 `chart.planets.Sun/Moon.degree_raw`。导入失败写入 `report.errors` 并带上 `ImportError` 原文,不再记成 blocked。算出的点写入 `yogi_avayogi_local`,中英附录出表。 +- 验证:`tests/test_yogi_sphuta_native.py`(虚构盘日、月与 `degree_raw` 差 ≤ 1e-6°;公式 93°20′ / 186°40′)。`tests/test_full_data_parity_chapters.py` 三张虚构盘中英都有 Yogi 与 Ava Yogi,格子不是「未判定」。 +- 防复发:引擎源码不得再出现 `yogi_sphuta_local_unavailable`。缺经度时 reason 写 `sun_or_moon_longitude_missing`。 +- 相关记录:issue #91 T4。 +- 复发自:无。 +- 修复版本:分支 `codex/full-data-parity-1-20261021`。 diff --git a/scripts/annual_native/__init__.py b/scripts/annual_native/__init__.py new file mode 100644 index 00000000..2e6c583f --- /dev/null +++ b/scripts/annual_native/__init__.py @@ -0,0 +1,5 @@ +"""Native annual and special-point producers for issue #91 section B. + +Longitudes come from this repository's chart functions. This package does not +import PyJHora and does not use Moshier or apparent-position flags. +""" diff --git a/scripts/annual_native/yogi_sphuta.py b/scripts/annual_native/yogi_sphuta.py new file mode 100644 index 00000000..9517f4e2 --- /dev/null +++ b/scripts/annual_native/yogi_sphuta.py @@ -0,0 +1,247 @@ +"""Local Yogi/Ava Yogi Sphuta from a chart that is already computed. + +The Sun and Moon longitudes are read from ``chart.planets.*.degree_raw``. +This module does not call an ephemeris of its own. +""" + +from __future__ import annotations + +import math +from bisect import bisect_right +from copy import deepcopy +from typing import Any + + +SCHEMA = "jyotish.yogi_sphuta.local.v1" +FORMULA_PROFILE = "pyjhora_yogi_sphuta_v1" +SIGNS = ( + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", +) +SIGN_LORDS = { + "Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Cancer": "Moon", + "Leo": "Sun", "Virgo": "Mercury", "Libra": "Venus", "Scorpio": "Mars", + "Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn", "Pisces": "Jupiter", +} +NAKSHATRA_NAMES = ( + "Ashwini", "Bharani", "Krittika", "Rohini", "Mrigashira", "Ardra", "Punarvasu", + "Pushya", "Ashlesha", "Magha", "Purva Phalguni", "Uttara Phalguni", "Hasta", + "Chitra", "Swati", "Vishakha", "Anuradha", "Jyeshtha", "Mula", "Purva Ashadha", + "Uttara Ashadha", "Shravana", "Dhanishta", "Shatabhisha", "Purva Bhadrapada", + "Uttara Bhadrapada", "Revati", +) +NAKSHATRA_LORDS = ( + "Ketu", "Venus", "Sun", "Moon", "Mars", "Rahu", "Jupiter", "Saturn", "Mercury", + "Ketu", "Venus", "Sun", "Moon", "Mars", "Rahu", "Jupiter", "Saturn", "Mercury", + "Ketu", "Venus", "Sun", "Moon", "Mars", "Rahu", "Jupiter", "Saturn", "Mercury", +) +NAKSHATRA_BOUNDARIES = tuple(index * 360.0 / 27.0 for index in range(27)) +YOGI_OFFSET = 93.0 + 20.0 / 60.0 +AVA_OFFSET = 186.0 + 40.0 / 60.0 + + +def _longitude(value: Any) -> float | None: + try: + if isinstance(value, bool): + return None + number = float(value) + return number % 360.0 if math.isfinite(number) else None + except (TypeError, ValueError): + return None + + +def _sign_from_longitude(longitude: float) -> str: + return SIGNS[int((float(longitude) % 360.0) // 30.0) % 12] + + +def _house_from_longitude(longitude: float, ascendant_sign: str | None) -> int | None: + if ascendant_sign not in SIGN_LORDS: + return None + asc_idx = SIGNS.index(ascendant_sign) + sign_idx = int((float(longitude) % 360.0) // 30.0) % 12 + return ((sign_idx - asc_idx) % 12) + 1 + + +def _point(longitude: float, ascendant_sign: str | None = None) -> dict[str, Any]: + value = float(longitude) % 360.0 + sign = _sign_from_longitude(value) + nakshatra_index = bisect_right(NAKSHATRA_BOUNDARIES, value) - 1 + return { + "longitude": round(value, 8), + "sign": sign, + "degree_in_sign": round(value % 30.0, 8), + "nakshatra": NAKSHATRA_NAMES[nakshatra_index], + "nakshatra_index": nakshatra_index, + "nakshatra_lord": NAKSHATRA_LORDS[nakshatra_index], + "sign_lord": SIGN_LORDS[sign], + "house": _house_from_longitude(value, ascendant_sign), + } + + +def build_yogi_sphuta_local(chart: dict[str, Any]) -> dict[str, Any]: + """Calculate local Yogi/Ava Yogi points without interpretive claims.""" + if not isinstance(chart, dict): + return { + "schema": SCHEMA, + "status": "blocked", + "reason": "chart_payload_missing", + "formula_profile": FORMULA_PROFILE, + } + planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {} + sun = planets.get("Sun") if isinstance(planets.get("Sun"), dict) else {} + moon = planets.get("Moon") if isinstance(planets.get("Moon"), dict) else {} + sun_lon = _longitude(sun.get("degree_raw", sun.get("lon", sun.get("degree")))) + moon_lon = _longitude(moon.get("degree_raw", moon.get("lon", moon.get("degree")))) + if sun_lon is None or moon_lon is None: + return { + "schema": SCHEMA, + "status": "blocked", + "reason": "sun_or_moon_longitude_missing", + "formula_profile": FORMULA_PROFILE, + } + ascendant = chart.get("ascendant") if isinstance(chart.get("ascendant"), dict) else {} + ascendant_sign = ascendant.get("sign") if isinstance(ascendant.get("sign"), str) else None + yogi_longitude = (sun_lon + moon_lon + YOGI_OFFSET) % 360.0 + ava_longitude = (yogi_longitude + AVA_OFFSET) % 360.0 + return { + "schema": SCHEMA, + "status": "partial_verified", + "formula_profile": FORMULA_PROFILE, + "formula": "sidereal_sun_longitude + sidereal_moon_longitude + 93d20m; Ava Yogi = Yogi + 186d40m", + "claim_boundary": ( + "Local Yogi/Ava Yogi Sphuta is a restricted supplementary point producer. " + "It cannot create event, wealth, timing, remedy, or final-judgment claims by itself." + ), + "source_fields": ["chart.planets.Sun.degree_raw", "chart.planets.Moon.degree_raw", "chart.ascendant.sign"], + "source_longitudes": {"Sun": sun_lon, "Moon": moon_lon}, + "yogi": _point(yogi_longitude, ascendant_sign), + "ava_yogi": _point(ava_longitude, ascendant_sign), + } + + +def build_luminary_sphuta_report( + *, + sun_longitude: float, + moon_longitude: float, + ayanamsa: str, + ascendant_sign: str | None = None, +) -> dict: + """Supplied sidereal longitudes; sum/difference plus the existing Yogi profile. + + No ephemeris, period or event prediction is inferred from these points. + """ + try: + from scripts.ayanamsa_utils import normalize_ayanamsa_name + except ImportError: + from ayanamsa_utils import normalize_ayanamsa_name + if not isinstance(ayanamsa, str) or not ayanamsa.strip(): + raise ValueError("explicit ayanamsa required") + mode = normalize_ayanamsa_name(ayanamsa) + for number in (sun_longitude, moon_longitude): + if ( + isinstance(number, bool) + or not isinstance(number, (int, float)) + or not math.isfinite(number) + or not 0 <= number < 360 + ): + raise ValueError("sidereal absolute longitudes must be finite in [0,360)") + if ascendant_sign is not None and ascendant_sign not in SIGNS: + raise ValueError("unknown ascendant sign") + local = build_yogi_sphuta_local({ + "planets": { + "Sun": {"degree_raw": sun_longitude}, + "Moon": {"degree_raw": moon_longitude}, + }, + "ascendant": {"sign": ascendant_sign}, + }) + points = { + "tithi_sphuta": _point((moon_longitude - sun_longitude) % 360, ascendant_sign), + "yoga_sphuta": _point((moon_longitude + sun_longitude) % 360, ascendant_sign), + "yogi": local["yogi"], + "ava_yogi": local["ava_yogi"], + } + evidence = { + "schema": "jyotish.luminary_sphuta.v1", + "status": "calculated", + "variant_profile_id": "jh761_luminary_existing_yogi_v1", + "tradition_profile": "jh761_luminary_pyjhora_yogi", + "ayanamsa": mode, + "points": points, + "house_basis": "whole_sign" if ascendant_sign else "not_supplied", + "duplicate_yogi": points["yogi"]["sign_lord"], + "formulae": { + "tithi_sphuta": "(Moon-Sun)%360", + "yoga_sphuta": "(Moon+Sun)%360", + "yogi": "(Moon+Sun+93d20m)%360", + "ava_yogi": "(Yogi+186d40m)%360", + }, + "sources": [ + "https://www.vedicastrologer.org/jh/update_7.61.htm", + ], + "claim_boundary": ( + "Calculated sensitive points only; no Dasha, event dates or standalone outcome verdict. " + "Supplied ayanamsa labels input provenance, not conversion." + ), + } + lines = [ + "# Luminary sphuta", + "", + evidence["claim_boundary"], + "", + "| Point | Longitude | Sign | Nakshatra | Lord |", + "| --- | --- | --- | --- | --- |", + ] + for key, point in points.items(): + lines.append( + f"| {key} | {point['longitude']} | {point['sign']} | {point['nakshatra']} | {point['nakshatra_lord']} |" + ) + return { + "status": "calculated", + "customer_export_admitted": False, + "evidence_appendix": [evidence], + "exports": {"ai_evidence_bundle": deepcopy(evidence), "markdown": "\n".join(lines)}, + } + + +def compare_yogi_sphuta_to_replay( + local: dict[str, Any], + replay: dict[str, Any], + tolerance_deg: float = 0.05, +) -> dict[str, Any]: + """Compare local points to an external replay without making multi-engine claims.""" + if not isinstance(local, dict) or local.get("status") != "partial_verified": + return {"status": "blocked", "reason": "local_yogi_sphuta_unavailable"} + if not isinstance(replay, dict) or replay.get("status") != "partial_verified": + return {"status": "blocked", "reason": "external_yogi_replay_unavailable"} + rows = [] + for field, key in (("Yogi", "yogi"), ("Ava Yogi", "ava_yogi")): + local_point = local.get(key) if isinstance(local.get(key), dict) else {} + replay_point = replay.get(key) if isinstance(replay.get(key), dict) else {} + local_lon = _longitude(local_point.get("longitude")) + replay_lon = _longitude(replay_point.get("longitude")) + if local_lon is None or replay_lon is None: + status = "blocked" + delta = None + else: + delta = abs((local_lon - replay_lon + 180.0) % 360.0 - 180.0) + status = "match" if delta <= tolerance_deg else "mismatch" + rows.append({ + "field": field, + "status": status, + "local_longitude": local_lon, + "external_longitude": replay_lon, + "delta_deg": round(delta, 8) if delta is not None else None, + }) + if all(row["status"] == "match" for row in rows): + status = "partial_verified" + elif any(row["status"] == "mismatch" for row in rows): + status = "parameter_sensitive" + else: + status = "blocked" + return { + "schema": "jyotish.yogi_sphuta.local_external_comparison.v1", + "status": status, + "claim_boundary": "This is same-formula local-vs-replay alignment, not independent multi-engine parity.", + "tolerance_deg": tolerance_deg, + "rows": rows, + } diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index ff8e4de8..15da82e6 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -16240,15 +16240,32 @@ def cmd_full_reading(args): jaimini_result['special_lagnas'] = calc_special_lagnas(asc_idx, args.hour, args.minute + _arg_second(args) / 60.0) # Restricted Yogi/Ava Yogi point producer: coordinates only, no verdict text. + # ImportError is logged with the missing module. It is not stored as status=blocked. + yogi_sphuta_mod = None try: - from yogi_sphuta import build_yogi_sphuta_local - jaimini_result['yogi_avayogi_local'] = build_yogi_sphuta_local(report.get('chart') or {}) - except Exception as yogi_local_exc: - jaimini_result['yogi_avayogi_local'] = { - 'schema': 'jyotish.yogi_sphuta.local.v1', - 'status': 'blocked', - 'reason': f'yogi_sphuta_local_unavailable:{yogi_local_exc.__class__.__name__}', - } + try: + from annual_native import yogi_sphuta as yogi_sphuta_mod + except ImportError: + from scripts.annual_native import yogi_sphuta as yogi_sphuta_mod + except ImportError as yogi_import_exc: + report['errors'].append(f"yogi_sphuta: ImportError: {yogi_import_exc}") + if yogi_sphuta_mod is not None: + try: + jaimini_result['yogi_avayogi_local'] = yogi_sphuta_mod.build_yogi_sphuta_local( + report.get('chart') or {} + ) + except Exception as yogi_local_exc: + report['errors'].append( + f"yogi_sphuta: {yogi_local_exc.__class__.__name__}: {yogi_local_exc}" + ) + jaimini_result['yogi_avayogi_local'] = { + 'schema': 'jyotish.yogi_sphuta.local.v1', + 'status': 'blocked', + 'reason': ( + f'yogi_sphuta_calculation_failed:' + f'{yogi_local_exc.__class__.__name__}:{yogi_local_exc}' + ), + } # External raw replay: comparison support only, never a strict verdict input. try: @@ -16275,18 +16292,26 @@ def cmd_full_reading(args): 'evidence_scope': 'pyjhora_behavior_only', 'parity_status': 'not_multiengine_parity', } - try: - from yogi_sphuta import compare_yogi_sphuta_to_replay - jaimini_result['yogi_avayogi_local_external_comparison'] = compare_yogi_sphuta_to_replay( - jaimini_result.get('yogi_avayogi_local') or {}, - jaimini_result.get('yogi_avayogi_external_replay') or {}, - ) - except Exception as yogi_cmp_exc: - jaimini_result['yogi_avayogi_local_external_comparison'] = { - 'schema': 'jyotish.yogi_sphuta.local_external_comparison.v1', - 'status': 'blocked', - 'reason': f'yogi_sphuta_comparison_unavailable:{yogi_cmp_exc.__class__.__name__}', - } + if yogi_sphuta_mod is not None: + try: + jaimini_result['yogi_avayogi_local_external_comparison'] = ( + yogi_sphuta_mod.compare_yogi_sphuta_to_replay( + jaimini_result.get('yogi_avayogi_local') or {}, + jaimini_result.get('yogi_avayogi_external_replay') or {}, + ) + ) + except Exception as yogi_cmp_exc: + report['errors'].append( + f"yogi_sphuta comparison: {yogi_cmp_exc.__class__.__name__}: {yogi_cmp_exc}" + ) + jaimini_result['yogi_avayogi_local_external_comparison'] = { + 'schema': 'jyotish.yogi_sphuta.local_external_comparison.v1', + 'status': 'blocked', + 'reason': ( + f'yogi_sphuta_comparison_failed:' + f'{yogi_cmp_exc.__class__.__name__}:{yogi_cmp_exc}' + ), + } # Darakaraka 深度解读(v6.1.10) # Registry 已将 modules.jaimini.darakaraka 标为 covered;这里把独立 DK diff --git a/scripts/report_render/parity_chapters.py b/scripts/report_render/parity_chapters.py index b6dd667c..4930893d 100644 --- a/scripts/report_render/parity_chapters.py +++ b/scripts/report_render/parity_chapters.py @@ -205,6 +205,7 @@ SECTIONS: tuple[tuple[str, str], ...] = ( ("Arudha / UL / A10", "Arudha、Upapada 与 A10"), ("Jaimini Calculation Details", "Jaimini 计算细节"), ("Graha Pada", "Graha Pada"), + ("Jaimini Yogi and Ava Yogi", "Jaimini Yogi 与 Ava Yogi"), ("Karakamsha", "Karakamsha"), ("Darakaraka", "Darakaraka"), ("Rashi Tulya Navamsa", "Rashi Tulya Navamsa"), @@ -228,7 +229,6 @@ FORBIDDEN_TITLES = ( "Tripataki Chakra Detail", "Annual Saham Formula Trace", "Natal Saham Formula Trace", - "Jaimini Yogi and Ava Yogi", "Annual Chart Numeric Data", "Bhava Bala Relative Score Components", "Score Extremes", @@ -884,6 +884,8 @@ def _tajika_candidates(packet: dict, language: str) -> str: "residual": item.get("residual"), "status": item.get("present") if "present" in item else item.get("motion") or item.get("status"), }) + if not rows: + return "本年没有候选" if language == "zh" else "No candidates this year" return _rows_from(rows, language, [("name", "text"), ("planet", "planets"), ("aspect", "text"), ("residual", "text"), ("status", "text")]) @@ -1148,11 +1150,18 @@ def _goel(packet: dict, language: str) -> str: for item in strength.get("rashi_strengths") or []: if not isinstance(item, dict): continue + occupants = item.get("occupants") + if isinstance(occupants, list) and not occupants: + occupant_cell = "无" if language == "zh" else "None" + elif isinstance(occupants, list): + occupant_cell = planet_list(occupants, language) or ("无" if language == "zh" else "None") + else: + occupant_cell = occupants rashi_rows.append({ "sign": item.get("sign"), "lord": item.get("lord"), "permanent": item.get("permanent"), - "occupant": item.get("occupants"), + "occupant": occupant_cell, "total": item.get("total"), "example_total": example_by_sign.get(item.get("sign")), }) @@ -1171,7 +1180,7 @@ def _goel(packet: dict, language: str) -> str: citation, intro, _rows_from(planet_rows, language, [("planet", "planet"), ("sign", "sign"), ("degree", "text"), ("dignity", "text"), ("total", "text"), ("example_total", "text")]), - _rows_from(rashi_rows, language, [("sign", "sign"), ("lord", "planet"), ("permanent", "text"), ("occupant", "planets"), ("total", "text"), ("example_total", "text")]), + _rows_from(rashi_rows, language, [("sign", "sign"), ("lord", "planet"), ("permanent", "text"), ("occupant", "text"), ("total", "text"), ("example_total", "text")]), _rows_from(point_rows, language, [("point", "text"), ("status", "text"), ("value", "planet"), ("rule", "text")]), ]) @@ -2225,6 +2234,37 @@ def _tara_name(entry: dict, key_en: str, key_zh: str, language: str) -> str | No return head or None +def _yogi_avayogi(packet: dict, language: str) -> str: + jaimini = _first(packet, ("advanced_systems", "jaimini")) or {} + local = jaimini.get("yogi_avayogi_local") if isinstance(jaimini, dict) else None + if not isinstance(local, dict) or local.get("status") == "blocked": + return _blank(language, ["point", "sign", "degree", "nakshatra", "lord", "house"]) + rows = [] + for key, label in (("yogi", "Yogi"), ("ava_yogi", "Ava Yogi")): + point = local.get(key) if isinstance(local.get(key), dict) else None + if not point: + continue + rows.append({ + "point": label, + "sign": point.get("sign"), + "degree": point.get("degree_in_sign"), + "nakshatra": point.get("nakshatra"), + "lord": point.get("nakshatra_lord"), + "house": point.get("house"), + }) + if not rows: + return _blank(language, ["point", "sign", "degree", "nakshatra", "lord", "house"]) + note = ( + "由本命太阳、月亮黄经按 93°20′ 与 186°40′ 算出,只给坐标,不单独下判断。" + if language == "zh" + else "Computed from the natal Sun and Moon by 93°20' and 186°40'. Coordinates only; not a judgment by itself." + ) + return note + "\n\n" + _rows_from(rows, language, [ + ("point", "text"), ("sign", "sign"), ("degree", "text"), + ("nakshatra", "text"), ("lord", "planet"), ("house", "text"), + ]) + + def _tara(packet: dict, language: str) -> str: module = _first(packet, ("advanced_systems", "nakshatra_adv")) or {} combined = module.get("tara_chandra_combined") if isinstance(module, dict) else None @@ -2371,8 +2411,8 @@ def _chapter_sections() -> list[list[str]]: order[14:17], order[17:32], order[32:43], - order[43:58], - order[58:], + order[43:59], + order[59:], ] return groups @@ -2418,6 +2458,7 @@ def _render_named(packet: dict, language: str, title: str) -> str: "Arudha / UL / A10": lambda: _special_points(packet, language), "Jaimini Calculation Details": lambda: _jaimini_details(packet, language), "Graha Pada": lambda: _graha_pada(packet, language), + "Jaimini Yogi and Ava Yogi": lambda: _yogi_avayogi(packet, language), "Karakamsha": lambda: _one_point(packet, language, "karakamsha"), "Darakaraka": lambda: _one_point(packet, language, "darakaraka"), "Rashi Tulya Navamsa": lambda: _rashi_tulya(packet, language), diff --git a/tests/test_full_data_parity_chapters.py b/tests/test_full_data_parity_chapters.py index fd544e62..8376b1d8 100644 --- a/tests/test_full_data_parity_chapters.py +++ b/tests/test_full_data_parity_chapters.py @@ -144,6 +144,12 @@ def test_three_fictional_charts_render_the_same_chapters(fictional_reports: dict assert "印刷页 10" in zh and "printed pages 10-17" in en assert "#### 来源感知口径" in zh and "#### 既有口径" in zh assert "#### Source-aware method" in en and "#### Legacy method" in en + zh_yogi = _section_body(zh, "Jaimini Yogi 与 Ava Yogi") + en_yogi = _section_body(en, "Jaimini Yogi and Ava Yogi") + assert "Yogi" in zh_yogi and "Ava Yogi" in zh_yogi + assert "Yogi" in en_yogi and "Ava Yogi" in en_yogi + assert "未判定" not in zh_yogi + assert "Not determined" not in en_yogi assert "再小运" in _section_body(zh, "Chara 大运时长") assert "Pratyantardasha" in _section_body(en, "Chara Dasha Period Durations") assert "marriage_windows" not in _section_body(zh, "Bhrigu 近似推进序列") @@ -363,6 +369,56 @@ def test_chinese_yoga_combinations_are_not_blanked(fictional_reports: dict[int, assert HAN.findall(english) == [] +def test_empty_tajika_candidates_are_one_sentence() -> None: + """Acceptance note: an empty candidate list is a sentence, not a row of 未判定.""" + packet = { + "worksheets": { + "timing_and_predictive_systems": { + "annual_tajika_pack": {"tajika_yogas": {"data": {"candidate_yogas": []}}}, + } + } + } + chinese = render_parity_chapters(packet, "zh") + english = render_parity_chapters(packet, "en") + zh_body = _section_body(chinese, "Tajika 相位与瑜伽候选") + en_body = _section_body(english, "Tajika Aspect and Yoga Candidates") + assert "本年没有候选" in zh_body + assert "No candidates this year" in en_body + assert "未判定" not in zh_body + assert "Not determined" not in en_body + assert "|" not in zh_body + + +def test_empty_goel_occupants_say_none() -> None: + """Acceptance note: an empty sign-strength occupant cell is 无 / None.""" + packet = { + "worksheets": { + "advanced_systems": { + "jaimini": { + "goel_full_strength": { + "status": "computed", + "atmakaraka": "Sun", + "node_occupancy_policy": "include_nodes_text", + "planets": { + "Sun": {"sign_idx": 0, "degree_in_sign": 1.0, "dignity": "own", "total": 10}, + }, + "rashi_strengths": [ + {"sign": "Aries", "lord": "Mars", "permanent": 1, "occupants": [], "total": 10}, + ], + } + } + } + } + } + chinese = render_parity_chapters(packet, "zh") + english = render_parity_chapters(packet, "en") + zh_body = _section_body(chinese, "Goel Jaimini 强度与特殊点") + en_body = _section_body(english, "Goel Jaimini Strength and Special Points") + assert "| 白羊座 | 火星 | 1 | 无 | 10 |" in zh_body or "| 白羊 | 火星 | 1 | 无 | 10 |" in zh_body + assert "None" in en_body + assert "未判定" not in zh_body.split("落入行星", 1)[-1].split("\n", 3)[2] + + def test_chart_nine_partial_kaal_and_friendship(fictional_reports: dict[int, tuple[str, str]]) -> None: chinese, english = fictional_reports[9] assert _KAAL_ZH.search(chinese) diff --git a/tests/test_yogi_sphuta_native.py b/tests/test_yogi_sphuta_native.py new file mode 100644 index 00000000..003a6aef --- /dev/null +++ b/tests/test_yogi_sphuta_native.py @@ -0,0 +1,86 @@ +"""BUG-1420: Yogi / Ava Yogi is computed from the chart, not left blocked.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scripts.annual_native.yogi_sphuta import ( + build_luminary_sphuta_report, + build_yogi_sphuta_local, + compare_yogi_sphuta_to_replay, +) +from scripts.jyotish_engine import compute_chart_data +from tests.report_english_fictional import fictional_case + +ROOT = Path(__file__).resolve().parents[1] +LIMIT = 1e-6 + + +def _delta(left: float, right: float) -> float: + return ((left - right + 180.0) % 360.0) - 180.0 + + +def test_formula_matches_the_declared_offsets() -> None: + result = build_yogi_sphuta_local({ + "ascendant": {"sign": "Aries"}, + "planets": {"Sun": {"degree_raw": 0.0}, "Moon": {"degree_raw": 0.0}}, + }) + assert result["status"] == "partial_verified" + assert result["formula_profile"] == "pyjhora_yogi_sphuta_v1" + assert result["yogi"]["longitude"] == pytest.approx(93.33333333) + assert result["yogi"]["sign"] == "Cancer" + assert result["ava_yogi"]["longitude"] == pytest.approx(280.0) + assert result["source_longitudes"] == {"Sun": 0.0, "Moon": 0.0} + + +def test_birth_longitudes_match_the_chart_within_one_microdegree() -> None: + birth = fictional_case(0) + chart, _asc, _jd, _ayanamsa = compute_chart_data( + birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"], + birth["lat"], birth["lon"], birth["tz"], + node_mode=birth["node_mode"], second=birth["second"], ayanamsa_name=birth["ayanamsa"], + ) + result = build_yogi_sphuta_local(chart) + assert result["status"] != "blocked" + for name in ("Sun", "Moon"): + assert abs(result["source_longitudes"][name] - chart["planets"][name]["degree_raw"]) <= LIMIT + expected = (result["source_longitudes"]["Sun"] + result["source_longitudes"]["Moon"] + 93.0 + 20.0 / 60.0) % 360.0 + assert abs(_delta(result["yogi"]["longitude"], expected)) <= LIMIT + + +def test_missing_longitude_states_the_reason() -> None: + result = build_yogi_sphuta_local({"planets": {"Sun": {"degree_raw": 1.0}}}) + assert result["status"] == "blocked" + assert result["reason"] == "sun_or_moon_longitude_missing" + + +def test_import_failure_is_not_stored_as_blocked() -> None: + source = (ROOT / "scripts" / "jyotish_engine.py").read_text(encoding="utf-8") + assert "yogi_sphuta_local_unavailable" not in source + assert "yogi_sphuta: ImportError:" in source + + +def test_luminary_bundle_and_replay_comparison() -> None: + report = build_luminary_sphuta_report(sun_longitude=350, moon_longitude=20, ayanamsa="lahiri", ascendant_sign="Aries") + points = report["evidence_appendix"][0]["points"] + assert points["yogi"]["longitude"] == pytest.approx(103.33333333) + local = build_yogi_sphuta_local({ + "ascendant": {"sign": "Aries"}, + "planets": {"Sun": {"degree_raw": 350.0}, "Moon": {"degree_raw": 20.0}}, + }) + comparison = compare_yogi_sphuta_to_replay(local, { + "status": "partial_verified", + "yogi": {"longitude": 103.33333333}, + "ava_yogi": {"longitude": 290.0}, + }) + assert comparison["status"] == "partial_verified" + assert "not independent multi-engine parity" in comparison["claim_boundary"] + + +@pytest.mark.parametrize("number", [True, float("nan"), float("inf")]) +def test_nonfinite_sun_is_blocked(number) -> None: + result = build_yogi_sphuta_local({"planets": {"Sun": {"degree_raw": number}, "Moon": {"degree_raw": 0}}}) + assert result["status"] == "blocked" + assert result["reason"] == "sun_or_moon_longitude_missing"