完整数据版的 Yogi / Ava Yogi 以前因缺少模块被记成 blocked。改为读取本命日月黄经计算,空候选和空落入行星不再印一整行未判定。

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-22 23:00:04 +08:00
co-authored by Grok (agent loop) Cursor
parent c1aa72fcbf
commit 1c60926a13
8 changed files with 506 additions and 25 deletions
+5
View File
@@ -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 的小运下面写「小运未经外部软件核对。」
+16
View File
@@ -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`。
+5
View File
@@ -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.
"""
+247
View File
@@ -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,
}
+45 -20
View File
@@ -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
+46 -5
View File
@@ -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),
+56
View File
@@ -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)
+86
View File
@@ -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"