feat(reading): fill the five full-reading modules missing from upstream

The western comparison layer was invoked without its files, so it stayed blocked. Navamsha and Niryaana Shoola are filled from the native rashi dashas.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-18 19:32:38 +08:00
co-authored by Grok (agent loop) Cursor
parent b3ef2d8158
commit 900581068e
11 changed files with 13869 additions and 1 deletions
+6
View File
@@ -1,5 +1,11 @@
# 印度占星 Skill 更新日志
## 2026-10-18 — 完整解读补上西占对照和五处缺失模块
- 个人报告里的西占对照层不再因为缺文件而整段空白。它仍只作对照,不拿来改印度占星的结论。
- 完整解读补上上升是否在九分盘同星座、Pushkara 行星简表、Punarphoo 观察,以及 Navamsha / Niryaana Shoola 两套大运。后两套用本站自己的算法,不换回上游那份已停用的算法。
- 页面没有单独的新入口。Skill 版本不变(BUG-1375、BUG-1376)。
## 2026-10-18 — 星盘档案最多保存 20 个人
- 星盘档案从最多 5 人(含本人)改为最多 20 人。第 19 个其他人可以保存,第 20 个其他人会被拒绝,提示「最多保存 20 个人的星盘」。建档仍不扣点。
+30
View File
@@ -36488,3 +36488,33 @@
- 相关记录:BUG-1361、BUG-1362。
- 复发自:无。
- 修复版本:分支 `codex/beta-safety-net-20261014`。本机没有装到生产主机,也没有点生产按钮。
## BUG-1375 | 完整解读的西占对照层永远空白
- 状态:代码已交,待验收
- 首次发现 / 最近更新:2026-10-18 / 2026-10-18
- 来源:issue #81。任务书 `docs/tasks/TASK-full-reading-module-parity-20261018.md`。
- 现象:完整解读里的西占对照一直是空白。原因写着找不到 `unified_western_reading`。
- 触发条件:任何一次 full-reading,只要给了参考日期。
- 根因:引擎里已经有调用,对应文件和它依赖的 `classical_astrology.py` 没有搬进本仓库。
- 修复:按上游 `bde5d615` 的副本搬入这两个文件。西占排盘只补了本仓没有的龙首盘和迁居盘函数,没有整份覆盖现有西占文件。特异性计分函数上游副本里没有,导入处返回「未移植、blocked」,不编造分数。
- 验证:两位公开名人的该模块状态不再是空白。把联网和外部程序打桩成抛错后,西占包本身的调用次数为 0,完整解读仍成功。
- 防复发:`tests/test_full_reading_module_parity.py` 检查模块能导入,并检查打桩后西占包不发起联网或外部程序。
- 相关记录:BUG-1376。
- 复发自:无。
- 修复版本:分支 `codex/full-reading-module-parity-20261018`。
## BUG-1376 | 完整解读比上游少五个模块,合盘的次要大运互证整项空白
- 状态:代码已交,待验收
- 首次发现 / 最近更新:2026-10-18 / 2026-10-18
- 来源:issue #81。任务书 `docs/tasks/TASK-full-reading-module-parity-20261018.md`。
- 现象:完整解读没有上升同星座、Pushkara 简表、Punarphoo、Navamsha 大运、Niryaana Shoola 大运这五个键。上游合盘校验因此把「次要大运互证」整项标成空白。
- 触发条件:对比上游 full-reading 的模块键;或用上游合盘校验器读两份完整解读。
- 根因:这几处是上游 2026 年 7 到 9 月加进 full-reading 的,同步时漏了。Punarphoo 文件其实已经在,只是没接上。
- 修复:前三项按上游函数接上。后两项用本仓 `rashi_dashas_native` 的 `goel_method_1_v1` 和 `goel_nirayana_shoola_v1` 填键,不调用已停用的 `niryaana_shoola_dasha.py`。外层状态给校验器读,期间表仍是本仓的。
- 验证:模块键覆盖上游那 86 个名字。两位公开名人的 Navamsha 首段星座和年数与上游 Goel Method 1 的算法一致。上游校验器里 Navamsha、Niryaana Shoola 两行不是空白,「次要大运互证」也不再是空白。屏蔽 PyJHora 后相关测试仍通过。
- 防复发:`tests/test_full_reading_module_parity.py` 固定 86 个键,并跑上游校验器。`tests/test_rashi_dashas_native.py` 仍禁止那三份文件导入已停用的大运模块。
- 相关记录:BUG-1283、BUG-1375。编号 1370~1374 由 issue #79 预留,本单从 1375 起。
- 复发自:无。
- 修复版本:分支 `codex/full-reading-module-parity-20261018`。
@@ -0,0 +1,46 @@
# 进度:full-reading 补齐上游 5 个模块(2026-10-18)
基线:`origin/staging` = `b3ef2d815`(任务书写作时写的 `47b4a2317` 已过时,开工以 fetch 后的最新 staging 为准)。分支 `codex/full-reading-module-parity-20261018`。
## 做了什么
- T3:`navamsha_dasha`、`niryaana_shoola_dasha` 用 `rashi_dashas_native.calculate_rashi_dashas` 的结果填键。profile id 仍是 `goel_method_1_v1` 和 `goel_nirayana_shoola_v1`,期间表原样放在 `periods`。外层 `status` 为 `parameter_sensitive`,给上游合盘校验器读。没有导入 `niryaana_shoola_dasha.py`。
- T2:搬入 `_calc_lagna_vargottamamsa`、`_summarize_pushkara_planets`,并在 full-reading 写入 `lagna_vargottamamsa`、`pushkara_planets`、`punarphoo`(调用已有的 `scripts/punarphoo.py`)。
- T1:按上游副本搬入 `scripts/unified_western_reading.py` 和 `scripts/classical_astrology.py`。西占排盘补了本仓没有的 `_birth_times`、`build_draconic_chart`、`build_relocation_chart`。加了 `scripts/_script_root.py`,因为搬来的文件会可选导入它,本仓原来没有这个文件。
- T4:`tests/test_full_reading_module_parity.py` 固定 86 个模块键。
- 两位公开名人用的是仓库里已有的样例(`tests/indastro_cases.json` 的 `brad_pitt`,`tests/celebrity_cases.json` 的 `jolie`),没有新抄出生时间。
## 耗时
同一进程里先跑一次预热,再各跑 3 次,取中位数。参考日 `2026-10-18`,ayanamsa `lahiri`,node `mean`。
| 样例 | 改前 3 次(秒) | 改前中位数 | 改后 3 次(秒) | 改后中位数 | 变化 |
| --- | --- | --- | --- | --- | --- |
| brad_pitt | 4.943 / 5.000 / 5.043 | 5.000 | 4.964 / 5.152 / 5.192 | 5.152 | +3.0% |
| jolie | 5.022 / 5.047 / 5.094 | 5.047 | 5.132 / 5.139 / 5.174 | 5.139 | +1.8% |
没有超过 20%。多出来的时间主要是西占各层和一次本仓七套 rashi 大运。
## Navamsha 首段
两位公开名人的首段星座序号和年数,与上游 `goel_method_1_v1` 用「D9 上升 + 宫主星在 D9 的位置」算出的首段一致,也与本仓用黄经直接取九分盘的算法一致。没有需要仲裁的差异。
## 没整份同步的西占差异
- `western_timing_engine.py` 里西占层要的函数,签名已经和上游副本一致,没有改这个文件。
- `build_tropical_natal_chart` 签名已一致。本仓这份还多了接口请求包装和岁差展示,没有用上游文件覆盖。
- 上游 `classical_astrology.py` 在导入时需要 `western_real_case_calibration._build_calibration_summary`。这个函数不在本单的上游副本里,本仓原来也没有。补了一个只返回 `blocked` 的垫片,键是调用方实际读取的那些,不编造特异性分数。完整解读里的 classical 层仍然是 `executed`。
- full-reading 不传迁居坐标,所以西占包里的 `relocation` 层是 `blocked`,整包状态是 `partial`。这和上游调用方式一样。挂到 full-reading 上的模块状态是 `parameter_sensitive`,不再是 `blocked`。
- 现有 VedAstro 适配器仍会尝试本地外部程序,失败被它自己接住。打桩测试确认这次调用栈不经过 `unified_western_reading.py` 或 `classical_astrology.py`;单独跑西占包时,`urlopen` 和 `subprocess.run` 的调用次数为 0。
## 检查
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 新测试 + 大运红线 + Punarphoo | `pytest tests/test_full_reading_module_parity.py tests/test_rashi_dashas_native.py tests/test_punarphoo_observation.py -q` | 27 passed |
| 屏蔽 PyJHora 后再跑新测试和大运红线 | 拦截 `jhora` 导入后跑上述前两个文件 | 19 passed |
| 改过的西占文件 | `pytest tests/test_western_chart_engine.py tests/test_western_real_case_calibration.py -q` | 8 passed |
| 全量 pytest | 未跑 | Windows 执行侧按循环约定不跑,留给验收 |
| 快速门 | 未跑 | 同上 |
解释器是本机 Anaconda,不是仓库 `.venv`(工作树里没有 `.venv`)。
+1 -1
View File
@@ -502,6 +502,6 @@
| `TASK-contracts-helpers-dedupe-20261013.md` | — | 前后端契约与小工具去重第一阶段 | 待领取(排队:frontend-dead-code、consult-route-split) | `codex/contracts-helpers-dedupe-20261013` |
| `TASK-people-archive-limit-20-20261017.md` | `PROGRESS-people-archive-limit-20-20261017.md` | 星盘档案上限 5→20 人(触发器新迁移 + 常量 + 文案;婚恋合盘前置单) | 待验收 | `codex/people-archive-limit-20-20261017` |
| `TASK-marriage-match-p1-20261017.md` | #79 | 婚恋合盘引擎(搬上游 bde5d615:marriage_match + 合盘证据包;35 分七项、不分男女;第六版不扣点、不做页面)(BUG-1370 起) | 待执行(不再等 #78) | `codex/marriage-match-p1-20261017`;动表 |
| `TASK-full-reading-module-parity-20261018.md` | #81 | full-reading 补齐上游 5 个模块(西占层文件没搬致永远 blocked、Navamsha / Niryaana 用我们自己的实现填键、Lagna Vargottamamsa / Pushkara / Punarphoo)(BUG-1375 起) | 待执行(可与 #78 并行) | `codex/full-reading-module-parity-20261018` |
| `TASK-full-reading-module-parity-20261018.md` | `PROGRESS-full-reading-module-parity-20261018.md` | #81 full-reading 补齐上游 5 个模块(西占层文件没搬致永远 blocked、Navamsha / Niryaana 用我们自己的实现填键、Lagna Vargottamamsa / Pushkara / Punarphoo)(BUG-1375、BUG-1376) | 待验收 | `codex/full-reading-module-parity-20261018` |
| `TASK-synastry-full-report-20261018.md` | #82 | 婚恋合盘:选人页 + 完整合盘报告(关系画像九节:结论+折叠的分析逻辑+关系时间线+技术依据摘要;英文按需;计费键 synastry.full) | 排队(等 #78、#79、#81) | `codex/synastry-full-report-20261018`;动表 |
| `TASK-synastry-report-chat-20261018.md` | #83 | 从合盘报告某一节进入对话问依据(绑定报告与节、对话里可用术语、只依据已存计算结果并逐条核对、计费照普通对话) | 排队(等 #82) | `codex/synastry-report-chat-20261018`;动表 |
+14
View File
@@ -0,0 +1,14 @@
"""Put the repository root on sys.path for scripts copied from upstream.
Those modules optionally import ``_script_root`` before their other imports.
This file only adjusts the import path. It does not calculate charts.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
root_text = str(ROOT)
if root_text not in sys.path:
sys.path.insert(0, root_text)
File diff suppressed because it is too large Load Diff
+81
View File
@@ -435,6 +435,52 @@ def _calc_pushkara_flags(planets):
return result
def _calc_lagna_vargottamamsa(report, varga_full):
chart = report.get('modules', {}).get('chart', {}) if isinstance(report, dict) else {}
ascendant = chart.get('ascendant', {}) if isinstance(chart, dict) else {}
d1_sign = ascendant.get('sign') if isinstance(ascendant, dict) else None
d9 = varga_full.get('D9_Navamsa', {}) if isinstance(varga_full, dict) else {}
d9_ascendant = d9.get('ascendant') if isinstance(d9, dict) else None
if not isinstance(d9_ascendant, dict) and isinstance(d9, dict):
d9_ascendant = d9.get('Ascendant', {})
d9_sign = d9_ascendant.get('sign') if isinstance(d9_ascendant, dict) else None
return {
'd1_sign': d1_sign,
'd9_sign': d9_sign,
'is_lagna_vargottamamsa': bool(d1_sign and d9_sign and d1_sign == d9_sign),
'source': 'raman_horoscope_v1_bounded_pp64',
}
def _summarize_pushkara_planets(flags):
"""Return the compact full-reading list without discarding raw flags."""
return [
f"{planet} ({data['sign']} {data['degree_in_sign']:.1f}°)"
for planet, data in flags.items()
if data.get('pushkara_navamsa') or data.get('pushkara_bhaga')
]
def _public_secondary_dasha_module(native):
"""Expose one native rashi dasha under the upstream full-reading key.
The period table and profile id stay ours. The outer status is what the
upstream synastry validator reads, and it is never a bare error object.
"""
if not isinstance(native, dict):
return {
'status': 'blocked',
'reason': 'native_rashi_dasha_missing',
'periods': [],
}
return {
'status': 'parameter_sensitive',
'profile_id': native.get('profile_id'),
'periods': list(native.get('periods') or []),
'native_result': native,
}
def _calc_sensitive_points(planets):
result = {}
try:
@@ -15551,7 +15597,9 @@ def cmd_full_reading(args):
report['errors'].append(f"yoga-context-injection: {yoga_ctx_e}")
report['modules']['vargottama'] = _calc_vargottama(planets, varga_result)
report['modules']['lagna_vargottamamsa'] = _calc_lagna_vargottamamsa(report, varga_result)
report['modules']['pushkara'] = _calc_pushkara_flags(planets)
report['modules']['pushkara_planets'] = _summarize_pushkara_planets(report['modules']['pushkara'])
report['modules']['sensitive_points'] = _calc_sensitive_points(planets)
except Exception as e:
report['errors'].append(f"varga-full: {e}")
@@ -16036,6 +16084,39 @@ def cmd_full_reading(args):
'status': 'blocked',
'reason': 'reference_date_missing',
}
try:
from punarphoo import detect_punarphoo
report['modules']['punarphoo'] = detect_punarphoo(planets)
except Exception as e:
report['modules']['punarphoo'] = {
'status': 'blocked',
'reason': f'punarphoo_producer_failed:{e}',
}
try:
from rashi_dashas_native import calculate_rashi_dashas
secondary_dashas = calculate_rashi_dashas(
_birth_datetime_from_args(args),
float(asc_deg),
planet_lons,
)
report['modules']['navamsha_dasha'] = _public_secondary_dasha_module(secondary_dashas.get('navamsha'))
report['modules']['niryaana_shoola_dasha'] = _public_secondary_dasha_module(
secondary_dashas.get('niryaana_shoola')
)
except Exception as e:
report['modules']['navamsha_dasha'] = {
'status': 'blocked',
'reason': f'navamsha_dasha_producer_failed:{e}',
'periods': [],
}
report['modules']['niryaana_shoola_dasha'] = {
'status': 'blocked',
'reason': f'niryaana_shoola_dasha_producer_failed:{e}',
'periods': [],
}
_record_stage_timing(
stage_timings,
'dasha_and_core_varga_stack',
+268
View File
@@ -0,0 +1,268 @@
"""Auditable orchestration for the native tropical Western calculation layers."""
from __future__ import annotations
try: # reached as a script: this directory is on the path, the root is not
import _script_root # noqa: F401
except ModuleNotFoundError: # pragma: no cover - reached as scripts.x instead
try:
from scripts import _script_root # noqa: F401
except ModuleNotFoundError:
pass
import argparse
import json
from datetime import date
from typing import Any, Callable
try:
import swisseph as swe
except ImportError: # pragma: no cover - optional runtime dependency
swe = None
try:
from scripts.classical_astrology import build_classical_astrology_packet, build_classical_usable_loop_audit_packet
from scripts.western_chart_engine import build_tropical_natal_chart
from scripts.western_chart_engine import build_draconic_chart, build_relocation_chart
from scripts.western_timing_engine import (
calculate_annual_profection,
calculate_converse_secondary_progressions,
calculate_converse_solar_arc_directions,
calculate_lunar_return,
calculate_lunar_return_series,
calculate_midpoints,
calculate_parans_status,
calculate_secondary_progressions,
calculate_solar_arc_directions,
calculate_solar_return,
calculate_tertiary_progressed_moon,
calculate_transit_duration_scan,
calculate_transit_to_natal,
calculate_zodiacal_release,
)
except ModuleNotFoundError: # pragma: no cover - direct script execution
from classical_astrology import build_classical_astrology_packet, build_classical_usable_loop_audit_packet
from western_chart_engine import build_tropical_natal_chart
from western_chart_engine import build_draconic_chart, build_relocation_chart
from western_timing_engine import (
calculate_annual_profection,
calculate_converse_secondary_progressions,
calculate_converse_solar_arc_directions,
calculate_lunar_return,
calculate_lunar_return_series,
calculate_midpoints,
calculate_parans_status,
calculate_secondary_progressions,
calculate_solar_arc_directions,
calculate_solar_return,
calculate_tertiary_progressed_moon,
calculate_transit_duration_scan,
calculate_transit_to_natal,
calculate_zodiacal_release,
)
def _executed(inputs: dict[str, Any], calculation: Callable[[], dict[str, Any]]) -> dict[str, Any]:
try:
return {"status": "executed", "input": inputs, "raw_result": calculation()}
except Exception as exc: # pragma: no cover - defensive orchestration boundary
return {
"status": "blocked",
"input": inputs,
"reason": f"{exc.__class__.__name__}: {exc}",
}
def _blocked(reason: str, inputs: dict[str, Any] | None = None) -> dict[str, Any]:
result: dict[str, Any] = {"status": "blocked", "reason": reason}
if inputs:
result["input"] = inputs
return result
def _reference_layer(
reference_date: str | None,
birth: dict[str, Any],
calculation: Callable[[str], dict[str, Any]],
) -> dict[str, Any]:
if not reference_date:
return _blocked("reference_date is required")
return _executed({"reference_date": reference_date}, lambda: calculation(reference_date))
def _overall_status(layers: dict[str, dict[str, Any]]) -> str:
return "complete" if all(layer["status"] == "executed" for layer in layers.values()) else "partial"
def _eclipse_events(reference_date: str) -> dict[str, Any]:
if swe is None:
raise RuntimeError("swisseph is unavailable")
start = date.fromisoformat(reference_date)
jd = swe.julday(start.year, start.month, start.day, 0.0)
events = []
for kind, finder in (("solar", swe.sol_eclipse_when_glob), ("lunar", swe.lun_eclipse_when)):
flags, times = finder(jd)
year, month, day, hour = swe.revjul(times[0])
events.append({"kind": kind, "maximum_utc": f"{year:04d}-{month:02d}-{day:02d}T{int(hour):02d}:{int((hour % 1) * 60):02d}:00Z", "swiss_event_flags": int(flags)})
return {"technique": "eclipse_triggers", "reference_date": reference_date, "events": events, "boundary": "Geometric eclipse triggers only; no predictive interpretation is inferred."}
def build_unified_western_reading(
*,
reference_date: str | None = None,
duration_scan_end_date: str | None = None,
lunar_return_months: int = 1,
relocation_latitude: float | None = None,
relocation_longitude: float | None = None,
relocation_timezone: str | float | int | None = None,
zodiacal_release_lot: str = "spirit",
zodiacal_release_levels: int = 2,
include_classical_usable_loop: bool = False,
classical_event_window_hours: int = 4,
classical_event_step_hours: int = 2,
classical_blind_scoring_manifest: str | None = None,
classical_reviewer_manifest: str | None = None,
**birth: Any,
) -> dict[str, Any]:
"""Run every available native Western layer and preserve raw evidence.
``reference_date`` drives the one-day timing techniques. A duration scan
additionally needs an explicit end date, so a caller cannot unknowingly
request an unbounded scan. Eclipse geometry uses the local Swiss Ephemeris
calculation and does not infer an event interpretation.
"""
layers: dict[str, dict[str, Any]] = {
"natal": _executed(dict(birth), lambda: build_tropical_natal_chart(**birth)),
"classical": _executed(
{"reference_date": reference_date},
lambda: build_classical_astrology_packet(reference_date=reference_date, **birth),
),
"draconic": _executed(dict(birth), lambda: build_draconic_chart(**birth)),
"transits": _reference_layer(
reference_date, birth, lambda target: calculate_transit_to_natal(target_date=target, **birth)
),
"solar_return": _reference_layer(
reference_date,
birth,
lambda target: calculate_solar_return(target_year=date.fromisoformat(target).year, **birth),
),
"secondary_progressions": _reference_layer(
reference_date, birth, lambda target: calculate_secondary_progressions(target_date=target, **birth)
),
"tertiary_moon": _reference_layer(
reference_date, birth, lambda target: calculate_tertiary_progressed_moon(target_date=target, **birth)
),
"solar_arc": _reference_layer(
reference_date, birth, lambda target: calculate_solar_arc_directions(target_date=target, **birth)
),
"midpoints": _reference_layer(
reference_date, birth, lambda target: calculate_midpoints(target_date=target, **birth)
),
"lunar_return": _reference_layer(
reference_date, birth, lambda target: calculate_lunar_return_series(
target_date=target, months=lunar_return_months, **birth
)
),
"parans": _reference_layer(
reference_date, birth, lambda target: calculate_parans_status(target_date=target, **birth)
),
"profection": _reference_layer(
reference_date, birth, lambda target: calculate_annual_profection(target_date=target, **birth)
),
"zodiacal_release": _reference_layer(
reference_date,
birth,
lambda target: calculate_zodiacal_release(
target_date=target,
lot=zodiacal_release_lot,
levels=int(zodiacal_release_levels),
**birth,
),
),
}
if relocation_latitude is None or relocation_longitude is None or relocation_timezone is None:
layers["relocation"] = _blocked(
"relocation_latitude, relocation_longitude, and relocation_timezone are required"
)
else:
layers["relocation"] = _executed(
{
"relocation_latitude": relocation_latitude,
"relocation_longitude": relocation_longitude,
"relocation_timezone": relocation_timezone,
},
lambda: build_relocation_chart(
relocation_latitude=float(relocation_latitude),
relocation_longitude=float(relocation_longitude),
relocation_timezone=relocation_timezone,
**birth,
),
)
if reference_date:
layers["converse"] = _executed(
{"reference_date": reference_date},
lambda: {
"secondary_progressions": calculate_converse_secondary_progressions(
target_date=reference_date, **birth
),
"solar_arc_directions": calculate_converse_solar_arc_directions(
target_date=reference_date, **birth
),
},
)
else:
layers["converse"] = _blocked("reference_date is required")
if not reference_date:
layers["transit_duration"] = _blocked("reference_date is required")
elif not duration_scan_end_date:
layers["transit_duration"] = _blocked("duration_scan_end_date is required")
else:
layers["transit_duration"] = _executed(
{"start_date": reference_date, "end_date": duration_scan_end_date},
lambda: calculate_transit_duration_scan(
start_date=reference_date, end_date=duration_scan_end_date, **birth
),
)
layers["eclipses"] = _reference_layer(reference_date, birth, _eclipse_events)
if include_classical_usable_loop:
layers["classical_usable_loop"] = _executed(
{
"reference_date": reference_date,
"event_window_hours": classical_event_window_hours,
"event_step_hours": classical_event_step_hours,
"blind_scoring_manifest": classical_blind_scoring_manifest,
"reviewer_manifest": classical_reviewer_manifest,
},
lambda: build_classical_usable_loop_audit_packet(
reference_date=reference_date,
event_window_hours=int(classical_event_window_hours),
event_step_hours=int(classical_event_step_hours),
blind_scoring_manifest_path=classical_blind_scoring_manifest,
reviewer_manifest_path=classical_reviewer_manifest,
**birth,
),
)
return {
"system": "western_astrology",
"status": _overall_status(layers),
"reference_date": reference_date,
"layers": layers,
"boundary": "Raw native calculations only; this entrypoint makes no predictive interpretation.",
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Run the unified native Western calculation layers.")
parser.add_argument("--input-json", required=True, help="Structured birth and timing input as JSON.")
args = parser.parse_args(argv)
payload = json.loads(args.input_json)
if not isinstance(payload, dict):
raise ValueError("--input-json must decode to an object")
print(json.dumps(build_unified_western_reading(**payload), ensure_ascii=False, default=str))
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
+109
View File
@@ -153,6 +153,21 @@ def _birth_zone(value: str | float | int):
return fixed_timezone(timedelta(hours=offset)), f"UTC{offset:+g}"
def _birth_times(
*,
year: int,
month: int,
day: int,
hour: int,
minute: int,
second: int,
timezone: str | float | int,
) -> tuple[datetime, datetime, str]:
zone, timezone_label = _birth_zone(timezone)
local = datetime(year, month, day, hour, minute, second, tzinfo=zone)
utc = local.astimezone(ZoneInfo("UTC"))
return local, utc, timezone_label
def build_tropical_natal_chart(
*,
year: int,
@@ -217,6 +232,100 @@ def build_tropical_natal_chart(
}
def build_draconic_chart(**birth: Any) -> dict[str, Any]:
"""Derive a draconic chart by rotating tropical longitudes to the true node Aries point."""
natal_chart = build_tropical_natal_chart(**birth)
natal = natal_chart["natal"]
node_longitude = natal["planets"]["true_node"]["longitude"]
def _rotate_point(point: dict[str, Any]) -> dict[str, Any]:
rotated = _point(point["longitude"] - node_longitude)
if "house" in point:
rotated["house"] = point["house"]
if "speed_longitude" in point:
rotated["speed_longitude"] = point["speed_longitude"]
rotated["retrograde"] = point.get("retrograde", False)
return rotated
rotated_planets = {name: _rotate_point(point) for name, point in natal["planets"].items()}
rotated_angles = {name: _rotate_point(point) for name, point in natal["angles"].items()}
rotated_houses = [
{"house": row["house"], "cusp": _rotate_point(row["cusp"])}
for row in natal["houses"]
]
aspect_points = {**rotated_planets, "ascendant": rotated_angles["ascendant"], "mc": rotated_angles["mc"]}
return {
"technique": "draconic_chart",
"status": "used",
"source_engine": natal_chart["source_engine"],
"base_zodiac": natal_chart["zodiac"],
"derived_zodiac": "draconic",
"node_basis": "true_node",
"node_longitude": node_longitude,
"draconic": {
"ascendant": rotated_angles["ascendant"],
"mc": rotated_angles["mc"],
"angles": rotated_angles,
"planets": rotated_planets,
"houses": rotated_houses,
"aspects": _aspects(aspect_points),
"distribution": _distribution(rotated_planets),
"house_ruler_chains": _ruler_chains(
[row["cusp"]["longitude"] for row in rotated_houses],
rotated_planets,
),
},
"boundary": "Longitude rotation to the true-node Aries point only; interpretation and timing remain separate audited layers.",
}
def build_relocation_chart(
*,
relocation_latitude: float,
relocation_longitude: float,
relocation_timezone: str | float | int,
house_system: str = "P",
**birth: Any,
) -> dict[str, Any]:
"""Calculate a relocated chart for the same UTC birth instant at a new location."""
local, utc, _ = _birth_times(
year=int(birth["year"]),
month=int(birth["month"]),
day=int(birth["day"]),
hour=int(birth["hour"]),
minute=int(birth["minute"]),
second=int(birth.get("second", 0)),
timezone=birth["timezone"],
)
relocation_zone, relocation_label = _birth_zone(relocation_timezone)
relocation_local = utc.astimezone(relocation_zone)
relocated_chart = build_tropical_natal_chart(
year=relocation_local.year,
month=relocation_local.month,
day=relocation_local.day,
hour=relocation_local.hour,
minute=relocation_local.minute,
second=relocation_local.second,
latitude=float(relocation_latitude),
longitude=float(relocation_longitude),
timezone=relocation_timezone,
house_system=house_system,
)
return {
"technique": "relocation_chart",
"status": "used",
"source_engine": relocated_chart["source_engine"],
"same_birth_utc": utc.isoformat(),
"source_local_time": local.isoformat(),
"relocation_local_time": relocation_local.isoformat(),
"relocation_timezone": relocation_label,
"relocation_coordinates": {
"latitude": float(relocation_latitude),
"longitude": float(relocation_longitude),
},
"relocated_chart": relocated_chart,
"boundary": "Same UTC birth instant with relocated coordinates and timezone; interpretation and relocation outcome judgment remain separate audited layers.",
}
def _request_field(body: dict[str, Any], *keys: str) -> Any:
for key in keys:
if key in body and body[key] not in (None, ""):
+19
View File
@@ -16,6 +16,25 @@ except ImportError:
DEFAULT_MANIFEST = Path("references/real_case_calibration/replay_manifest.json")
def _build_calibration_summary(primary_theme: str = "general") -> dict:
"""Import shim for classical_astrology.
Upstream's scoring body is not in this tree. The caller only reads these
keys and treats a missing gate as blocked, so full-reading can import the
classical packet without inventing a specificity score.
"""
return {
"status": "blocked",
"reason": "specificity_gate_not_ported",
"primary_theme": primary_theme,
"false_positive_audit": {"frozen_negative_holdout": {"status": "blocked"}},
"positive_replay": {},
"positive_holdout": {},
"domain_replay_scoring": {},
"review_pack": {},
}
def build_case(case_id: str, manifest_path: Path = DEFAULT_MANIFEST) -> dict:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
case = next((row for row in manifest["cases"] if row["case_id"] == case_id), None)
+230
View File
@@ -0,0 +1,230 @@
"""full-reading keeps the upstream module keys and does not go online."""
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import pytest
from scripts.jaimini import SIGNS
from scripts.jyotish_engine import cmd_full_reading
from scripts.professional_report_reference import _export_args
from scripts.punarphoo import detect_punarphoo
from scripts.rashi_dashas_native import SIGN_LORDS, navamsha_sign
ROOT = Path(__file__).resolve().parents[1]
VALIDATOR = (
ROOT
/ "docs/research/upstream_full_synastry_bde5d615/skill/scripts/validate_full_synastry_packets.py.txt"
)
# Baseline full-reading keys on origin/staging b3ef2d815, plus the five keys this
# change adds. 81 + 5 = the upstream 86-key set.
UPSTREAM_MODULE_KEYS = frozenset({
"actionable_context", "argala", "ashtakavarga", "ashtottari_dasha", "aspects",
"audit", "avasthas", "bhava_bala", "bhava_chalit", "bhrigu_pada_dasha",
"career_strict_evidence", "chart", "chaturshitisama_dasha", "congregation",
"d9_navamsa_expanded", "dasa_convergence", "dasha", "dasha_sandhi",
"dispositor_chains", "double_transit_pac", "dwadashottari_dasha",
"dwisaptatisama_dasha", "final_dispositors", "finance_strict_evidence",
"functional_benefic_malefic", "guided_topics", "house_map", "inter_chart_linkage",
"jaimini", "kalachakra_dasha", "kp", "lagna_vargottamamsa", "marriage_counting",
"moon_chart", "muntha", "nakshatra_advanced", "nakshatra_dasha", "narayana_dasha",
"navamsha_dasha", "niryaana_shoola_dasha", "pancha_pakshi", "panchanga",
"panchottari_dasha", "planetary_congregation", "planetary_friendship", "prashna",
"punarphoo", "pushkara", "pushkara_planets", "rashi_tulya_navamsa",
"relationship_strict_evidence", "remedies", "sahams", "satabdika_dasha",
"sensitive_points", "shadbala", "shastihayani_dasha", "shattrimshatsama_dasha",
"shodashottari_dasha", "solar_return", "source_priority", "special_lagnas",
"sudarshana", "tajika", "tajika_yogas", "tithi_lord", "transit_ll7l",
"transit_multi_reference", "transit_positions", "tribhagi_40_dasha",
"tribhagi_dasha", "trimshamsa_d30", "unified_western_reading", "upagrahas",
"validation", "varga_extended", "varga_full", "varga_research_high", "vargottama",
"vedastro_official_full_snapshot", "vedastro_range_scan_result", "vimsopaka",
"vivah_saham", "yoga", "yogas_doshas", "yogini_dasha",
})
def _case(path: Path, case_id: str) -> dict:
rows = json.loads(path.read_text(encoding="utf-8"))
birth = next(row for row in rows if row["id"] == case_id)
return {
"year": birth["year"],
"month": birth["month"],
"day": birth["day"],
"hour": birth["hour"],
"minute": birth["minute"],
"second": birth.get("second") or 0,
"lat": birth["lat"],
"lon": birth["lon"],
"tz": birth["tz"],
"ayanamsa": "lahiri",
"node_mode": "mean",
"today": "2026-10-18",
}
@pytest.fixture(scope="module")
def reports():
cases = {
"pitt": _case(ROOT / "tests/indastro_cases.json", "brad_pitt"),
"jolie": _case(ROOT / "tests/celebrity_cases.json", "jolie"),
}
return {name: cmd_full_reading(_export_args(birth)) for name, birth in cases.items()}
def _validator():
from importlib.machinery import SourceFileLoader
loader = SourceFileLoader("validate_full_synastry_packets", str(VALIDATOR))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
def _goel_method_1_first(d9_lagna: int, lord_sign_by_sign: dict[int, int]) -> tuple[int, int]:
start = d9_lagna if d9_lagna % 2 == 0 else (d9_lagna + 6) % 12
lord_sign = int(lord_sign_by_sign[start]) % 12
if start % 2 == 0:
distance = (lord_sign - start) % 12
else:
distance = (start - lord_sign) % 12
years = 12 if distance == 0 else distance + 1
return start, years
def test_unified_western_reading_module_imports() -> None:
import unified_western_reading
assert callable(unified_western_reading.build_unified_western_reading)
def test_engine_does_not_import_the_retired_niryaana_file() -> None:
source = (ROOT / "scripts/jyotish_engine.py").read_text(encoding="utf-8")
assert "from niryaana_shoola_dasha import" not in source
assert "import niryaana_shoola_dasha" not in source
def test_full_reading_module_keys_cover_the_upstream_set(reports) -> None:
assert len(UPSTREAM_MODULE_KEYS) == 86
for name, report in reports.items():
missing = UPSTREAM_MODULE_KEYS - set(report["modules"])
assert not missing, name
def test_simple_modules_match_the_upstream_shapes(reports) -> None:
for report in reports.values():
modules = report["modules"]
lagna = modules["lagna_vargottamamsa"]
assert set(lagna) == {"d1_sign", "d9_sign", "is_lagna_vargottamamsa", "source"}
assert lagna["source"] == "raman_horoscope_v1_bounded_pp64"
assert lagna["is_lagna_vargottamamsa"] is bool(
lagna["d1_sign"] and lagna["d9_sign"] and lagna["d1_sign"] == lagna["d9_sign"]
)
flags = modules["pushkara"]
expected = [
f"{planet} ({data['sign']} {data['degree_in_sign']:.1f}°)"
for planet, data in flags.items()
if data.get("pushkara_navamsa") or data.get("pushkara_bhaga")
]
assert modules["pushkara_planets"] == expected
assert modules["punarphoo"] == detect_punarphoo(report["chart"]["planets"])
def test_secondary_dashas_use_native_profiles_and_match_goel_method_1(reports) -> None:
for report in reports.values():
modules = report["modules"]
navamsha = modules["navamsha_dasha"]
niryaana = modules["niryaana_shoola_dasha"]
assert navamsha["status"] == "parameter_sensitive"
assert navamsha["profile_id"] == "goel_method_1_v1"
assert niryaana["status"] == "parameter_sensitive"
assert niryaana["profile_id"] == "goel_nirayana_shoola_v1"
assert navamsha["native_result"]["status"] == "listed"
first = navamsha["periods"][0]
d9 = modules["varga_full"]["D9_Navamsa"]
d9_lagna = int(d9["Ascendant"]["sign_idx"])
lord_signs = {
index: int(d9[SIGN_LORDS[SIGNS[index]]]["sign_idx"])
for index in range(12)
}
# Upstream goel_method_1_v1 reads the lord's D9 sign. Ours derives the
# same sign from the natal longitude. Both must name the same first major.
assert (first["sign_index"], first["years"]) == _goel_method_1_first(d9_lagna, lord_signs)
longitudes = {
name: float(row["degree_raw"])
for name, row in report["chart"]["planets"].items()
if isinstance(row, dict) and isinstance(row.get("degree_raw"), (int, float))
}
asc_lon = float(report["chart"]["ascendant"]["degree_raw"])
longitude_lords = {
index: navamsha_sign(longitudes[SIGN_LORDS[SIGNS[index]]])
for index in range(12)
}
assert (first["sign_index"], first["years"]) == _goel_method_1_first(
navamsha_sign(asc_lon), longitude_lords
)
def test_synastry_validator_accepts_the_secondary_dashas(reports) -> None:
validator = _validator()
receipt = validator.build_execution_receipt(reports["pitt"], reports["jolie"])
rows = {row["technique"]: row for row in receipt["individual_technique_audit"]}
for label in ("Navamsha Dasha", "Niryaana Shoola Dasha"):
assert rows[label]["status"] in {"executed", "parameter_sensitive"}
tasks = {row["task"]: row for row in receipt["pair_execution_tasks"]}
assert tasks["secondary-dasha convergence and disagreement review"]["status"] != "blocked"
def test_full_reading_still_succeeds_when_network_and_subprocess_raise() -> None:
import subprocess
import urllib.request
from unified_western_reading import build_unified_western_reading
calls = {"count": 0, "stacks": []}
original_urlopen = urllib.request.urlopen
original_run = subprocess.run
def boom(*_args, **_kwargs):
import traceback
calls["count"] += 1
calls["stacks"].append("".join(traceback.format_stack()))
raise RuntimeError("network and external programs are not allowed")
urllib.request.urlopen = boom
subprocess.run = boom
try:
birth = _case(ROOT / "tests/indastro_cases.json", "brad_pitt")
report = cmd_full_reading(_export_args(birth))
western = report["modules"]["unified_western_reading"]
assert western["status"] != "blocked"
# VedAstro's existing local runner may attempt subprocess and fail closed.
# The western packet must not be on that stack.
for stack in calls["stacks"]:
assert "unified_western_reading.py" not in stack
assert "classical_astrology.py" not in stack
calls["count"] = 0
packet = build_unified_western_reading(
year=birth["year"],
month=birth["month"],
day=birth["day"],
hour=birth["hour"],
minute=birth["minute"],
second=birth["second"],
latitude=birth["lat"],
longitude=birth["lon"],
timezone=birth["tz"],
reference_date=birth["today"],
duration_scan_end_date=birth["today"],
lunar_return_months=3,
)
assert packet["system"] == "western_astrology"
assert calls["count"] == 0
finally:
urllib.request.urlopen = original_urlopen
subprocess.run = original_run