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:
co-authored by
Grok (agent loop)
Cursor
parent
b3ef2d8158
commit
900581068e
@@ -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 个人的星盘」。建档仍不扣点。
|
||||
|
||||
@@ -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`)。
|
||||
@@ -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`;动表 |
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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())
|
||||
@@ -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, ""):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user