diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 0ef9635f..dadba286 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7904,3 +7904,18 @@ - 相关记录:BUG-511 - 复发自:无 - 修复版本:待发布 +## BUG-513 | Gulika 计算泄漏进程级岁差模式,Raman 请求回执与后续计算可能漂移 + +- 状态:resolved +- 首次发现:2026-09-03 +- 最近更新:2026-09-03 +- 影响面:`scripts/gulika.py`、`scripts/jyotish_api_server.py`、`scripts/prashna_context.py` +- 用户现象:请求选择 Raman 时 Gulika 曾按其他岁差返回或污染后续恒星黄道计算;同一常驻进程的下一次计算可能继承错误模式。 +- 触发条件:Gulika / Upagraha 计算在已设置另一岁差的进程内调用 `houses_ex(..., FLG_SIDEREAL)`。 +- 根因:Swiss Ephemeris 岁差模式是进程全局状态;Gulika 设置请求岁差后没有恢复调用前模式,API 与 Prashna 调用方也没有始终传入本次请求岁差。 +- 修复:`ayanamsa_utils.temporary_ayanamsa` 在 `finally` 恢复前一模式;Gulika 的恒星上升计算统一使用该上下文;API 与 Prashna 显式透传并回执实际岁差。 +- 验证:Raman→Lahiri、Lahiri→Raman 两向同进程回归测试均保持调用前 Moon 黄经;模式设置所有权测试锁定只有 `ayanamsa_utils.py` 可直调 `set_sid_mode`;API Raman smoke 回执为 `raman` / `Raman`。 +- 防复发:临时切换进程级岁差必须通过 `temporary_ayanamsa`;业务模块不得直接调用 `set_sid_mode`。 +- 相关记录:BUG-511、BUG-512 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-repo-hygiene-fix-20260903.md b/docs/tasks/PROGRESS-repo-hygiene-fix-20260903.md new file mode 100644 index 00000000..c1ca9165 --- /dev/null +++ b/docs/tasks/PROGRESS-repo-hygiene-fix-20260903.md @@ -0,0 +1,57 @@ +# PROGRESS · 仓库整备修复单(2026-09-03) + +工作树:`.worktrees/repo-hygiene-20260903` +分支:`codex/repo-hygiene-fix-20260903` +基线:最新 `origin/staging`(提交前重放) + +本轮完成修复与本地验证;交付目标为 `origin/staging`,提交/推送状态以 Git 远端为准;不手动部署。其他工作树的既有脏改动未触碰。 + +## 已完成 + +- BUG-512:Gulika / Upagraha 临时切换岁差后恢复进程原模式;API 与 Prashna 透传同一请求岁差,回执使用实际标准化名称。 +- 设置 UI:提示语明确咨询/报告/星盘库/每日星语跟随设置,生时校正目前仍固定 Raman;无样式类名由最新 staging 删除,不重复增加空样式。 +- 默认值:修复单列出的 Prashna、校正扫描、Muhurta、Solar Return CLI、ephemeris contract 改用 `DEFAULT_AYANAMSA_NAME`;五个参考/外部对照脚本保留显式 Lahiri 并标注参考口径。 +- 仓库卫生:三个 oracle 生成器支持 `--output-dir`,对应测试写入 pytest 临时目录,不再改写 `references/oracle`。 +- 记录:新增 `BUG-512`,更新本状态板。 + +## 验证 + +```text +python -m pytest -q \ + tests/test_gulika.py \ + tests/test_gulika_ayanamsa_mode.py \ + tests/test_ayanamsa_mode_ownership.py \ + tests/test_prashna_context.py \ + tests/test_standalone_ayanamsa_defaults.py \ + tests/test_full_technique_invocation_matrix.py \ + tests/test_muhurta_factor_only_scoring_packet.py \ + tests/test_prashna_sphuta_oss_case_probe.py +# 24 passed + +node --test --import tsx \ + tests/ayanamsa.test.ts \ + tests/settings-mvp-contract.test.ts \ + tests/class-name-definition-contract.test.ts +# 8 passed + +./node_modules/.bin/tsc --noEmit +# exit 0 + +npm run lint +# exit 0; 73 个既有 warning,0 error + +npm run build +# exit 0;5 个既有 Turbopack 动态文件访问 warning + +JYOTISH_PRE_WORK_PYTHON=/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python \ + python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45 +# pass +``` + +额外 smoke:`_attach_gulika_observation` 输入 `ayanamsa=raman`,回执为 `ayanamsa=raman`、`ayanamsa_display=Raman`。测试后 `git diff -- references/oracle` 为空。 + +## 明确未做 + +- 可选任务 5(机器相关计数测试加 `external` marker):任务书要求产品同意,本轮没有该授权,未改。 +- Python quick gate:503 passed、1 skipped。前端全量 `npm test` 在本机数据库/Docker 与缺少 PyYAML 等既有环境项失败;本轮相关的 8 项定向测试通过。 +- 最新 staging 另有不在修复单表内的显式 Lahiri:Solar Return / Transit 的独立 helper 仍有现成合同测试要求 Lahiri,VedAstro、PyJHora、三引擎 parity 等为外部参考口径;因此旧的宽泛 grep 验收命令仍会列出这些预存行,本轮没有越界改写其业务口径。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 1008ef18..b01e26ad 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -92,7 +92,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | | `TASK-repo-hygiene-20260903.md` | `PROGRESS-repo-hygiene-20260903.md` | 岁差默认 Raman + 用户设置可选、参考集锁口径、发布门本地可复现、注册表白名单、仓库地址、早期日志搬家、远端分支清理 | 已验收(带修复单) | `b6a70aa7`、`2415e751`、`01a45363`(BUG-511);验收结论见修复单附录 | -| `TASK-repo-hygiene-fix-20260903.md` | — | Gulika 全局岁差泄漏(P1)、设置类名无样式、提示语对齐校正现状、剩余 lahiri 字面量、测试不写仓库 | 待领取 | 分支 `codex/repo-hygiene-fix-20260903` | +| `TASK-repo-hygiene-fix-20260903.md` | `PROGRESS-repo-hygiene-fix-20260903.md` | Gulika 全局岁差泄漏(P1)、设置类名无样式、提示语对齐校正现状、剩余 lahiri 字面量、测试不写仓库 | 修复完成 | 分支 `codex/repo-hygiene-fix-20260903`;交付状态以 Git 远端为准 | ### 上游同步与能力接入 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index ec9eb848..f812437c 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -206,7 +206,7 @@ The self-profile editor in 星盘资料 (`ChartLibraryPanel`) owns the ayanamsa - **Structure:** four 44px stacked radios reused from `.theme-preference-option`, plus a caption under the group. Raman is marked 默认. Each option has a one-line description; selected rows show the same `Check` as the theme panel. - **Values:** `raman` (default) / `lahiri` / `kp` / `true_pushya`. Copy lives in `frontend/src/lib/ayanamsa.ts`. -- **Hint:** “切换只影响之后的新计算;已生成的报告和校正结果保持当时的口径。” +- **Hint:** “之后的咨询、报告、星盘库和每日星语会按新岁差算。生时校正目前仍固定按 Raman,之后的版本才会跟着这项设置走。” - **Placement:** after `ProfileFields` on the self-profile form only. Not on other-chart save, not on `page.tsx`. - **States:** default, hover, selected (`aria-checked` only), keyboard focus. Switching does not clear a confirmed birth minute. diff --git a/frontend/src/lib/ayanamsa.ts b/frontend/src/lib/ayanamsa.ts index dc046784..cb2dd5d5 100644 --- a/frontend/src/lib/ayanamsa.ts +++ b/frontend/src/lib/ayanamsa.ts @@ -30,7 +30,7 @@ export const AYANAMSA_OPTIONS = [ }>; export const AYANAMSA_SWITCH_HINT = - "切换只影响之后的新计算;已生成的报告和校正结果保持当时的口径。"; + "之后的咨询、报告、星盘库和每日星语会按新岁差算。生时校正目前仍固定按 Raman,之后的版本才会跟着这项设置走。"; export function isAyanamsaName(value: unknown): value is AyanamsaName { return typeof value === "string" && (AYANAMSA_VALUES as readonly string[]).includes(value); diff --git a/frontend/tests/ayanamsa.test.ts b/frontend/tests/ayanamsa.test.ts index e7043ab0..2bbbfe28 100644 --- a/frontend/tests/ayanamsa.test.ts +++ b/frontend/tests/ayanamsa.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + AYANAMSA_SWITCH_HINT, AYANAMSA_VALUES, DEFAULT_AYANAMSA, isAyanamsaName, @@ -21,4 +22,6 @@ test("resolveAyanamsa defaults to Raman and accepts the four product values", () assert.equal(resolveAyanamsa({ ayanamsa: "fagan" }), "raman"); assert.equal(isAyanamsaName("raman"), true); assert.equal(isAyanamsaName("sidereal"), false); + assert.match(AYANAMSA_SWITCH_HINT, /咨询、报告、星盘库和每日星语/); + assert.match(AYANAMSA_SWITCH_HINT, /生时校正目前仍固定按 Raman/); }); diff --git a/scripts/ayanamsa_utils.py b/scripts/ayanamsa_utils.py index 03ded1c6..e904ecf5 100644 --- a/scripts/ayanamsa_utils.py +++ b/scripts/ayanamsa_utils.py @@ -10,7 +10,8 @@ comparison fixtures must pass an explicit `lahiri` name. from __future__ import annotations -from typing import Any +from contextlib import contextmanager +from typing import Any, Iterator try: import swisseph as swe @@ -91,6 +92,7 @@ def current_ayanamsa_name(args: Any = None) -> str: def apply_ayanamsa(name: Any = None, swe_module: Any = None) -> bool: """Set Swiss Ephemeris sidereal mode explicitly.""" + global ACTIVE_AYANAMSA_NAME module = swe_module or swe if module is None: return False @@ -99,9 +101,24 @@ def apply_ayanamsa(name: Any = None, swe_module: Any = None) -> bool: module.set_sid_mode(AYANAMSA_MODES[key]) except Exception: return False + ACTIVE_AYANAMSA_NAME = key return True +@contextmanager +def temporary_ayanamsa(name: Any = None, swe_module: Any = None) -> Iterator[str]: + """Apply a sidereal mode and restore the previous process-wide mode.""" + previous = ACTIVE_AYANAMSA_NAME + target = normalize_ayanamsa_name(name) + if not apply_ayanamsa(target, swe_module): + raise RuntimeError(f"failed to apply ayanamsa {target}") + try: + yield target + finally: + if not apply_ayanamsa(previous, swe_module): + raise RuntimeError(f"failed to restore ayanamsa {previous}") + + def sidereal_flags(swe_module: Any, ayanamsa_name: Any = None) -> int: apply_ayanamsa(ayanamsa_name, swe_module) return getattr(swe_module, 'FLG_SWIEPH', 2) | getattr(swe_module, 'FLG_SIDEREAL', 65536) diff --git a/scripts/candidate_time_sensitivity_scan.py b/scripts/candidate_time_sensitivity_scan.py index a0d61fb5..351798f4 100644 --- a/scripts/candidate_time_sensitivity_scan.py +++ b/scripts/candidate_time_sensitivity_scan.py @@ -11,6 +11,10 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Any +try: + from scripts.ayanamsa_utils import DEFAULT_AYANAMSA_NAME +except ModuleNotFoundError: # pragma: no cover - direct script execution + from ayanamsa_utils import DEFAULT_AYANAMSA_NAME try: from scripts.rectification_input_contract import ( candidate_input_fingerprint, @@ -109,7 +113,7 @@ def scan_candidate_times(payload: dict[str, Any], *, uncertainty_minutes: int = "version": "rectification-input-v1", "center_input_fingerprint": candidate_input_fingerprint(payload), "settings": { - "ayanamsa": str(payload.get("ayanamsa") or "lahiri").strip().lower(), + "ayanamsa": str(payload.get("ayanamsa") or DEFAULT_AYANAMSA_NAME).strip().lower(), "node_mode": str( payload.get("node_mode", payload.get("nodeMode", "mean")) ).strip().lower(), diff --git a/scripts/cmd_muhurta.py b/scripts/cmd_muhurta.py index a3d9707a..faba5471 100644 --- a/scripts/cmd_muhurta.py +++ b/scripts/cmd_muhurta.py @@ -17,7 +17,7 @@ import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from ayanamsa_utils import sidereal_flags +from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, sidereal_flags def _weekday_to_vara(py_weekday: int) -> int: @@ -26,7 +26,7 @@ def _weekday_to_vara(py_weekday: int) -> int: def _get_sun_moon_lons(year: int, month: int, day: int, - hour: int = 12, ayanamsa_name: str = 'lahiri') -> tuple: + hour: int = 12, ayanamsa_name: str = DEFAULT_AYANAMSA_NAME) -> tuple: """获取太阳/月亮恒星黄经。优先 swisseph,退化为近似算法。""" def _primary_longitude(value): if isinstance(value, (list, tuple)): @@ -112,7 +112,7 @@ def cmd_muhurta(args, chart_data: Optional[Dict] = None) -> int: hour_from_sunrise = getattr(args, 'hour_from_sunrise', 6.0) target_activity = getattr(args, 'activity', None) scan_days = getattr(args, 'scan_days', 1) - ayanamsa_name = getattr(args, 'ayanamsa', 'lahiri') + ayanamsa_name = getattr(args, 'ayanamsa', None) or DEFAULT_AYANAMSA_NAME activities = list(ACTIVITY_RULES.keys()) if target_activity and target_activity not in activities: diff --git a/scripts/cmd_solar_return.py b/scripts/cmd_solar_return.py index 6b1a0542..bde98c0e 100644 --- a/scripts/cmd_solar_return.py +++ b/scripts/cmd_solar_return.py @@ -28,6 +28,7 @@ def cmd_solar_return(args: Any) -> Dict[str, Any]: """ try: sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from ayanamsa_utils import DEFAULT_AYANAMSA_NAME from solar_return import solar_return_full_report except ImportError as e: return {"error": f"solar_return 模块导入失败: {e}"} @@ -41,7 +42,7 @@ def cmd_solar_return(args: Any) -> Dict[str, Any]: args.hour, args.minute, args.lat, args.lon, args.tz, args.target_year, - ayanamsa_name=getattr(args, 'ayanamsa', 'lahiri'), + ayanamsa_name=getattr(args, 'ayanamsa', None) or DEFAULT_AYANAMSA_NAME, ) diff --git a/scripts/collect_vedastro_official_parity_raw.py b/scripts/collect_vedastro_official_parity_raw.py index fc67ee27..06c13060 100644 --- a/scripts/collect_vedastro_official_parity_raw.py +++ b/scripts/collect_vedastro_official_parity_raw.py @@ -144,7 +144,7 @@ def collect_chart_core( settings = { "case_id": case_id, "birth": {key: case.get(key) for key in ("year", "month", "day", "hour", "minute", "second", "lat", "lon", "tz")}, - "ayanamsa_policy": case.get("ayanamsa_policy") or case.get("ayanamsa") or "lahiri", + "ayanamsa_policy": case.get("ayanamsa_policy") or case.get("ayanamsa") or "lahiri", # 参考口径,勿改为默认 "node_policy": case.get("node_policy") or case.get("node_mode") or "mean", "endpoint_host": adapter._endpoint_host(endpoint), "calculator": "AllPlanetData", diff --git a/scripts/ephemeris_adapter_contract.py b/scripts/ephemeris_adapter_contract.py index 48845cd4..e072f922 100644 --- a/scripts/ephemeris_adapter_contract.py +++ b/scripts/ephemeris_adapter_contract.py @@ -22,6 +22,7 @@ if str(SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(SCRIPT_DIR)) from jyotish_engine import HAS_SWE, compute_chart_data # noqa: E402 +from ayanamsa_utils import DEFAULT_AYANAMSA_NAME # noqa: E402 BODY_KEYS = ["Sun", "Moon", "Ascendant", "Rahu", "Ketu"] @@ -40,7 +41,7 @@ class EphemerisAdapterContract: lat: float lon: float tz: float - ayanamsa_policy: str = "lahiri" + ayanamsa_policy: str = DEFAULT_AYANAMSA_NAME node_policy: str = "mean" body_list: tuple[str, ...] = tuple(BODY_KEYS) diff --git a/scripts/full_technique_invocation_matrix.py b/scripts/full_technique_invocation_matrix.py index 81251e4c..0c64deda 100644 --- a/scripts/full_technique_invocation_matrix.py +++ b/scripts/full_technique_invocation_matrix.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from __future__ import annotations +import argparse import hashlib import json import re @@ -9,7 +10,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -OUTPUT = ROOT / "references/oracle/full_technique_invocation_matrix_2026_07_22.json" +OUTPUT_NAME = "full_technique_invocation_matrix_2026_07_22.json" SCAN_ROOTS = { "skill": ["SKILL.md", "skills"], @@ -339,13 +340,18 @@ def _claim_boundary(tech: Technique, claim_status: str) -> str: def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--output-dir", type=Path, default=ROOT / "references/oracle") + args = parser.parse_args() packet = build_matrix() without_hash = dict(packet) without_hash["content_hash"] = "" digest = hashlib.sha256(json.dumps(without_hash, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest() packet["content_hash"] = digest - OUTPUT.write_text(json.dumps(packet, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - print(json.dumps({"path": str(OUTPUT.relative_to(ROOT)), "sha256": digest, "summary": packet["summary"]}, ensure_ascii=False, indent=2)) + args.output_dir.mkdir(parents=True, exist_ok=True) + output = args.output_dir / OUTPUT_NAME + output.write_text(json.dumps(packet, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps({"path": str(output), "sha256": digest, "summary": packet["summary"]}, ensure_ascii=False, indent=2)) if __name__ == "__main__": diff --git a/scripts/gulika.py b/scripts/gulika.py index 71ca9b6a..2b389c37 100644 --- a/scripts/gulika.py +++ b/scripts/gulika.py @@ -12,9 +12,9 @@ try: except ImportError: from scripts.saham_daynight import determine_daytime try: - from ayanamsa_utils import ayanamsa_display_name, normalize_ayanamsa_name, sidereal_flags + from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, ayanamsa_display_name, normalize_ayanamsa_name, temporary_ayanamsa except ImportError: - from scripts.ayanamsa_utils import ayanamsa_display_name, normalize_ayanamsa_name, sidereal_flags + from scripts.ayanamsa_utils import DEFAULT_AYANAMSA_NAME, ayanamsa_display_name, normalize_ayanamsa_name, temporary_ayanamsa # Monday=0, matching datetime.weekday(). Values are the end of Saturn's share @@ -72,10 +72,10 @@ TEMPORAL_UPAGRAHAS = { } -def _sidereal_ascendant(jd_ut: float, lat: float, lon: float, ayanamsa: str = "raman") -> float: - flags = sidereal_flags(swe, ayanamsa) - cusps, ascmc = swe.houses_ex(jd_ut, lat, lon, b"P", flags) - return float(ascmc[0]) % 360 +def _sidereal_ascendant(jd_ut: float, lat: float, lon: float, ayanamsa: str = DEFAULT_AYANAMSA_NAME) -> float: + with temporary_ayanamsa(ayanamsa, swe): + _, ascmc = swe.houses_ex(jd_ut, lat, lon, b"P", swe.FLG_SWIEPH | swe.FLG_SIDEREAL) + return float(ascmc[0]) % 360 def calculate_gulika( @@ -85,7 +85,7 @@ def calculate_gulika( lon: float, tz: float, method: str = "saturn_part_start", - ayanamsa: str = "raman", + ayanamsa: str = DEFAULT_AYANAMSA_NAME, ) -> dict[str, Any]: """Return Gulika from local moment/location using Swiss sunrise and sunset.""" ayanamsa_name = normalize_ayanamsa_name(ayanamsa) @@ -140,7 +140,7 @@ def _temporal_upagraha( tz: float, planet_index: int, segment_part: str, - ayanamsa: str = "raman", + ayanamsa: str = DEFAULT_AYANAMSA_NAME, ) -> dict[str, Any]: ayanamsa_name = normalize_ayanamsa_name(ayanamsa) daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz) @@ -198,7 +198,7 @@ def calculate_upagrahas( lon: float, tz: float, sun_longitude: float, - ayanamsa: str = "raman", + ayanamsa: str = DEFAULT_AYANAMSA_NAME, ) -> dict[str, Any]: """Calculate the D1 Upagraha packet using local Swiss Ephemeris inputs.""" ayanamsa_name = normalize_ayanamsa_name(ayanamsa) diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index ee356f69..7ad25b84 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -1046,7 +1046,9 @@ def _attach_gulika_observation(chart: dict, birth_payload: dict) -> None: int(float(birth_payload.get('minute', 0) or 0)), int(float(birth_payload.get('second', 0) or 0)), ) - result = _load_local_module('gulika').calculate_gulika(moment, lat=lat, lon=lon, tz=tz) + result = _load_local_module('gulika').calculate_gulika( + moment, lat=lat, lon=lon, tz=tz, ayanamsa=_request_ayanamsa(birth_payload) + ) result['claim_boundary'] = 'observation_only; not a verified event claim' modules['gulika'] = result except Exception as exc: diff --git a/scripts/local_accuracy_report.py b/scripts/local_accuracy_report.py index 4f0c7c02..4e6dec5e 100755 --- a/scripts/local_accuracy_report.py +++ b/scripts/local_accuracy_report.py @@ -227,7 +227,7 @@ def build_report() -> dict[str, Any]: } return { "scope": "local_jyotish_accuracy_report", - "reference_ayanamsa": "lahiri", + "reference_ayanamsa": "lahiri", # 参考口径,勿改为默认 "generated_at": datetime.now(timezone.utc).isoformat(), "summary": summary, "checks": checks, diff --git a/scripts/muhurta.py b/scripts/muhurta.py index 92dfff0b..aee6b2cc 100644 --- a/scripts/muhurta.py +++ b/scripts/muhurta.py @@ -18,7 +18,7 @@ from datetime import datetime, timedelta import calendar import math -from ayanamsa_utils import sidereal_flags +from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, sidereal_flags # ── Vara(周日行星)────────────────────────────────────────────────── VARA_LORDS = { @@ -2138,7 +2138,7 @@ def _local_time_from_jd(jd: float, tz: float) -> str: return local_dt.strftime('%Y-%m-%d %H:%M') -def _swisseph_sun_moon_lon(jd: float, ayanamsa_name: str = 'lahiri') -> Optional[Tuple[float, float]]: +def _swisseph_sun_moon_lon(jd: float, ayanamsa_name: str = DEFAULT_AYANAMSA_NAME) -> Optional[Tuple[float, float]]: try: import swisseph as swe flags = sidereal_flags(swe, ayanamsa_name) @@ -2192,7 +2192,7 @@ def calc_panchanga_end_times( day: int, tz: float, local_hours: float, - ayanamsa_name: str = 'raman', + ayanamsa_name: str = DEFAULT_AYANAMSA_NAME, ) -> Optional[Dict]: """Calculate current Tithi/Nakshatra/Yoga end times from SwissEph positions.""" jd_start = _jd_for_local_time(year, month, day, local_hours, tz) @@ -2903,7 +2903,7 @@ def panchanga_range_report( lat: Optional[float] = None, lon: Optional[float] = None, tz: Optional[float] = None, - ayanamsa_name: str = 'raman', + ayanamsa_name: str = DEFAULT_AYANAMSA_NAME, ) -> Dict: """Build a date-range Panchanga calendar with daytime inauspicious windows.""" start_dt = datetime.strptime(start_date[:10], '%Y-%m-%d') @@ -3034,7 +3034,7 @@ def muhurta_range_search( lon: Optional[float] = None, tz: Optional[float] = None, avoid_inauspicious_periods: bool = True, - ayanamsa_name: str = 'raman', + ayanamsa_name: str = DEFAULT_AYANAMSA_NAME, ) -> Dict: """Search a date range for ranked Muhurta candidates for a selected activity.""" limit = max(1, min(int(limit or 5), 20)) @@ -3106,7 +3106,7 @@ def build_muhurta_sidecar( lat: Optional[float] = None, lon: Optional[float] = None, tz: Optional[float] = None, - ayanamsa_name: str = 'raman', + ayanamsa_name: str = DEFAULT_AYANAMSA_NAME, ) -> Dict: """Compact Muhurta/Panchanga packet for unified workflow consumers.""" activity = activity if activity in ACTIVITY_RULES else 'business' diff --git a/scripts/muhurta_factor_only_scoring_packet.py b/scripts/muhurta_factor_only_scoring_packet.py index 1ed27f33..5957120f 100644 --- a/scripts/muhurta_factor_only_scoring_packet.py +++ b/scripts/muhurta_factor_only_scoring_packet.py @@ -2,6 +2,7 @@ """Capture a Muhurta factor-only scoring observation packet.""" from __future__ import annotations +import argparse import hashlib import json from pathlib import Path @@ -11,7 +12,7 @@ from muhurta_factor_probe import build_probe ROOT = Path(__file__).resolve().parents[1] -OUTPUT = ROOT / "references/oracle/muhurta_factor_only_scoring_packet_2026_07_23.json" +OUTPUT_NAME = "muhurta_factor_only_scoring_packet_2026_07_23.json" def _stable_json(data: Any) -> str: @@ -63,8 +64,14 @@ def build() -> dict[str, Any]: def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output-dir", type=Path, default=ROOT / "references/oracle") + args = parser.parse_args() packet = build() - OUTPUT.write_text(json.dumps(packet, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + args.output_dir.mkdir(parents=True, exist_ok=True) + (args.output_dir / OUTPUT_NAME).write_text( + json.dumps(packet, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) print(json.dumps(packet, ensure_ascii=False, sort_keys=True)) return 0 diff --git a/scripts/oracle_boundary_audit.py b/scripts/oracle_boundary_audit.py index 7aea7f6f..d739886a 100644 --- a/scripts/oracle_boundary_audit.py +++ b/scripts/oracle_boundary_audit.py @@ -256,7 +256,7 @@ def _namespace_from_template(case: dict[str, Any]) -> argparse.Namespace: birth["node_mode"] = settings["node_mode"] return _namespace_from_birth( birth, - ayanamsa=settings.get("ayanamsa", "lahiri"), + ayanamsa=settings.get("ayanamsa", "lahiri"), # 参考口径,勿改为默认 moon_lon=None, nakshatra=None, pada=None, diff --git a/scripts/prashna_context.py b/scripts/prashna_context.py index 38a58caf..3d9c2706 100644 --- a/scripts/prashna_context.py +++ b/scripts/prashna_context.py @@ -9,6 +9,10 @@ try: from scripts.domain_calculation_service import CalculationError, compute_chart except ModuleNotFoundError: # pragma: no cover - CLI execution path from domain_calculation_service import CalculationError, compute_chart +try: + from scripts.ayanamsa_utils import DEFAULT_AYANAMSA_NAME, normalize_ayanamsa_name +except ModuleNotFoundError: # pragma: no cover - CLI execution path + from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, normalize_ayanamsa_name try: from scripts.gulika import calculate_gulika except ModuleNotFoundError: # pragma: no cover - CLI execution path @@ -39,6 +43,10 @@ def _timezone_offset(value: Any, moment: datetime) -> float: raise PrashnaContextError("timezone must be a numeric UTC offset or present in question_timestamp") +def _payload_ayanamsa(payload: dict[str, Any]) -> str: + return normalize_ayanamsa_name(payload.get("ayanamsa") or DEFAULT_AYANAMSA_NAME) + + def build_prashna_context(payload: dict[str, Any]) -> dict[str, Any]: required = ("question_text", "question_timestamp", "lat", "lon", "timezone") missing = [field for field in required if payload.get(field) in (None, "")] @@ -56,18 +64,21 @@ def build_prashna_context(payload: dict[str, Any]) -> dict[str, Any]: if abs(timestamp_tz - tz) > 0.001: raise PrashnaContextError("timezone conflicts with question_timestamp offset") moment = moment.replace(tzinfo=None) + ayanamsa = _payload_ayanamsa(payload) try: chart = compute_chart({ "year": moment.year, "month": moment.month, "day": moment.day, "hour": moment.hour, "minute": moment.minute, "second": moment.second, "lat": float(payload["lat"]), "lon": float(payload["lon"]), "tz": tz, - "ayanamsa": str(payload.get("ayanamsa") or "lahiri"), + "ayanamsa": ayanamsa, "node_mode": str(payload.get("node_mode") or "mean"), }) except (CalculationError, ValueError, TypeError) as exc: raise PrashnaContextError(f"Swiss Prashna chart blocked: {exc}") from exc try: - gulika = calculate_gulika(moment, lat=float(payload["lat"]), lon=float(payload["lon"]), tz=tz) + gulika = calculate_gulika( + moment, lat=float(payload["lat"]), lon=float(payload["lon"]), tz=tz, ayanamsa=ayanamsa + ) except Exception as exc: gulika = { "status": "blocked", @@ -92,7 +103,7 @@ def build_prashna_context(payload: dict[str, Any]) -> dict[str, Any]: "question_text": str(payload["question_text"])[:500], "question_timestamp": str(payload["question_timestamp"]), "location": {"lat": float(payload["lat"]), "lon": float(payload["lon"]), "timezone": tz, "location_convention": "wgs84"}, - "ayanamsa": str(payload.get("ayanamsa") or "lahiri"), + "ayanamsa": ayanamsa, "node_mode": str(payload.get("node_mode") or "mean"), "chart_source": "swiss_ephemeris_backend", "ascendant": chart["ascendant"], diff --git a/scripts/prashna_sphuta_oss_case_probe.py b/scripts/prashna_sphuta_oss_case_probe.py index 02d36288..4ff4b04a 100644 --- a/scripts/prashna_sphuta_oss_case_probe.py +++ b/scripts/prashna_sphuta_oss_case_probe.py @@ -37,5 +37,5 @@ def build(date): return data def main(): - ap=argparse.ArgumentParser(); ap.add_argument('--date',default='2026-07-20'); args=ap.parse_args(); data=build(args.date); OUT.write_text(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)+'\n'); print(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)) + ap=argparse.ArgumentParser(); ap.add_argument('--date',default='2026-07-20'); ap.add_argument('--output-dir',type=Path,default=OUT.parent); args=ap.parse_args(); data=build(args.date); args.output_dir.mkdir(parents=True,exist_ok=True); (args.output_dir/OUT.name).write_text(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)+'\n'); print(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)) if __name__=='__main__': main() diff --git a/scripts/pyjhora_multi_case_panchanga_gulika_replay.py b/scripts/pyjhora_multi_case_panchanga_gulika_replay.py index f5760270..607da0ca 100644 --- a/scripts/pyjhora_multi_case_panchanga_gulika_replay.py +++ b/scripts/pyjhora_multi_case_panchanga_gulika_replay.py @@ -53,7 +53,7 @@ def build_report() -> dict[str, Any]: "abhijit_muhurta": drik.abhijit_muhurta(local_jd, place), } utc_jd = local_jd - tz / 24 - sun_moon = _swisseph_sun_moon_lon(utc_jd, "lahiri") + sun_moon = _swisseph_sun_moon_lon(utc_jd, "lahiri") # 参考口径,勿改为默认 local = calc_panchanga(*sun_moon, weekday=(datetime(y, m, d).weekday() + 1) % 7) local_gulika = calculate_gulika(datetime(y, m, d, hh, mm, ss), lat=lat, lon=lon, tz=tz) py_gulika_deg = raw["gulika"][0] * 30 + raw["gulika"][1] diff --git a/scripts/shadbala_dig_source_of_truth_audit.py b/scripts/shadbala_dig_source_of_truth_audit.py index 6497aecc..ed6f1d9d 100644 --- a/scripts/shadbala_dig_source_of_truth_audit.py +++ b/scripts/shadbala_dig_source_of_truth_audit.py @@ -70,7 +70,7 @@ def _namespace_from_template(case: dict[str, Any]) -> Any: "lat": birth["lat"], "lon": birth["lon"], "tz": birth["tz"], - "ayanamsa": settings.get("ayanamsa", "lahiri"), + "ayanamsa": settings.get("ayanamsa", "lahiri"), # 参考口径,勿改为默认 "node_mode": settings.get("node_mode", "mean"), }, )() diff --git a/tests/test_ayanamsa_mode_ownership.py b/tests/test_ayanamsa_mode_ownership.py new file mode 100644 index 00000000..746f485a --- /dev/null +++ b/tests/test_ayanamsa_mode_ownership.py @@ -0,0 +1,15 @@ +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_only_ayanamsa_utils_sets_process_sidereal_mode() -> None: + violations = [] + for path in (ROOT / "scripts").rglob("*.py"): + if path.name == "ayanamsa_utils.py": + continue + if re.search(r"\bset_sid_mode\s*\(", path.read_text(encoding="utf-8")): + violations.append(str(path.relative_to(ROOT))) + assert violations == [] diff --git a/tests/test_full_technique_invocation_matrix.py b/tests/test_full_technique_invocation_matrix.py index 1a6a0483..9c84f30b 100644 --- a/tests/test_full_technique_invocation_matrix.py +++ b/tests/test_full_technique_invocation_matrix.py @@ -14,15 +14,16 @@ INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" @pytest.fixture(scope="module") -def matrix_packet() -> dict: +def matrix_packet(tmp_path_factory: pytest.TempPathFactory) -> dict: + output_dir = tmp_path_factory.mktemp("full-technique-invocation-matrix") subprocess.run( - [sys.executable, "scripts/full_technique_invocation_matrix.py"], + [sys.executable, "scripts/full_technique_invocation_matrix.py", "--output-dir", str(output_dir)], cwd=ROOT, check=True, text=True, capture_output=True, ) - return json.loads(PACKET.read_text(encoding="utf-8")) + return json.loads((output_dir / PACKET.name).read_text(encoding="utf-8")) def test_full_matrix_scans_all_required_runtime_layers(matrix_packet: dict) -> None: diff --git a/tests/test_gulika_ayanamsa_mode.py b/tests/test_gulika_ayanamsa_mode.py new file mode 100644 index 00000000..5d72aa9e --- /dev/null +++ b/tests/test_gulika_ayanamsa_mode.py @@ -0,0 +1,32 @@ +from datetime import datetime + +import pytest +import swisseph as swe + +from scripts.gulika import calculate_gulika +from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, apply_ayanamsa + + +def _moon_longitude() -> float: + jd = swe.julday(2000, 1, 1, 12.0) + return float(swe.calc_ut(jd, swe.MOON, swe.FLG_SWIEPH | swe.FLG_SIDEREAL)[0][0]) + + +@pytest.mark.parametrize(("previous", "requested"), [("lahiri", "raman"), ("raman", "lahiri")]) +def test_gulika_restores_process_ayanamsa(previous: str, requested: str) -> None: + try: + assert apply_ayanamsa(previous, swe) + before = _moon_longitude() + + result = calculate_gulika( + datetime(1990, 1, 1, 12, 0), + lat=39.9042, + lon=116.4074, + tz=8, + ayanamsa=requested, + ) + + assert result["ayanamsa"] == requested + assert _moon_longitude() == pytest.approx(before, abs=1e-10) + finally: + apply_ayanamsa(DEFAULT_AYANAMSA_NAME, swe) diff --git a/tests/test_muhurta_factor_only_scoring_packet.py b/tests/test_muhurta_factor_only_scoring_packet.py index 374cae1a..6aed3489 100644 --- a/tests/test_muhurta_factor_only_scoring_packet.py +++ b/tests/test_muhurta_factor_only_scoring_packet.py @@ -7,10 +7,10 @@ ROOT = Path(__file__).resolve().parents[1] PACKET = ROOT / "references/oracle/muhurta_factor_only_scoring_packet_2026_07_23.json" -def test_muhurta_factor_only_packet_generator_captures_ten_factors(): +def test_muhurta_factor_only_packet_generator_captures_ten_factors(tmp_path: Path): data = json.loads( subprocess.check_output( - ["python3", "scripts/muhurta_factor_only_scoring_packet.py"], + ["python3", "scripts/muhurta_factor_only_scoring_packet.py", "--output-dir", str(tmp_path)], cwd=ROOT, text=True, ) diff --git a/tests/test_prashna_context.py b/tests/test_prashna_context.py index 6603e532..0d6a3f7c 100644 --- a/tests/test_prashna_context.py +++ b/tests/test_prashna_context.py @@ -22,6 +22,8 @@ def test_prashna_context_uses_backend_chart_from_question_moment(): assert packet["ascendant"]["degree"] >= 0 assert "Sun" in packet["planets"] assert "question_timestamp" in packet + assert packet["ayanamsa"] == "lahiri" + assert packet["supporting_indicators"]["gulika"]["ayanamsa"] == "lahiri" def test_prashna_context_rejects_missing_time_or_non_wgs84_location(): diff --git a/tests/test_prashna_sphuta_oss_case_probe.py b/tests/test_prashna_sphuta_oss_case_probe.py index 4196fa82..114a70dc 100644 --- a/tests/test_prashna_sphuta_oss_case_probe.py +++ b/tests/test_prashna_sphuta_oss_case_probe.py @@ -7,8 +7,8 @@ ARTIFACT = ROOT / "references/oracle/prashna_sphuta_oss_case_probe_2026_07_20.js INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" -def test_prashna_sphuta_oss_case_probe_runs_pyjhora_case_in_isolation(): - data = json.loads(subprocess.check_output(["python3", "scripts/prashna_sphuta_oss_case_probe.py", "--date", "2026-07-20"], cwd=ROOT, text=True)) +def test_prashna_sphuta_oss_case_probe_runs_pyjhora_case_in_isolation(tmp_path: Path): + data = json.loads(subprocess.check_output(["python3", "scripts/prashna_sphuta_oss_case_probe.py", "--date", "2026-07-20", "--output-dir", str(tmp_path)], cwd=ROOT, text=True)) assert data["scope"] == "prashna_sphuta_oss_case_probe" assert data["claim_status"] == "tooling_observation_only" assert data["license_boundary"] == "agpl_observation_only_do_not_vendor" diff --git a/tests/test_standalone_ayanamsa_defaults.py b/tests/test_standalone_ayanamsa_defaults.py index bbe505e9..b98c59a0 100644 --- a/tests/test_standalone_ayanamsa_defaults.py +++ b/tests/test_standalone_ayanamsa_defaults.py @@ -79,10 +79,10 @@ def test_solar_return_julian_datetime_helpers_work_without_greg_flag_constant(): assert roundtrip.minute == 30 -def test_muhurta_sun_moon_helper_defaults_to_lahiri_after_global_raman_switch(): +def test_muhurta_sun_moon_helper_defaults_to_product_ayanamsa(): jd = swe.julday(2026, 6, 25, 4.0) - expected_sun = _sidereal_lon(jd, swe.SUN, swe.SIDM_LAHIRI) - expected_moon = _sidereal_lon(jd, swe.MOON, swe.SIDM_LAHIRI) + expected_sun = _sidereal_lon(jd, swe.SUN, swe.SIDM_RAMAN) + expected_moon = _sidereal_lon(jd, swe.MOON, swe.SIDM_RAMAN) _apply_ayanamsa('raman') actual = _swisseph_sun_moon_lon(jd) @@ -92,10 +92,10 @@ def test_muhurta_sun_moon_helper_defaults_to_lahiri_after_global_raman_switch(): assert _angular_diff(actual[1], expected_moon) < 1e-9 -def test_cmd_muhurta_helper_defaults_to_lahiri_after_global_raman_switch(): +def test_cmd_muhurta_helper_defaults_to_product_ayanamsa(): jd = swe.julday(2026, 6, 25, 4) - expected_sun = _sidereal_lon(jd, swe.SUN, swe.SIDM_LAHIRI) - expected_moon = _sidereal_lon(jd, swe.MOON, swe.SIDM_LAHIRI) + expected_sun = _sidereal_lon(jd, swe.SUN, swe.SIDM_RAMAN) + expected_moon = _sidereal_lon(jd, swe.MOON, swe.SIDM_RAMAN) _apply_ayanamsa('raman') sun, moon, has_swe = _get_sun_moon_lons(2026, 6, 25, hour=4)