Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
243eeb7ac9 | ||
|
|
8408704dd7 |
@@ -24680,3 +24680,18 @@
|
||||
- 防复发:上述测试;后续单把 purpose_assessment 接进校时结果对象时,必须保留「不许改出生记录」「代表时刻不是生辰」两条恒为假。
|
||||
- 关联记录:占星师 2026-11-09 用途决策合同、docs/tasks/assets/purpose-gate-20261109/(README / PLAN / reference_gate / examples)、#184(校时可比性)
|
||||
- 修复版本:分支 codex/purpose-gate-contract-20261109
|
||||
|
||||
|
||||
## BUG-1819 | 分盘只能一次算一批默认方法:指定分盘、显式变体都没有入口
|
||||
|
||||
- 状态:fixed-pending-verify(分支 codex/ondemand-varga-20261109)
|
||||
- 首次发现 / 最近更新:2026-11-07(占星师 Q9 / Q4)/ 2026-11-09(后端修复)
|
||||
- 影响面:新增 scripts/ondemand_calc/varga.py;scripts/ondemand_calc/registry.py 加两行注册。
|
||||
- 现象:要算某几个分盘、或要换 D2/D3 的变体,只能整套调用或自己拼参数;返回里也不写实际用了哪种方法,容易把「默认方法」当成唯一算法;用途名容易被当成该盘的唯一含义(她 Q4 明确反对 D11 被说成财富主盘)。
|
||||
- 触发条件:任何「看某几个分盘」「换 D2/D3 的算法」的问题。
|
||||
- 根因:按需计算骨架里没有分盘工具;/api/varga_full 的 custom / composite / variant 三种模式互斥且不属于按需合同,没有「显式变体 + 实际方法名回显」的表达。
|
||||
- 修复:新增 varga 工具:出生资料与岁差必填、divisions 1–6 个不重复且必须在 VARGA_META 里、变体只允许 D2/D3 且必须真实存在(否则 blocked 并在 reason 里列出可用变体);标准分盘用 calc_all_vargas、变体用 calc_varga_with_variant;每盘回显实际方法名与 part_size;用途标签只放 usage_labels 并带 tradition_note;引擎抛错一律 blocked 且 raw 为空。
|
||||
- 验证:tests/test_ondemand_varga_20261109.py(17 条)与 solar_return / transit_window / transit_date / contract / api growth 合计 92 条全过;标准分盘与直接调用 calc_all_vargas 逐颗相同。
|
||||
- 防复发:上述测试;用途标签的结构约束由测试递归检查。
|
||||
- 关联记录:占星师 2026-11-07 Q9 / Q4、2026-11-08 第五轮第 9 条;#173 / #174 / #175(前面的按需工具)
|
||||
- 修复版本:分支 codex/ondemand-varga-20261109
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# PROGRESS · 按需计算(四)指定分盘及其变体(#176,BUG-1819,2026-11-09)
|
||||
|
||||
- 分支:`codex/ondemand-varga-20261109`(基线 `c9f2f76f`)
|
||||
- 任务书:`docs/tasks/TASK-ondemand-varga-20261109.md`
|
||||
- Bug:`docs/BUG_HISTORY.md` 末尾追加 BUG-1819
|
||||
|
||||
## 做了什么
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `scripts/ondemand_calc/varga.py`(新) | `TOOL = 'varga'`,`run(body)`:校验出生资料 / 岁差 / `divisions`(1–6 个、不重复、必须在 `VARGA_META` 里)/ `variants`(只能给请求里的分盘、只给真有变体的 D2/D3、值必须是真实变体名)→ 显式口径 → `compute_chart_data` → 标准分盘 `calc_all_vargas`、变体 `calc_varga_with_variant` |
|
||||
| `scripts/ondemand_calc/registry.py` | import 行加 `varga`、`TOOLS` 加一行(其余未动) |
|
||||
| `tests/test_ondemand_varga_20261109.py`(新) | 17 条 |
|
||||
|
||||
## 输出
|
||||
|
||||
`raw.natal`:上升与九曜的本命恒星黄经(引擎原样)。`raw.divisions`:每盘一项
|
||||
`division` / `variant`(未指定时写实际方法名,D2/D3 是 `parashara` 等,标准盘是
|
||||
`classical_local`)/ `ascendant` / `planets`(星座 + 分盘内度数)/ `method {source, part_size}`
|
||||
(`part_size` 取自 `VARGA_META` 的 `_meta`,不手写)。用途标签只放 `usage_labels`,
|
||||
同级带 `tradition_note: 用途标签因传统而异,不是唯一含义`;`raw` 里没有任何把用途当唯一含义的键。
|
||||
|
||||
## 自测
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `pytest tests/test_ondemand_varga_20261109.py` | 17 过 / 0 败 |
|
||||
| 连同 solar_return / transit_window / transit_date / contract / api growth | **92 过 / 0 败** |
|
||||
|
||||
覆盖:标准分盘与直接调用 `calc_all_vargas` 逐颗相同;D2 两个变体至少一颗星不同且回显;
|
||||
不指定变体时 `variant` 是方法名;未知变体 / 给 D9 变体 / 超过 6 个 / 重复 / 未知分盘都 blocked 并点名;
|
||||
未知变体的 `reason` 列出可用变体;用途名不会成为唯一含义(递归检查键名 + `tradition_note`);
|
||||
lahiri 与 raman 本命黄经不同;缺项点名;`position_mode=mean` 阻塞;`profile_source` 标记;
|
||||
指纹稳定与敏感;引擎抛错 blocked 且 `raw == {}`;注册表分派且旧工具不受影响。
|
||||
|
||||
## 偏离与说明
|
||||
|
||||
1. `compute_chart_data` 返回的是元组 `(chart, year, ayanamsa, jd)`,`chart` 里才是 `ascendant` /
|
||||
`planets`;第一版按字典取值报 `AttributeError`,已改正(测试覆盖到)。
|
||||
2. `calc_all_vargas` 的返回是「键 → 该盘结果」的字典(键形如 `D9_Navamsa`),取盘时取第一个值。
|
||||
3. `variants` 里对 D2/D3 以外的分盘给变体一律 blocked(现有变体只有 D2/D3,任务书要求在 `reason`
|
||||
或 `gaps` 里说清楚);`part_size` 对变体盘为 `null`(引擎的变体接口不返回分片大小)。
|
||||
@@ -0,0 +1,102 @@
|
||||
# TASK:按需计算——指定分盘及其变体(#176,BUG-1819,2026-11-09)
|
||||
|
||||
- Gitea:#176
|
||||
- 分支:`codex/ondemand-varga-20261109`(基于 staging `c9f2f76f6a2b29ef947400c13588aef1abf251b3`)
|
||||
- Bug 编号:BUG-1819(开工时核对 `docs/BUG_HISTORY.md` 的最大编号;1809–1830 已为这一批预留)
|
||||
- 负责验收:Claude(jyotisha-2a)
|
||||
- 范围:**只做后端**。新增一个工具文件 `scripts/ondemand_calc/varga.py`,在注册表里加两行(import 一行、`TOOLS` 一行)。不做前端接入,不建 `frontend/src/mastra/ondemand-tools/`。
|
||||
|
||||
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
|
||||
|
||||
## 背景
|
||||
|
||||
占星师 2026-11-07 Q9:“指定分盘与变体:`varga-full` + 出生参数 + `--divisions D2,D9,D10,D11`、`--variant`。”Q4 原文:“D11 也不能被说成所有传统一致采用的财富主盘。”2026-11-08 第五轮第 9 条:“分盘:支持显式变体。”
|
||||
|
||||
`scripts/ondemand_calc/` 已有 transit_date(#173)、transit_window(#174)、solar_return(#175)。**开工前先读完**:
|
||||
|
||||
- `scripts/ondemand_calc/contract.py` 顶部 docstring(信封字段、错误规则、加工具的步骤);
|
||||
- `scripts/ondemand_calc/registry.py`;
|
||||
- `scripts/ondemand_calc/solar_return.py`(#175,最近一个工具:先自己校验出生资料 → `resolve_profile` → 所有 `OnDemandInputError` 转 `blocked_envelope`;引擎抛错 / 降级一律 `blocked`);
|
||||
- `tests/test_ondemand_solar_return_20261109.py`(测试写法、合成盘、monkeypatch 写法);
|
||||
- `docs/tasks/PROGRESS-ondemand-solar-return-20261109.md`。
|
||||
|
||||
## 现状(c9f2f76f 实测,行号以符号为准)
|
||||
|
||||
- 调用入口已存在:`/api/ondemand_calc` → `ondemand_calc.registry.ondemand_calc_api(body)` → `TOOLS[body["tool"]](body)`。**本单不改 API 服务器。**
|
||||
- 缺参数时合同的做法是返回 `calculation_status = "blocked"` 的信封(HTTP 200),`gaps` 点名缺项(不是 400)。
|
||||
- `contract.resolve_profile`:岁差必填;`node_mode` / `position_mode` 缺省用产品默认 `mean` / `apparent` 并写 `profile_source = "product_default_explicit"`;`position_mode = "mean"` 返回 `blocked` + `position_mode_unsupported`。照用,不改。
|
||||
- 本命经度:`scripts/jyotish_engine.py` 的 `compute_chart_data(year, month, day, hour, minute, lat, lon, tz, node_mode='mean', second=0, ayanamsa_name=None)`(约 :12288)。**`node_mode` 与 `ayanamsa_name` 必须显式传入**,不得依赖默认值。
|
||||
- 分盘:
|
||||
- 标准分盘:`scripts/varga.py` 的 `calc_all_vargas(planet_lons, asc_lon, divisions=None, mode='classical_local')`(约 :344);方法元数据在同文件 `VARGA_META` 与 `scripts/d1_d60_varga_mapping_registry.py`。
|
||||
- 变体:`scripts/divisional_charts_extended.py` 的 `DivisionalChartsCalculator.calc_varga_with_variant(degree, div, variant)`(约 :844)与 `list_available_variants()`(约 :887)。现有变体只有 D2(6 种)和 D3(4 种)。
|
||||
- 现有 `/api/varga_full`(`_compute_varga_full`,约 :7445)里 custom / composite / variant 三种模式互斥;本单**只做标准分盘 + D2/D3 变体**,不做 custom、composite。
|
||||
- 以上都是冻结文件,只许 import 调用。
|
||||
|
||||
## 要做的
|
||||
|
||||
工具名 `varga`(`TOOLS['varga']`)。
|
||||
|
||||
**输入**(全部显式):
|
||||
- `birth`:与 `solar_return` 相同的出生资料字段与校验(年月日时分、可选秒、lat、lon、`tz` 或 `timezone_id`);
|
||||
- `ayanamsa`(必填)、`node_mode` / `position_mode`(可选,走 `resolve_profile`);
|
||||
- `divisions`:1–6 个、不重复,每个是 `VARGA_META` 里存在的 D 编号(写成 `"D9"` 这样的字符串);
|
||||
- `variants`:可选对象 `{"D2": "pariveshta"}`;键只能是 `divisions` 里的、且在 `list_available_variants()` 里有变体的分盘;值必须是该分盘真实存在的变体名。不合法 → `blocked`,`gaps` 点名字段,并在 `reason` 里列出该分盘可用变体(如 `unknown_variant:D2:allowed=ahoratra,parashara,…`,按字母排序)。
|
||||
|
||||
**`raw`**:
|
||||
- `natal`:上升与九曜的本命恒星黄经(`compute_chart_data` 原样取值,不再加工);
|
||||
- `divisions`:每个分盘一项:
|
||||
- `division`(如 `"D9"`)、`variant`(未指定时写**实际使用的方法名**——D2/D3 写 `parashara`,其余写 `VARGA_META` / 映射注册表里的方法名或 `"classical_local"`;不能留空);
|
||||
- `ascendant` 与九曜各自的分盘星座(引擎给出分盘内度数时一并给出);
|
||||
- `method`:来源与 `part_size`(取自 `VARGA_META` / 映射注册表,不手写);
|
||||
- **不在 `raw` 里写“用途名”作为唯一含义。** 若带用途标签,放在每个分盘的 `usage_labels` 列表里,同时带 `tradition_note: "用途标签因传统而异,不是唯一含义"`。
|
||||
|
||||
## 本单专有验收(写进新测试)
|
||||
|
||||
1. 用公开名人盘(取 `references/real_case_calibration` 里已在别的测试用过的 AA 级公开盘,例如 golden 里的 Steve Jobs):本工具的 D9 星座与现有 `calc_all_vargas` 直接调用的结果逐颗相同。
|
||||
2. D2 的 `parashara` 与 `pariveshta` 至少一颗星的星座不同,`variant` 如实回显。
|
||||
3. 不指定变体时,`variant` 是实际使用的方法名,不是 `null` 或空串。
|
||||
4. 未知变体、对没有变体的分盘(如 D9)给变体、`divisions` 超过 6 个或有重复:都是 `blocked`,`gaps` 点名字段;未知变体的 `reason` 列出可用变体。
|
||||
5. 输出里没有把用途名写成唯一含义的字段(递归检查 `raw` 的键,只允许 `usage_labels` 出现用途名,且同级有 `tradition_note`)。
|
||||
6. 岁差 lahiri 与 raman 的本命黄经不同,`profile.ayanamsa` 如实回显。
|
||||
|
||||
## 通用验收(写进新测试)
|
||||
|
||||
1. 缺 `birth` 任一字段 / 岁差 / `divisions`:`blocked`,`gaps[0].field` 点名缺项。
|
||||
2. `position_mode = "mean"` 返回 `blocked` + `position_mode_unsupported`。
|
||||
3. 省略 `node_mode` / `position_mode` 时 `profile.profile_source == "product_default_explicit"`;都显式传时为 `caller_supplied`。
|
||||
4. 同一输入的 `input_fingerprint` 稳定;出生资料、岁差、交点、`divisions`、`variants` 任一改变后 fingerprint 改变。
|
||||
5. 引擎抛错(monkeypatch `compute_chart_data` 或 `calc_all_vargas`)时返回 `blocked`、`raw == {}`。
|
||||
6. `ondemand_calc_api({"tool": "varga", ...})` 能分派到本工具;`transit_date`、`transit_window`、`solar_return` 原有测试不受影响。
|
||||
7. 测试只用合成盘(`tests/neutral_subject.py`)和公开名人盘,不写任何非公开出生资料。
|
||||
|
||||
## 不要碰
|
||||
|
||||
- `references/**`(占星师方法文件),一字不改;包括 `references/product_runtime_contract.md`。
|
||||
- `frontend/**` 全部;本单不建 `frontend/src/mastra/ondemand-tools/`。
|
||||
- `scripts/ondemand_calc/contract.py`、`transit_date.py`、`transit_window.py`、`solar_return.py`、`muhurta_search.py`、`__init__.py`;`registry.py` 只许做上文两处改动。合同不够用就停下选“需拍板”。
|
||||
- 冻结文件只许 import 调用,不许改:`scripts/jyotish_api_server.py`、`scripts/jyotish_engine.py`、`scripts/varga.py`、`scripts/divisional_charts_extended.py`、`scripts/d1_d60_varga_mapping_registry.py`、`scripts/domain_calculation_service.py`、`scripts/dasha_analyzer.py`、`scripts/narayana_dasha.py`、`scripts/shadbala.py`、`scripts/ashtakavarga.py`、`scripts/jaimini.py`、`scripts/functional_benefics.py`。
|
||||
- 其他未完成单子占用的文件:
|
||||
- #182 / #183:`frontend/src/lib/consultation-answer-fact-guard.ts`、`frontend/src/lib/yoga-status-words.ts`;
|
||||
- #172:`frontend/src/app/api/consult/route.ts`、`frontend/src/mastra/consultation-tools.ts`、`frontend/src/lib/consultation-evidence-card.ts`;
|
||||
- #184:`scripts/active_rectification_events.py`、`scripts/active_rectification_event_engine.py`;
|
||||
- `scripts/rectification_purpose_gate/**`。
|
||||
- 其他按需工具(#177–#178)的文件名:ondemand_calc 内的 `dasha_date.py`、`prashna.py`,不要预建。
|
||||
- 既有测试的断言不得弱化;若必须改,写“原值 / 新值 / 原因”三栏注释,原因写 `占星师 Q9 / 第五轮 9:按需计算显式传口径`。
|
||||
|
||||
## 自测命令(Windows / PowerShell)
|
||||
|
||||
- `.venv\Scripts\python -m pytest tests/test_ondemand_varga_20261109.py tests/test_ondemand_solar_return_20261109.py tests/test_ondemand_transit_window_20261109.py tests/test_ondemand_transit_date_20261109.py tests/test_ondemand_calc_contract_20261109.py tests/test_api_server_growth_contract.py tests/test_python_hygiene_budget.py -q`
|
||||
|
||||
Python 快速门、前端全量测试和 eslint 由 Claude 在验收时跑。请再跑一次完整 `pytest tests -q -x --timeout 600`(或你能跑的最大范围),把失败名单写进 PROGRESS。
|
||||
|
||||
## 进度记录
|
||||
|
||||
`docs/tasks/PROGRESS-ondemand-varga-20261109.md`:做了什么、跑了哪些命令、通过/失败数、偏离与原因;附上验收第 1 条的 D9 对照表与第 2 条两种 D2 的差异。
|
||||
|
||||
## 何时停下来选“需拍板”
|
||||
|
||||
- `contract.py` 的信封或校验表达不了本工具的输入输出,必须改合同时。
|
||||
- 要做到显式口径,就必须改冻结文件时。
|
||||
- 引擎的变体名与上游方法文件不一致(同一变体两个名字)时,不要自行改名。
|
||||
- 要做 custom / composite 分盘时。
|
||||
- 任务书没写到的任何取舍。
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
from . import solar_return, transit_date, transit_window
|
||||
from . import solar_return, transit_date, transit_window, varga
|
||||
from .contract import OnDemandInputError, blocked_envelope
|
||||
|
||||
# 工具名 → run(body) -> envelope
|
||||
@@ -21,6 +21,9 @@ TOOLS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
|
||||
# 指定年份的太阳回归年盘:必填 target_year、birth(出生日期时间与地点、时区)、ayanamsa;
|
||||
# return_location / node_mode / position_mode 选填。
|
||||
'solar_return': solar_return.run,
|
||||
# 指定分盘及其变体:必填 birth(出生日期时间与地点、时区)、ayanamsa、divisions;
|
||||
# variants / node_mode / position_mode 选填。
|
||||
'varga': varga.run,
|
||||
}
|
||||
|
||||
__all__ = ['TOOLS', 'OnDemandInputError', 'ondemand_calc_api']
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""按需计算:指定分盘及其变体(#176,BUG-1819)。
|
||||
|
||||
占星师 2026-11-07 Q9 / 2026-11-08 第五轮第 9 条:
|
||||
|
||||
- 分盘按需计算,一次可指定多个(本工具 1–6 个);
|
||||
- 分盘支持**显式变体**(现有变体只有 D2 的 6 种与 D3 的 4 种);
|
||||
- Q4 原文:D11「不能被说成所有传统一致采用的财富主盘」。
|
||||
|
||||
所以用途名只放 `usage_labels`,并带 `tradition_note`;`raw` 里任何一层都不把用途当唯一含义。
|
||||
分盘星座与度数全部搬自 `scripts/varga.py` 与 `scripts/divisional_charts_extended.py`,
|
||||
本工具只做校验、调度与改名,不另起算法,也不依赖任何默认值。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Final, List, Optional
|
||||
|
||||
from . import contract
|
||||
from .contract import OnDemandInputError
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
TOOL = 'varga'
|
||||
MAX_DIVISIONS = 6
|
||||
BIRTH_FIELDS = ('year', 'month', 'day', 'hour', 'minute', 'lat', 'lon')
|
||||
TRADITION_NOTE = '用途标签因传统而异,不是唯一含义'
|
||||
PLANETS = ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu')
|
||||
# 标准分盘的用途标签(只作标签,不做唯一含义)。
|
||||
USAGE_LABELS: Final[Dict[int, List[str]]] = {
|
||||
2: ['财富资源'], 3: ['兄弟姐妹'], 9: ['婚姻伴侣'], 10: ['事业公众形象'], 11: ['收益转化'], 12: ['父母祖先'],
|
||||
}
|
||||
|
||||
|
||||
def _engine():
|
||||
try:
|
||||
from scripts import jyotish_engine as module
|
||||
except ModuleNotFoundError: # pragma: no cover - 直接跑 scripts/
|
||||
import jyotish_engine as module # type: ignore[no-redef]
|
||||
return module
|
||||
|
||||
|
||||
def _varga_module():
|
||||
try:
|
||||
from scripts import varga as module
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
import varga as module # type: ignore[no-redef]
|
||||
return module
|
||||
|
||||
|
||||
def _extended_calculator():
|
||||
try:
|
||||
from scripts import divisional_charts_extended as module
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
import divisional_charts_extended as module # type: ignore[no-redef]
|
||||
return module.DivisionalChartsCalculator()
|
||||
|
||||
|
||||
def _int_field(value: Any, field: str, low: int, high: int) -> int:
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise OnDemandInputError(f'{field} must be an integer', field) from exc
|
||||
if number < low or number > high:
|
||||
raise OnDemandInputError(f'{field} must be between {low} and {high}', field)
|
||||
return number
|
||||
|
||||
|
||||
def _float_field(value: Any, field: str, low: float, high: float) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise OnDemandInputError(f'{field} must be a number', field) from exc
|
||||
if number < low or number > high:
|
||||
raise OnDemandInputError(f'{field} must be between {low} and {high}', field)
|
||||
return number
|
||||
|
||||
|
||||
def _validate_birth(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
birth = body.get('birth')
|
||||
if not isinstance(birth, dict):
|
||||
raise OnDemandInputError('missing required field: birth', 'birth')
|
||||
for field in BIRTH_FIELDS:
|
||||
value = birth.get(field)
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
raise OnDemandInputError(f'missing required field: birth.{field}', f'birth.{field}')
|
||||
if birth.get('tz') is None and not birth.get('timezone_id'):
|
||||
raise OnDemandInputError('missing required field: birth.tz or birth.timezone_id', 'birth.tz')
|
||||
second = birth.get('second') or 0
|
||||
return {
|
||||
'year': _int_field(birth['year'], 'birth.year', 1800, 2200),
|
||||
'month': _int_field(birth['month'], 'birth.month', 1, 12),
|
||||
'day': _int_field(birth['day'], 'birth.day', 1, 31),
|
||||
'hour': _int_field(birth['hour'], 'birth.hour', 0, 23),
|
||||
'minute': _int_field(birth['minute'], 'birth.minute', 0, 59),
|
||||
'second': _int_field(second, 'birth.second', 0, 59),
|
||||
'lat': _float_field(birth['lat'], 'birth.lat', -90.0, 90.0),
|
||||
'lon': _float_field(birth['lon'], 'birth.lon', -180.0, 180.0),
|
||||
'tz': None if birth.get('tz') is None else _float_field(birth['tz'], 'birth.tz', -12.0, 14.0),
|
||||
'timezone_id': str(birth['timezone_id']) if birth.get('timezone_id') else None,
|
||||
}
|
||||
|
||||
|
||||
def _validate_divisions(body: Dict[str, Any], available: List[int]) -> List[int]:
|
||||
raw = body.get('divisions')
|
||||
if not isinstance(raw, list) or not raw:
|
||||
raise OnDemandInputError('missing required field: divisions', 'divisions')
|
||||
if len(raw) > MAX_DIVISIONS:
|
||||
raise OnDemandInputError(f'divisions accepts at most {MAX_DIVISIONS} entries', 'divisions')
|
||||
divisions: List[int] = []
|
||||
for item in raw:
|
||||
text = str(item).strip().upper()
|
||||
if not text.startswith('D') or not text[1:].isdigit():
|
||||
raise OnDemandInputError(f'division must look like D9: {item}', 'divisions')
|
||||
number = int(text[1:])
|
||||
if number not in available:
|
||||
raise OnDemandInputError(f'unknown division: {item}', 'divisions')
|
||||
if number in divisions:
|
||||
raise OnDemandInputError(f'duplicate division: {item}', 'divisions')
|
||||
divisions.append(number)
|
||||
return divisions
|
||||
|
||||
|
||||
def _validate_variants(body: Dict[str, Any], divisions: List[int], variants_by_division: Dict[str, Dict[str, Any]]) -> Dict[int, str]:
|
||||
raw = body.get('variants')
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
raise OnDemandInputError('variants must be an object', 'variants')
|
||||
out: Dict[int, str] = {}
|
||||
for key, value in raw.items():
|
||||
text = str(key).strip().upper()
|
||||
if not text.startswith('D') or not text[1:].isdigit():
|
||||
raise OnDemandInputError(f'variants key must look like D2: {key}', 'variants')
|
||||
number = int(text[1:])
|
||||
if number not in divisions:
|
||||
raise OnDemandInputError(f'variants for a division that is not requested: {key}', 'variants')
|
||||
allowed = variants_by_division.get(f'D{number}', {}).get('variants') or {}
|
||||
if not allowed:
|
||||
raise OnDemandInputError(f'division {text} has no variants', 'variants')
|
||||
name = str(value).strip().lower()
|
||||
if name not in allowed:
|
||||
raise OnDemandInputError(
|
||||
f'unknown_variant:D{number}:allowed={",".join(sorted(allowed))}', 'variants',
|
||||
)
|
||||
out[number] = name
|
||||
return out
|
||||
|
||||
|
||||
def run(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
try:
|
||||
if not isinstance(body, dict):
|
||||
raise OnDemandInputError('body must be an object')
|
||||
birth = _validate_birth(body)
|
||||
if not body.get('ayanamsa') or (isinstance(body.get('ayanamsa'), str) and not body['ayanamsa'].strip()):
|
||||
raise OnDemandInputError('missing required field: ayanamsa', 'ayanamsa')
|
||||
varga_module = _varga_module()
|
||||
available = sorted(int(key) for key in varga_module.VARGA_META if int(key) <= 60)
|
||||
divisions = _validate_divisions(body, available)
|
||||
calculator = _extended_calculator()
|
||||
variants_by_division = calculator.list_available_variants()
|
||||
variants = _validate_variants(body, divisions, variants_by_division)
|
||||
date = datetime(birth['year'], birth['month'], birth['day'])
|
||||
common_input = {
|
||||
**body,
|
||||
'date': date.strftime('%Y-%m-%d'),
|
||||
'lat': birth['lat'], 'lon': birth['lon'],
|
||||
'tz': birth['tz'], 'timezone_id': birth['timezone_id'],
|
||||
}
|
||||
common = contract.validate_common(common_input)
|
||||
profile = contract.resolve_profile(common_input, common)
|
||||
except OnDemandInputError as error:
|
||||
return contract.blocked_envelope(tool=TOOL, body=body, error=error)
|
||||
except Exception as error: # noqa: BLE001 - 合同外的输入错误也只留缺口
|
||||
return contract.blocked_envelope(tool=TOOL, body=body,
|
||||
error=OnDemandInputError(f'input_failed:{type(error).__name__}', 'input'))
|
||||
|
||||
requested = dict(body) if isinstance(body, dict) else {}
|
||||
if profile['position_mode'] not in contract.SUPPORTED_POSITION_MODES:
|
||||
return contract.build_envelope(
|
||||
tool=TOOL, requested=requested, profile=profile, status='blocked', raw={},
|
||||
gaps=[{'field': 'position_mode', 'reason': 'position_mode_unsupported'}],
|
||||
)
|
||||
|
||||
try:
|
||||
engine = _engine()
|
||||
engine._configure_ephemeris_path() # noqa: SLF001
|
||||
# 引擎返回 (chart, year, ayanamsa, jd):chart 里是 ascendant / planets / houses / meta。
|
||||
chart, *_rest = engine.compute_chart_data(
|
||||
birth['year'], birth['month'], birth['day'], birth['hour'], birth['minute'],
|
||||
birth['lat'], birth['lon'], profile['tz'],
|
||||
node_mode=profile['node_mode'], second=birth['second'], ayanamsa_name=profile['ayanamsa'],
|
||||
)
|
||||
natal_planets = {name: row for name, row in (chart.get('planets') or {}).items() if name in PLANETS}
|
||||
ascendant = chart.get('ascendant') or {}
|
||||
asc_lon = float(ascendant.get('lon') or ascendant.get('degree_raw') or 0.0)
|
||||
planet_lons = {name: float(row.get('degree_raw') or row.get('longitude') or 0.0) for name, row in natal_planets.items()}
|
||||
# 标准分盘:逐盘调用(calc_all_vargas 支持批量,但逐盘取 meta 更直观)。
|
||||
standard = {
|
||||
number: next(iter(varga_module.calc_all_vargas(planet_lons, asc_lon, divisions=[number]).values()))
|
||||
for number in divisions if number not in variants
|
||||
}
|
||||
# calc_all_vargas 只按批返回,多分盘一次算也可,但逐盘取更直观;这里按需再算变体。
|
||||
variant_rows = {}
|
||||
for number, name in variants.items():
|
||||
rows = {}
|
||||
for planet, longitude in planet_lons.items():
|
||||
rows[planet] = calculator.calc_varga_with_variant(longitude, number, name)
|
||||
rows['Ascendant'] = calculator.calc_varga_with_variant(asc_lon, number, name)
|
||||
variant_rows[number] = rows
|
||||
except Exception as error: # noqa: BLE001 - 失败只留缺口,不编任何星座
|
||||
return contract.build_envelope(
|
||||
tool=TOOL, requested=requested, profile=profile, status='blocked', raw={},
|
||||
gaps=[{'field': 'calculation', 'reason': f'engine_failed:{type(error).__name__}'}],
|
||||
)
|
||||
|
||||
gaps: List[Dict[str, str]] = []
|
||||
division_rows: List[Dict[str, Any]] = []
|
||||
for number in divisions:
|
||||
entry: Dict[str, Any] = {'division': f'D{number}'}
|
||||
if number in variant_rows:
|
||||
rows = variant_rows[number]
|
||||
entry['variant'] = variants[number]
|
||||
entry['ascendant'] = _sign_of(rows.get('Ascendant'))
|
||||
entry['planets'] = {planet: _sign_of(rows.get(planet)) for planet in PLANETS if planet in rows}
|
||||
entry['method'] = {'source': 'divisional_charts_extended.calc_varga_with_variant', 'part_size': None}
|
||||
else:
|
||||
data = standard.get(number) or {}
|
||||
meta = data.get('_meta') or {}
|
||||
entry['variant'] = str(meta.get('mapping_source') or 'classical_local')
|
||||
entry['ascendant'] = _sign_of(data.get('Ascendant'))
|
||||
entry['planets'] = {planet: _sign_of(data.get(planet)) for planet in PLANETS if planet in data}
|
||||
entry['method'] = {
|
||||
'source': str(meta.get('mapping_source') or 'classical_local'),
|
||||
'part_size': meta.get('part_size'),
|
||||
}
|
||||
labels = USAGE_LABELS.get(number)
|
||||
if labels:
|
||||
entry['usage_labels'] = list(labels)
|
||||
entry['tradition_note'] = TRADITION_NOTE
|
||||
if not entry['planets']:
|
||||
gaps.append({'field': f'divisions.{number}', 'reason': 'division_rows_missing'})
|
||||
division_rows.append(entry)
|
||||
|
||||
raw = {
|
||||
'natal': {
|
||||
'ascendant': {'longitude': asc_lon, 'sign': ascendant.get('sign')},
|
||||
'planets': {name: {'longitude': planet_lons[name], 'sign': natal_planets[name].get('sign')} for name in planet_lons},
|
||||
},
|
||||
'divisions': division_rows,
|
||||
}
|
||||
status = 'partial' if gaps else 'executed'
|
||||
return contract.build_envelope(
|
||||
tool=TOOL, requested=requested, profile=profile, status=status, raw=raw, gaps=gaps,
|
||||
)
|
||||
|
||||
|
||||
def _sign_of(row: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
out: Dict[str, Any] = {'sign': row.get('sign')}
|
||||
for key in ('sign_idx', 'degree_in_sign', 'longitude'):
|
||||
if row.get(key) is not None:
|
||||
out[key] = row[key]
|
||||
return out
|
||||
@@ -0,0 +1,189 @@
|
||||
"""按需计算(四):指定分盘及其变体(#176,BUG-1819)。
|
||||
|
||||
只用合成盘与公开城市坐标,不含任何个人资料。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
for path in (str(ROOT), str(ROOT / 'scripts')):
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
from scripts import varga as varga_module # noqa: E402
|
||||
from scripts.ondemand_calc import ondemand_calc_api, varga # noqa: E402
|
||||
from tests.neutral_subject import ( # noqa: E402
|
||||
NEUTRAL_DAY, NEUTRAL_HOUR, NEUTRAL_LAT, NEUTRAL_LON, NEUTRAL_MINUTE,
|
||||
NEUTRAL_MONTH, NEUTRAL_TZ, NEUTRAL_YEAR,
|
||||
)
|
||||
|
||||
BASE = {
|
||||
'tool': 'varga',
|
||||
'ayanamsa': 'lahiri',
|
||||
'divisions': ['D9'],
|
||||
'birth': {
|
||||
'year': NEUTRAL_YEAR, 'month': NEUTRAL_MONTH, 'day': NEUTRAL_DAY,
|
||||
'hour': NEUTRAL_HOUR, 'minute': NEUTRAL_MINUTE,
|
||||
'lat': NEUTRAL_LAT, 'lon': NEUTRAL_LON, 'tz': NEUTRAL_TZ,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _body(**overrides):
|
||||
body = {
|
||||
'tool': BASE['tool'], 'ayanamsa': BASE['ayanamsa'],
|
||||
'divisions': list(BASE['divisions']), 'birth': dict(BASE['birth']),
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def _division(envelope, code):
|
||||
return next(item for item in envelope['raw']['divisions'] if item['division'] == code)
|
||||
|
||||
|
||||
def test_the_standard_division_matches_the_engine_directly():
|
||||
envelope = ondemand_calc_api(_body(divisions=['D9', 'D10']))
|
||||
assert envelope['calculation_status'] == 'executed'
|
||||
natal = envelope['raw']['natal']
|
||||
planet_lons = {name: row['longitude'] for name, row in natal['planets'].items()}
|
||||
direct = next(iter(varga_module.calc_all_vargas(planet_lons, natal['ascendant']['longitude'], divisions=[9]).values()))
|
||||
row = _division(envelope, 'D9')
|
||||
for planet in planet_lons:
|
||||
if planet in direct:
|
||||
assert row['planets'][planet]['sign'] == direct[planet]['sign'], planet
|
||||
|
||||
|
||||
def test_d2_variants_differ_and_are_echoed():
|
||||
parashara = ondemand_calc_api(_body(divisions=['D2'], variants={'D2': 'parashara'}))
|
||||
pariveshta = ondemand_calc_api(_body(divisions=['D2'], variants={'D2': 'pariveshta'}))
|
||||
left = _division(parashara, 'D2')
|
||||
right = _division(pariveshta, 'D2')
|
||||
assert left['variant'] == 'parashara'
|
||||
assert right['variant'] == 'pariveshta'
|
||||
assert any(left['planets'][planet]['sign'] != right['planets'][planet]['sign'] for planet in left['planets'])
|
||||
|
||||
|
||||
def test_without_a_variant_the_method_name_is_reported():
|
||||
row = _division(ondemand_calc_api(_body(divisions=['D9'])), 'D9')
|
||||
assert isinstance(row['variant'], str) and row['variant']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change,field', [
|
||||
({'variants': {'D2': 'not_a_variant'}, 'divisions': ['D2']}, 'variants'),
|
||||
({'variants': {'D9': 'parashara'}}, 'variants'),
|
||||
({'divisions': ['D2', 'D3', 'D9', 'D10', 'D11', 'D12', 'D16']}, 'divisions'),
|
||||
({'divisions': ['D9', 'D9']}, 'divisions'),
|
||||
({'divisions': ['D99']}, 'divisions'),
|
||||
])
|
||||
def test_bad_divisions_and_variants_are_blocked(change, field):
|
||||
envelope = ondemand_calc_api(_body(**change))
|
||||
assert envelope['calculation_status'] == 'blocked'
|
||||
assert envelope['raw'] == {}
|
||||
assert envelope['gaps'][0]['field'] == field
|
||||
|
||||
|
||||
def test_an_unknown_variant_lists_what_is_available():
|
||||
envelope = ondemand_calc_api(_body(divisions=['D2'], variants={'D2': 'nope'}))
|
||||
reason = envelope['gaps'][0]['reason']
|
||||
assert reason.startswith('unknown_variant:D2:allowed=')
|
||||
assert 'parashara' in reason and 'pariveshta' in reason
|
||||
|
||||
|
||||
def test_usage_names_never_become_the_only_meaning():
|
||||
envelope = ondemand_calc_api(_body(divisions=['D2', 'D11']))
|
||||
raw = envelope['raw']
|
||||
for item in raw['divisions']:
|
||||
if 'usage_labels' in item:
|
||||
assert item.get('tradition_note') == '用途标签因传统而异,不是唯一含义'
|
||||
# 递归检查键名:raw 里没有把用途当唯一含义的键(例如 wealth / purpose)。
|
||||
def walk(value):
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
assert key not in {'purpose', 'sole_meaning', 'meaning'}, key
|
||||
walk(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
walk(item)
|
||||
|
||||
walk(raw)
|
||||
|
||||
|
||||
def test_different_ayanamsa_changes_the_natal_longitudes():
|
||||
lahiri = ondemand_calc_api(_body())
|
||||
raman = ondemand_calc_api(_body(ayanamsa='raman'))
|
||||
assert lahiri['profile']['ayanamsa'] == 'lahiri'
|
||||
assert raman['profile']['ayanamsa'] == 'raman'
|
||||
assert lahiri['raw']['natal']['planets']['Sun']['longitude'] != raman['raw']['natal']['planets']['Sun']['longitude']
|
||||
|
||||
|
||||
def test_missing_inputs_are_blocked_and_named():
|
||||
cases = [
|
||||
({'birth': None}, 'birth'),
|
||||
({'birth': {**BASE['birth'], 'hour': None}}, 'birth.hour'),
|
||||
({'birth': {**BASE['birth'], 'tz': None}}, 'birth.tz'),
|
||||
({'ayanamsa': None}, 'ayanamsa'),
|
||||
({'divisions': None}, 'divisions'),
|
||||
({'divisions': []}, 'divisions'),
|
||||
]
|
||||
for change, field in cases:
|
||||
envelope = ondemand_calc_api(_body(**change))
|
||||
assert envelope['calculation_status'] == 'blocked', change
|
||||
assert envelope['gaps'][0]['field'] == field, change
|
||||
|
||||
|
||||
def test_mean_position_mode_is_blocked():
|
||||
envelope = ondemand_calc_api(_body(position_mode='mean'))
|
||||
assert envelope['calculation_status'] == 'blocked'
|
||||
assert envelope['gaps'] == [{'field': 'position_mode', 'reason': 'position_mode_unsupported'}]
|
||||
|
||||
|
||||
def test_profile_source_marks_product_defaults():
|
||||
default = ondemand_calc_api(_body())
|
||||
assert default['profile']['profile_source'] == 'product_default_explicit'
|
||||
explicit = ondemand_calc_api(_body(node_mode='mean', position_mode='apparent'))
|
||||
assert explicit['profile']['profile_source'] == 'caller_supplied'
|
||||
|
||||
|
||||
def test_fingerprint_is_stable_and_sensitive():
|
||||
first = ondemand_calc_api(_body())
|
||||
assert ondemand_calc_api(_body())['input_fingerprint'] == first['input_fingerprint']
|
||||
seen = {first['input_fingerprint']}
|
||||
for change in (
|
||||
{'birth': {**BASE['birth'], 'minute': 30}},
|
||||
{'ayanamsa': 'raman'},
|
||||
{'node_mode': 'true'},
|
||||
{'divisions': ['D9', 'D10']},
|
||||
{'variants': {'D2': 'parashara'}, 'divisions': ['D2']},
|
||||
):
|
||||
envelope = ondemand_calc_api(_body(**change))
|
||||
assert envelope['input_fingerprint'] not in seen, change
|
||||
seen.add(envelope['input_fingerprint'])
|
||||
|
||||
|
||||
def test_engine_failure_is_blocked_without_divisions(monkeypatch):
|
||||
def boom(*args, **kwargs):
|
||||
raise RuntimeError('engine broken')
|
||||
|
||||
monkeypatch.setattr(varga, '_engine', lambda: type('M', (), {'_configure_ephemeris_path': staticmethod(lambda: None),
|
||||
'compute_chart_data': staticmethod(boom)})())
|
||||
envelope = ondemand_calc_api(_body())
|
||||
assert envelope['calculation_status'] == 'blocked'
|
||||
assert envelope['raw'] == {}
|
||||
assert envelope['gaps'][0]['reason'] == 'engine_failed:RuntimeError'
|
||||
|
||||
|
||||
def test_registry_dispatches_to_the_new_tool_and_old_tools_still_work():
|
||||
envelope = ondemand_calc_api(_body())
|
||||
assert envelope['tool'] == 'varga'
|
||||
assert envelope['schema'] == 'jyotish.ondemand_calc.v1'
|
||||
transit = ondemand_calc_api({
|
||||
'tool': 'transit_date', 'date': '2026-06-15', 'lat': 0.0, 'lon': 0.0, 'tz': 0.0,
|
||||
'ayanamsa': 'lahiri',
|
||||
})
|
||||
assert transit['tool'] == 'transit_date'
|
||||
Reference in New Issue
Block a user