feat(report): 完整数据版末尾增加独立年度方法参考
PyJHora 只在子进程里计算请求年及后两年。对话、校正和其余章节保持不导入时的结果。结果指纹不算这一章。 Windows 自测:隐私标记、导入扫描、结果指纹和 S7 退出码 0。阅读页、英文和年运章在此之前已通过。虚构盘 0 中英一起最高 59 秒。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
85e8adddbc
commit
c644881a30
@@ -39,6 +39,13 @@ def _jhora_importable() -> bool:
|
||||
return _jhora_spec_cache
|
||||
|
||||
|
||||
def _call_isolated_runner(fn, profile, birth_julian_day, place, age, *, research: bool):
|
||||
"""Call a 4-arg test stand-in, or the real runner that also takes research."""
|
||||
if "research" in signature(fn).parameters:
|
||||
return fn(profile, birth_julian_day, place, age, research=research)
|
||||
return fn(profile, birth_julian_day, place, age)
|
||||
|
||||
|
||||
def _no_jhora_tajaka_replay(settings: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Same dict the child returns when ``import jhora`` raises ModuleNotFoundError."""
|
||||
copied = dict(settings)
|
||||
@@ -92,6 +99,7 @@ def probe_pyjhora_year_lord(
|
||||
import_module: Callable[[str], Any] = _import_module,
|
||||
distribution_version: Callable[[str], str] = _distribution_version,
|
||||
isolated_replay: Callable[[dict[str, Any], float, Any, int | float], dict[str, Any]] | None = None,
|
||||
research: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Probe PyJHora for an annual Tajaka/Panchadhikari Year Lord API.
|
||||
|
||||
@@ -123,8 +131,13 @@ def probe_pyjhora_year_lord(
|
||||
"year_lord_replay": replay,
|
||||
}
|
||||
|
||||
replay_runner = isolated_replay or _run_isolated_tajaka_year_lord
|
||||
isolated = replay_runner(profile, birth_julian_day, place, age)
|
||||
if isolated_replay is None:
|
||||
isolated = _call_isolated_runner(
|
||||
_run_isolated_tajaka_year_lord,
|
||||
profile, birth_julian_day, place, age, research=research,
|
||||
)
|
||||
else:
|
||||
isolated = isolated_replay(profile, birth_julian_day, place, age)
|
||||
if isolated.get("reason") != "pyjhora_tajaka_api_missing":
|
||||
replay.update(isolated)
|
||||
replay["normalized_candidates"] = _normalize_tajaka_candidates(replay.get("raw_candidates"))
|
||||
@@ -159,6 +172,8 @@ def _run_isolated_tajaka_year_lord(
|
||||
birth_julian_day: float,
|
||||
place: Any,
|
||||
age: int | float,
|
||||
*,
|
||||
research: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
settings = _replay_settings(profile)
|
||||
request = {
|
||||
@@ -168,7 +183,9 @@ def _run_isolated_tajaka_year_lord(
|
||||
"settings": settings,
|
||||
}
|
||||
request_hash = _request_hash(request)
|
||||
if not _jhora_importable():
|
||||
# Product calls leave research false and return the no-install golden.
|
||||
# They do not call find_spec. Only an explicit research replay may spawn.
|
||||
if not research or not _jhora_importable():
|
||||
replay = _no_jhora_tajaka_replay(settings)
|
||||
replay["request_hash"] = request_hash
|
||||
return replay
|
||||
@@ -298,6 +315,8 @@ def _run_isolated_annual_replay(
|
||||
birth_julian_day: float,
|
||||
place: Any,
|
||||
age: int | float,
|
||||
*,
|
||||
research: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Call optional PyJHora annual surfaces with a child-process Place."""
|
||||
|
||||
@@ -309,7 +328,8 @@ def _run_isolated_annual_replay(
|
||||
"settings": settings,
|
||||
}
|
||||
request_hash = _request_hash(request)
|
||||
if not _jhora_importable():
|
||||
# Same pin as the Tajaka year-lord child: product traffic stays on the golden.
|
||||
if not research or not _jhora_importable():
|
||||
replay = _no_jhora_annual_replay(settings)
|
||||
replay["request_hash"] = request_hash
|
||||
return replay
|
||||
@@ -662,16 +682,25 @@ def replay_pyjhora_annual(
|
||||
import_module: Callable[[str], Any] = _import_module,
|
||||
year_lord_probe: Callable[..., dict[str, Any]] | None = None,
|
||||
isolated_annual_replay: Callable[[dict[str, Any], float, Any, int | float], dict[str, Any]] | None = None,
|
||||
research: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Replay annual PyJHora methods for the same profile, if available."""
|
||||
|
||||
year_lord_observer = year_lord_probe or probe_pyjhora_year_lord
|
||||
year_lord_result = year_lord_observer(
|
||||
profile,
|
||||
birth_julian_day=birth_julian_day,
|
||||
place=place,
|
||||
age=age,
|
||||
)
|
||||
if year_lord_probe is None:
|
||||
year_lord_result = probe_pyjhora_year_lord(
|
||||
profile,
|
||||
birth_julian_day=birth_julian_day,
|
||||
place=place,
|
||||
age=age,
|
||||
research=research,
|
||||
)
|
||||
else:
|
||||
year_lord_result = year_lord_probe(
|
||||
profile,
|
||||
birth_julian_day=birth_julian_day,
|
||||
place=place,
|
||||
age=age,
|
||||
)
|
||||
year_lord_replay = year_lord_result.get("year_lord_replay") if isinstance(year_lord_result, dict) else None
|
||||
blocked = _base(profile)
|
||||
blocked["year_lord_replay"] = year_lord_replay if isinstance(year_lord_replay, dict) else {
|
||||
@@ -681,8 +710,13 @@ def replay_pyjhora_annual(
|
||||
blocked["evidence_scope"] = "pyjhora_behavior_only"
|
||||
blocked["parity_status"] = "not_multiengine_parity"
|
||||
if isolated_annual_replay is not None or (import_module is _import_module and year_lord_probe is None):
|
||||
observer = isolated_annual_replay or _run_isolated_annual_replay
|
||||
observed = observer(profile, birth_julian_day, place, age)
|
||||
if isolated_annual_replay is None:
|
||||
observed = _call_isolated_runner(
|
||||
_run_isolated_annual_replay,
|
||||
profile, birth_julian_day, place, age, research=research,
|
||||
)
|
||||
else:
|
||||
observed = isolated_annual_replay(profile, birth_julian_day, place, age)
|
||||
if not isinstance(observed, dict):
|
||||
observed = _annual_replay_blocked(
|
||||
"pyjhora_isolated_annual_replay_invalid_payload",
|
||||
|
||||
@@ -17,6 +17,38 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
ADAPTER_RELATIVE = "benchmarks/jyotish/scripts/run_pyjhora_compare.py"
|
||||
|
||||
|
||||
def build_product_report() -> dict:
|
||||
"""Product path: the same dict as a machine without PyJHora.
|
||||
|
||||
Does not call find_spec and does not import jhora. The CLI still uses
|
||||
``build_report``, which may probe.
|
||||
"""
|
||||
adapter = ROOT / ADAPTER_RELATIVE
|
||||
module_name = os.environ.get("PYJHORA_MODULE_NAME", "jhora").strip() or "jhora"
|
||||
adapter_exists = adapter.exists()
|
||||
status = "missing_adapter" if not adapter_exists else "missing_dependency"
|
||||
return {
|
||||
"scope": "pyjhora_adapter_diagnostics",
|
||||
"status": status,
|
||||
"adapter_command": f"python3 {ADAPTER_RELATIVE}",
|
||||
"adapter_exists": adapter_exists,
|
||||
"dependency_module": module_name,
|
||||
"dependency_available": False,
|
||||
"missing_dependency": module_name,
|
||||
"dependency_import_error": None,
|
||||
"install_hint": {
|
||||
"package": "PyJHora",
|
||||
"commands": [
|
||||
"pip install -r requirements.txt -r requirements-reference-engines.txt"
|
||||
],
|
||||
"note": "Install in an isolated optional benchmark environment, not as a hard runtime dependency.",
|
||||
},
|
||||
"license_boundary": "AGPL external benchmark only; do not vendor or make it a runtime dependency.",
|
||||
"ephemeris_data_note": "Recent PyJHora releases may require separate Swiss Ephemeris data download/configuration before full chart comparison can run.",
|
||||
"boundary": "This verifies required PyJHora modules import successfully, but does not run chart comparison.",
|
||||
}
|
||||
|
||||
|
||||
def build_report() -> dict:
|
||||
adapter = ROOT / ADAPTER_RELATIVE
|
||||
module_name = os.environ.get("PYJHORA_MODULE_NAME", "jhora").strip() or "jhora"
|
||||
|
||||
@@ -2332,6 +2332,29 @@ def _attach_annual_tajika_pack(packet: dict, args) -> dict:
|
||||
return packet
|
||||
|
||||
|
||||
def _attach_pyjhora_annual_method_reference(packet: dict, args):
|
||||
"""One call into the subprocess-backed annual method reference."""
|
||||
try:
|
||||
from scripts.report_render.annual_method_reference import attach_pyjhora_annual_method_reference
|
||||
except ModuleNotFoundError: # pragma: no cover - direct script execution
|
||||
from report_render.annual_method_reference import attach_pyjhora_annual_method_reference
|
||||
return attach_pyjhora_annual_method_reference(packet, args)
|
||||
|
||||
|
||||
def _bind_result_without_annual_method_reference(packet: dict, profile: dict) -> dict:
|
||||
"""Keep the new chapter out of result_hash. The chart result itself is unchanged."""
|
||||
worksheets = packet.get("worksheets") if isinstance(packet, dict) else None
|
||||
timing = worksheets.get("timing_and_predictive_systems") if isinstance(worksheets, dict) else None
|
||||
detached = None
|
||||
if isinstance(timing, dict) and "pyjhora_annual_method_reference" in timing:
|
||||
detached = timing.pop("pyjhora_annual_method_reference")
|
||||
try:
|
||||
return bind_result_to_profile(packet, profile)
|
||||
finally:
|
||||
if isinstance(timing, dict) and detached is not None:
|
||||
timing["pyjhora_annual_method_reference"] = detached
|
||||
|
||||
|
||||
def _uncertainty_minutes_from_args(args):
|
||||
explicit = getattr(args, 'uncertainty_minutes', None)
|
||||
if explicit not in (None, ''):
|
||||
@@ -18083,6 +18106,7 @@ def build_professional_report_reference_packet(
|
||||
packet = build_pl9_style_export_packet(full_reading, include_raw=True)
|
||||
packet = attach_calculation_profile(packet, args)
|
||||
packet = _attach_annual_tajika_pack(packet, args)
|
||||
packet = _attach_pyjhora_annual_method_reference(packet, args)
|
||||
packet = _attach_report_governance_contracts(packet, args)
|
||||
packet = _attach_base_charts_pack(packet)
|
||||
packet = _attach_visual_chart_pack(packet, args)
|
||||
@@ -18100,7 +18124,7 @@ def build_professional_report_reference_packet(
|
||||
and isinstance(profile.get('input_hash'), str)
|
||||
and len(profile['input_hash']) == 64
|
||||
):
|
||||
delivered = bind_result_to_profile(delivered, profile)
|
||||
delivered = _bind_result_without_annual_method_reference(delivered, profile)
|
||||
if isinstance(delivered, dict) and delivered.get('selected_report_scope') == 'full':
|
||||
evaluate_full_report = _try_attr_import('full_report_quality_gate', 'evaluate_full_report')
|
||||
if evaluate_full_report is None:
|
||||
|
||||
@@ -2417,7 +2417,8 @@ def render_full_data_markdown(packet: dict) -> str:
|
||||
_pl9_english_report_title(packet),
|
||||
)
|
||||
rendered = _append_parity_chapters(rendered, packet, "en")
|
||||
return _apply_local_report_conventions(rendered, language="en")
|
||||
rendered = _apply_local_report_conventions(rendered, language="en")
|
||||
return _append_annual_method_reference(rendered, packet, "en")
|
||||
zh_structured_appendix = re.sub(
|
||||
r"^#\s+.*?(?:\n{2,}|\n)",
|
||||
"",
|
||||
@@ -2439,7 +2440,20 @@ def render_full_data_markdown(packet: dict) -> str:
|
||||
)
|
||||
rendered = _sanitize_pl9_ai_density_markdown(zh_markdown, _pl9_public_report_title(packet))
|
||||
rendered = _append_parity_chapters(rendered, packet, "zh")
|
||||
return _apply_local_report_conventions(rendered, language="zh")
|
||||
rendered = _apply_local_report_conventions(rendered, language="zh")
|
||||
return _append_annual_method_reference(rendered, packet, "zh")
|
||||
|
||||
|
||||
def _append_annual_method_reference(markdown: str, packet: dict, language: str) -> str:
|
||||
"""After the sanitizers. Those sanitizers delete any line that names PyJHora."""
|
||||
try:
|
||||
from scripts.report_render.annual_method_reference import render_annual_method_reference
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
from report_render.annual_method_reference import render_annual_method_reference
|
||||
extra = render_annual_method_reference(packet, language).strip()
|
||||
if not extra:
|
||||
return markdown
|
||||
return markdown.rstrip() + "\n\n" + extra + "\n"
|
||||
|
||||
|
||||
def read_full_data_domain(packet: Mapping, domain: str) -> dict:
|
||||
@@ -2452,7 +2466,7 @@ def read_full_data_domain(packet: Mapping, domain: str) -> dict:
|
||||
raise KeyError(domain)
|
||||
if not isinstance(packet, Mapping):
|
||||
raise TypeError("packet must be a mapping")
|
||||
return copy.deepcopy({
|
||||
copied = copy.deepcopy({
|
||||
"domain": domain,
|
||||
"label": FULL_DATA_DOMAINS[domain],
|
||||
"report_version": packet.get("report_version"),
|
||||
@@ -2461,3 +2475,8 @@ def read_full_data_domain(packet: Mapping, domain: str) -> dict:
|
||||
"calculation_profile": packet.get("calculation_profile"),
|
||||
"worksheets": packet.get("worksheets"),
|
||||
})
|
||||
worksheets = copied.get("worksheets")
|
||||
timing = worksheets.get("timing_and_predictive_systems") if isinstance(worksheets, dict) else None
|
||||
if isinstance(timing, dict):
|
||||
timing.pop("pyjhora_annual_method_reference", None)
|
||||
return copied
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Build a privacy-safe, request-level three-engine rectification parity packet."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
@@ -45,12 +44,12 @@ def _local_d1(case: dict[str, Any]) -> dict[str, str]:
|
||||
|
||||
|
||||
def _pyjhora_d1(case: dict[str, Any]) -> dict[str, str]:
|
||||
"""PyJHora D1 signs. The jhora import stays in the reference module."""
|
||||
try:
|
||||
reference = importlib.import_module("pyjhora_rectification_d1_reference")
|
||||
except ModuleNotFoundError:
|
||||
reference = importlib.import_module("scripts.pyjhora_rectification_d1_reference")
|
||||
return reference.pyjhora_d1(case)
|
||||
"""Product path keeps today's missing-install row and does not load PyJHora.
|
||||
|
||||
``case`` stays in the signature so the packet caller is unchanged.
|
||||
"""
|
||||
del case
|
||||
raise ModuleNotFoundError("No module named 'jhora'")
|
||||
|
||||
|
||||
def _ensure_jyotishganit_data_dir() -> str:
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
"""Parent side of the full-data annual method reference.
|
||||
|
||||
This module does not import PyJHora. The worker runs in a child process.
|
||||
The worker module name appears only as the ``-m`` argument below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
log = logging.getLogger("jyotisha.annual_method_reference")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKER_MODULE = "scripts.report_render.pyjhora_annual_reference_worker"
|
||||
# Windows measured about 50s for three years. 45s killed a finished call.
|
||||
# Linux is expected to be faster. 120s still leaves the report under the 180s wait.
|
||||
DEFAULT_TIMEOUT_SECONDS = 120
|
||||
TABLES = ("mudda_birth", "mudda_annual", "patyayini", "tajika")
|
||||
CHAPTER_ZH = "独立年度方法参考"
|
||||
CHAPTER_EN = "Independent Annual Method Reference"
|
||||
DASHA_LORDS = {
|
||||
0: "Sun",
|
||||
1: "Moon",
|
||||
2: "Mars",
|
||||
3: "Mercury",
|
||||
4: "Jupiter",
|
||||
5: "Venus",
|
||||
6: "Saturn",
|
||||
7: "Rahu",
|
||||
8: "Ketu",
|
||||
}
|
||||
YOGA_NAMES = {
|
||||
"ithasala": "Ithasala",
|
||||
"eesarpha": "Esharpha",
|
||||
"manahoo": "Manahoo",
|
||||
"kamboola": "Kamboola",
|
||||
"gairi_kamboola": "Gairi-Kamboola",
|
||||
"khallasara": "Khallsara",
|
||||
"radda": "Radda",
|
||||
"nakta": "Nakta",
|
||||
"yamaya": "Yamaya",
|
||||
}
|
||||
YOGA_ORDER = (
|
||||
"ithasala",
|
||||
"eesarpha",
|
||||
"manahoo",
|
||||
"kamboola",
|
||||
"gairi_kamboola",
|
||||
"khallasara",
|
||||
"radda",
|
||||
"nakta",
|
||||
"yamaya",
|
||||
)
|
||||
ZH_PLANETS = {
|
||||
"Sun": "太阳",
|
||||
"Moon": "月亮",
|
||||
"Mars": "火星",
|
||||
"Mercury": "水星",
|
||||
"Jupiter": "木星",
|
||||
"Venus": "金星",
|
||||
"Saturn": "土星",
|
||||
"Rahu": "罗睺",
|
||||
"Ketu": "计都",
|
||||
"Lagna": "上升",
|
||||
}
|
||||
|
||||
_CACHE: dict[str, dict[str, Any]] = {}
|
||||
_RUNNER: Callable[[dict[str, Any], int], dict[str, Any]] | None = None
|
||||
|
||||
|
||||
def set_reference_runner(runner: Callable[[dict[str, Any], int], dict[str, Any]] | None) -> None:
|
||||
"""Tests and the 200-chart sweep install a stand-in. ``None`` restores the child process."""
|
||||
global _RUNNER
|
||||
_RUNNER = runner
|
||||
|
||||
|
||||
def reset_reference_cache() -> None:
|
||||
_CACHE.clear()
|
||||
|
||||
|
||||
def strip_annual_method_chapter(markdown: str) -> str:
|
||||
"""Drop this chapter so older full-text checks still see the previous report."""
|
||||
lines = (markdown or "").splitlines(keepends=True)
|
||||
start = None
|
||||
for index, line in enumerate(lines):
|
||||
title = line.strip()
|
||||
if title in {f"## {CHAPTER_ZH}", f"## {CHAPTER_EN}"}:
|
||||
start = index
|
||||
break
|
||||
if start is None:
|
||||
return markdown or ""
|
||||
body = "".join(lines[:start]).rstrip()
|
||||
return (body + "\n") if body else ""
|
||||
|
||||
|
||||
def _request_hash(payload: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _pick(args: Any, packet: dict[str, Any], name: str) -> Any:
|
||||
value = getattr(args, name, None)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
birth = packet.get("birth_info") if isinstance(packet.get("birth_info"), dict) else {}
|
||||
if birth.get(name) not in (None, ""):
|
||||
return birth.get(name)
|
||||
profile = packet.get("calculation_profile") if isinstance(packet.get("calculation_profile"), dict) else {}
|
||||
if profile.get(name) not in (None, ""):
|
||||
return profile.get(name)
|
||||
return None
|
||||
|
||||
|
||||
def _payload_from(args: Any, packet: dict[str, Any]) -> dict[str, Any] | None:
|
||||
try:
|
||||
year = int(_pick(args, packet, "year"))
|
||||
target_year = int(_pick(args, packet, "target_year") or year)
|
||||
birth = {
|
||||
"name": "request",
|
||||
"year": year,
|
||||
"month": int(_pick(args, packet, "month")),
|
||||
"day": int(_pick(args, packet, "day")),
|
||||
"hour": int(_pick(args, packet, "hour") or 0),
|
||||
"minute": int(_pick(args, packet, "minute") or 0),
|
||||
"second": int(_pick(args, packet, "second") or 0),
|
||||
"lat": float(_pick(args, packet, "lat")),
|
||||
"lon": float(_pick(args, packet, "lon")),
|
||||
"tz": float(_pick(args, packet, "tz")),
|
||||
}
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
ayanamsa = _pick(args, packet, "ayanamsa") or "lahiri"
|
||||
node_mode = _pick(args, packet, "node_mode") or "mean"
|
||||
return {
|
||||
"birth": birth,
|
||||
"ayanamsa": str(ayanamsa),
|
||||
"node_mode": str(node_mode),
|
||||
"target_year": target_year,
|
||||
}
|
||||
|
||||
|
||||
def _invoke_worker(payload: dict[str, Any], timeout: int) -> dict[str, Any]:
|
||||
if _RUNNER is not None:
|
||||
return _RUNNER(payload, timeout)
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-m", WORKER_MODULE],
|
||||
input=json.dumps(payload, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
log.warning(
|
||||
"annual method reference worker exit %s: %s",
|
||||
completed.returncode,
|
||||
(completed.stderr or "")[-500:],
|
||||
)
|
||||
return {"ok": False, "reason": f"exit_{completed.returncode}"}
|
||||
text = (completed.stdout or "").strip()
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
for line in reversed(text.splitlines()):
|
||||
if line.strip().startswith("{"):
|
||||
try:
|
||||
parsed = json.loads(line)
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if parsed is None:
|
||||
log.warning("annual method reference worker returned invalid json")
|
||||
return {"ok": False, "reason": "invalid_json"}
|
||||
if not isinstance(parsed, dict):
|
||||
log.warning("annual method reference worker returned %s", type(parsed).__name__)
|
||||
return {"ok": False, "reason": "invalid_payload"}
|
||||
if completed.stderr and not parsed.get("ok"):
|
||||
log.warning("annual method reference worker stderr: %s", completed.stderr[-500:])
|
||||
return parsed
|
||||
|
||||
|
||||
def _year_shell(year: int, *, ayanamsa: str | None = None, node_header: str = "default_true") -> dict[str, Any]:
|
||||
return {
|
||||
"requested_year": year,
|
||||
"ayanamsa": ayanamsa,
|
||||
"ayanamsa_ok": False,
|
||||
"node_header": node_header,
|
||||
"patyayini_anchor": None,
|
||||
"tajika_anchor": None,
|
||||
"tables": {name: {"ok": False, "rows": []} for name in TABLES},
|
||||
}
|
||||
|
||||
|
||||
def _unavailable(target_year: int) -> dict[str, Any]:
|
||||
return {"years": [_year_shell(target_year + offset) for offset in range(3)]}
|
||||
|
||||
|
||||
def _lord_label(value: Any) -> str:
|
||||
if isinstance(value, str) and value.strip().upper() == "L":
|
||||
return "Lagna"
|
||||
try:
|
||||
return str(DASHA_LORDS.get(int(value), value))
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
|
||||
def _display_clock(start: Any) -> str | None:
|
||||
if not isinstance(start, (list, tuple)) or len(start) != 4:
|
||||
return None
|
||||
try:
|
||||
year, month, day = (int(part) for part in start[:3])
|
||||
hour_decimal = float(start[3])
|
||||
stamp = datetime(year, month, day) + timedelta(hours=hour_decimal)
|
||||
return stamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def _duration_text(value: Any) -> str:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
text = f"{number:.4f}".rstrip("0").rstrip(".")
|
||||
return text
|
||||
|
||||
|
||||
def _period_rows(periods: Any, *, with_end: bool) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
if not isinstance(periods, list):
|
||||
return rows
|
||||
for order, period in enumerate(periods, start=1):
|
||||
if not isinstance(period, (list, tuple)) or len(period) < 3:
|
||||
continue
|
||||
lords, start, duration = period[0], period[1], period[2]
|
||||
if not isinstance(lords, (list, tuple)) or len(lords) < 2:
|
||||
continue
|
||||
display = _display_clock(start)
|
||||
if display is None:
|
||||
continue
|
||||
rows.append({
|
||||
"order": order,
|
||||
"main_lord": _lord_label(lords[0]),
|
||||
"sub_lord": _lord_label(lords[1]),
|
||||
"start": display,
|
||||
"end": None,
|
||||
"duration": _duration_text(duration),
|
||||
})
|
||||
if with_end:
|
||||
for index, row in enumerate(rows):
|
||||
following = rows[index + 1] if index + 1 < len(rows) else None
|
||||
row["end"] = following["start"] if following else None
|
||||
return rows
|
||||
|
||||
|
||||
def _anchor_text(anchor: Any) -> str | None:
|
||||
if not isinstance(anchor, dict):
|
||||
return None
|
||||
date = anchor.get("date")
|
||||
time = anchor.get("time")
|
||||
if not isinstance(date, list) or len(date) != 3 or not isinstance(time, str):
|
||||
return None
|
||||
try:
|
||||
return f"{int(date[0]):04d}-{int(date[1]):02d}-{int(date[2]):02d} {time}"
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _planet_name(value: Any) -> str:
|
||||
return _lord_label(value)
|
||||
|
||||
|
||||
def _relationship_groups(raw: Any) -> list[list[str]]:
|
||||
if raw in (None, []):
|
||||
return []
|
||||
if isinstance(raw, (list, tuple)):
|
||||
if raw and all(not isinstance(item, (list, tuple, dict)) for item in raw):
|
||||
return [[_planet_name(item) for item in raw]]
|
||||
groups = []
|
||||
for item in raw:
|
||||
if isinstance(item, (list, tuple)):
|
||||
groups.append([_planet_name(part) for part in item])
|
||||
else:
|
||||
groups.append([_planet_name(item)])
|
||||
return groups
|
||||
return [[_planet_name(raw)]]
|
||||
|
||||
|
||||
def _tajika_rows(raw: Any) -> list[dict[str, Any]]:
|
||||
yogas = raw.get("yogas") if isinstance(raw, dict) and isinstance(raw.get("yogas"), dict) else {}
|
||||
failures = raw.get("failures") if isinstance(raw, dict) and isinstance(raw.get("failures"), dict) else {}
|
||||
rows = []
|
||||
for name in YOGA_ORDER:
|
||||
if name in failures:
|
||||
state = "unavailable"
|
||||
groups: list[list[str]] = []
|
||||
elif name not in yogas:
|
||||
state = "unavailable"
|
||||
groups = []
|
||||
elif yogas[name] is None:
|
||||
state = "unknown"
|
||||
groups = []
|
||||
elif yogas[name] == []:
|
||||
state = "empty"
|
||||
groups = []
|
||||
else:
|
||||
state = "returned"
|
||||
groups = _relationship_groups(yogas[name])
|
||||
rows.append({
|
||||
"yoga": YOGA_NAMES.get(name, name.title()),
|
||||
"state": state,
|
||||
"relationships": groups,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _normalize_year(raw: dict[str, Any], *, ayanamsa: str | None, ayanamsa_ok: bool, node_header: str) -> dict[str, Any]:
|
||||
year = int(raw.get("requested_year"))
|
||||
shell = _year_shell(year, ayanamsa=ayanamsa, node_header=node_header)
|
||||
shell["ayanamsa_ok"] = ayanamsa_ok
|
||||
if not ayanamsa_ok:
|
||||
return shell
|
||||
anchor = _anchor_text(raw.get("anchor"))
|
||||
shell["patyayini_anchor"] = anchor
|
||||
shell["tajika_anchor"] = anchor
|
||||
mudda_birth = _period_rows(raw.get("mudda_birth"), with_end=False)
|
||||
mudda_annual = _period_rows(raw.get("mudda_annual"), with_end=False)
|
||||
patyayini = _period_rows(raw.get("patyayini"), with_end=True)
|
||||
tajika = _tajika_rows(raw.get("tajika")) if isinstance(raw.get("tajika"), dict) else []
|
||||
shell["tables"] = {
|
||||
"mudda_birth": {"ok": bool(mudda_birth) and "mudda_birth_error" not in raw, "rows": mudda_birth},
|
||||
"mudda_annual": {"ok": bool(mudda_annual) and "annual_return_error" not in raw and "annual_chart_error" not in raw, "rows": mudda_annual},
|
||||
"patyayini": {"ok": bool(patyayini) and "patyayini_error" not in raw, "rows": patyayini},
|
||||
"tajika": {"ok": bool(tajika) and "annual_chart_error" not in raw, "rows": tajika},
|
||||
}
|
||||
return shell
|
||||
|
||||
|
||||
def normalize_worker_payload(payload: dict[str, Any], target_year: int) -> dict[str, Any]:
|
||||
"""Turn the child JSON into reader rows. Failure text stays out of the packet."""
|
||||
if not isinstance(payload, dict) or not payload.get("ok") or not payload.get("ayanamsa_ok"):
|
||||
reference = _unavailable(target_year)
|
||||
if isinstance(payload, dict):
|
||||
reference["ayanamsa_requested"] = payload.get("ayanamsa_requested")
|
||||
reference["node_header"] = payload.get("node_header") or "default_true"
|
||||
for year in reference["years"]:
|
||||
year["node_header"] = reference["node_header"]
|
||||
year["ayanamsa"] = payload.get("ayanamsa_readback") or payload.get("ayanamsa_requested")
|
||||
return reference
|
||||
years = []
|
||||
for raw in payload.get("years") or []:
|
||||
if isinstance(raw, dict):
|
||||
years.append(_normalize_year(
|
||||
raw,
|
||||
ayanamsa=payload.get("ayanamsa_readback"),
|
||||
ayanamsa_ok=True,
|
||||
node_header=str(payload.get("node_header") or "default_true"),
|
||||
))
|
||||
if len(years) != 3:
|
||||
return _unavailable(target_year)
|
||||
return {"years": years}
|
||||
|
||||
|
||||
def build_pyjhora_annual_method_reference(args: Any, packet: dict[str, Any], *, timeout: int = DEFAULT_TIMEOUT_SECONDS) -> dict[str, Any]:
|
||||
payload = _payload_from(args, packet if isinstance(packet, dict) else {})
|
||||
if payload is None:
|
||||
return _unavailable(0)
|
||||
key = _request_hash(payload)
|
||||
if _RUNNER is None and key in _CACHE:
|
||||
return _CACHE[key]
|
||||
try:
|
||||
raw = _invoke_worker(payload, timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
log.warning("annual method reference worker timed out after %s seconds", timeout)
|
||||
raw = {"ok": False, "reason": "timeout"}
|
||||
except Exception as exc:
|
||||
log.warning("annual method reference worker could not start: %s", exc.__class__.__name__)
|
||||
raw = {"ok": False, "reason": exc.__class__.__name__}
|
||||
reference = normalize_worker_payload(raw, int(payload["target_year"]))
|
||||
if _RUNNER is None and raw.get("ok"):
|
||||
_CACHE[key] = reference
|
||||
return reference
|
||||
|
||||
|
||||
def attach_pyjhora_annual_method_reference(packet: dict[str, Any], args: Any) -> dict[str, Any]:
|
||||
"""Store the chapter under the new packet key. Existing keys stay untouched."""
|
||||
if not isinstance(packet, dict):
|
||||
return packet
|
||||
worksheets = packet.get("worksheets")
|
||||
timing = worksheets.get("timing_and_predictive_systems") if isinstance(worksheets, dict) else None
|
||||
if not isinstance(timing, dict):
|
||||
return packet
|
||||
timing["pyjhora_annual_method_reference"] = build_pyjhora_annual_method_reference(args, packet)
|
||||
return packet
|
||||
|
||||
|
||||
def _copy(packet: dict[str, Any]) -> dict[str, Any]:
|
||||
worksheets = packet.get("worksheets") if isinstance(packet.get("worksheets"), dict) else {}
|
||||
timing = worksheets.get("timing_and_predictive_systems") if isinstance(worksheets.get("timing_and_predictive_systems"), dict) else {}
|
||||
reference = timing.get("pyjhora_annual_method_reference")
|
||||
return reference if isinstance(reference, dict) else {}
|
||||
|
||||
|
||||
def _planet(name: str, language: str) -> str:
|
||||
if language == "zh":
|
||||
return ZH_PLANETS.get(name, name)
|
||||
return name
|
||||
|
||||
|
||||
def _relationships(groups: list[list[str]], language: str) -> str:
|
||||
rendered = []
|
||||
for group in groups:
|
||||
rendered.append("–".join(_planet(name, language) for name in group))
|
||||
return "; ".join(item for item in rendered if item)
|
||||
|
||||
|
||||
def _state_label(state: str, language: str) -> str:
|
||||
labels = {
|
||||
"returned": ("Combination returned; conditions still need checking", "已返回组合,需核对条件"),
|
||||
"empty": ("No combination detected", "检测为空"),
|
||||
"unknown": ("Unknown", "未知"),
|
||||
"unavailable": ("Result unavailable", "未获得结果"),
|
||||
}
|
||||
pair = labels.get(state, labels["unavailable"])
|
||||
return pair[1] if language == "zh" else pair[0]
|
||||
|
||||
|
||||
def _table(headers: list[str], rows: list[list[str]]) -> str:
|
||||
head = "| " + " | ".join(headers) + " |"
|
||||
rule = "| " + " | ".join("---" for _ in headers) + " |"
|
||||
body = ["| " + " | ".join(row) + " |" for row in rows]
|
||||
return "\n".join([head, rule, *body])
|
||||
|
||||
|
||||
def _one_year(year: dict[str, Any], language: str) -> str:
|
||||
requested = year.get("requested_year")
|
||||
if language == "zh":
|
||||
lines = [f"### 年度方法补充数据(请求年份 {requested})", "", "来源:PyJHora。本节数字由 PyJHora 计算,与前面的原生周期分开读。"]
|
||||
node = {"mean": "平交点", "true": "真交点"}.get(str(year.get("node_header")), "PyJHora 默认(真交点)")
|
||||
ayanamsa = year.get("ayanamsa") or "未能按请求设定"
|
||||
lines.append(f"岁差:{ayanamsa}。交点:{node}。")
|
||||
lines.append(f"Patyayini 回归锚点:{year.get('patyayini_anchor') or '未取得'}")
|
||||
lines.append(f"Tajika 回归锚点:{year.get('tajika_anchor') or '未取得'}")
|
||||
headings = {
|
||||
"mudda_birth": "#### Mudda(出生时月亮起算)",
|
||||
"mudda_annual": "#### Mudda(年盘月亮起算)",
|
||||
"patyayini": "#### Patyayini",
|
||||
"tajika": "#### Tajika 瑜伽",
|
||||
}
|
||||
notes = {
|
||||
"mudda_birth": "时长单位是天。这一表没有结束时间。",
|
||||
"mudda_annual": "时长单位是天。这一表没有结束时间。",
|
||||
"patyayini": "时长单位是年。结束时间是下一段的开始。最后一段没有结束时间。",
|
||||
"tajika": "空的组合写检测为空。没有返回结果时写未获得结果。",
|
||||
}
|
||||
period_headers = ["序号", "大运主星", "小运主星", "开始", "时长(天)"]
|
||||
patyayini_headers = ["序号", "大运主星", "小运主星", "开始", "结束", "时长(年)"]
|
||||
yoga_headers = ["瑜伽", "结果", "行星关系"]
|
||||
missing = "未获得结果"
|
||||
else:
|
||||
lines = [
|
||||
f"### Supplementary Annual Method Data (requested year {requested})",
|
||||
"",
|
||||
"Source: PyJHora. These figures are calculated by PyJHora and are read separately from the native cycles above.",
|
||||
]
|
||||
node = {"mean": "mean", "true": "true"}.get(str(year.get("node_header")), "PyJHora default (true node)")
|
||||
ayanamsa = year.get("ayanamsa") or "could not be set to the requested mode"
|
||||
lines.append(f"Ayanamsa: {ayanamsa}. Nodes: {node}.")
|
||||
lines.append(f"Patyayini return anchor: {year.get('patyayini_anchor') or 'Not obtained'}")
|
||||
lines.append(f"Tajika return anchor: {year.get('tajika_anchor') or 'Not obtained'}")
|
||||
headings = {
|
||||
"mudda_birth": "#### Mudda (Moon at birth)",
|
||||
"mudda_annual": "#### Mudda (Moon in annual chart)",
|
||||
"patyayini": "#### Patyayini",
|
||||
"tajika": "#### Tajika Yoga",
|
||||
}
|
||||
notes = {
|
||||
"mudda_birth": "Duration is in days. This table has no end time.",
|
||||
"mudda_annual": "Duration is in days. This table has no end time.",
|
||||
"patyayini": "Duration is in years. The end is the next row's start. The last row has no end time.",
|
||||
"tajika": "An empty detection is written as no combination. A failed call is written as result unavailable.",
|
||||
}
|
||||
period_headers = ["No.", "MD lord", "AD lord", "Start", "Duration (days)"]
|
||||
patyayini_headers = ["No.", "MD lord", "AD lord", "Start", "End", "Duration (years)"]
|
||||
yoga_headers = ["Yoga", "Result", "Planet relationships"]
|
||||
missing = "Result unavailable"
|
||||
tables = year.get("tables") if isinstance(year.get("tables"), dict) else {}
|
||||
for key in TABLES:
|
||||
lines.extend(["", headings[key], "", notes[key], ""])
|
||||
table = tables.get(key) if isinstance(tables.get(key), dict) else {}
|
||||
rows = table.get("rows") if table.get("ok") and isinstance(table.get("rows"), list) else []
|
||||
if not rows:
|
||||
lines.append(missing)
|
||||
continue
|
||||
if key == "tajika":
|
||||
rendered = [[
|
||||
str(row.get("yoga") or ""),
|
||||
_state_label(str(row.get("state") or ""), language),
|
||||
_relationships(row.get("relationships") or [], language),
|
||||
] for row in rows if isinstance(row, dict)]
|
||||
lines.append(_table(yoga_headers, rendered))
|
||||
continue
|
||||
headers = patyayini_headers if key == "patyayini" else period_headers
|
||||
rendered = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
end = row.get("end") or ("—" if language == "zh" else "-")
|
||||
base = [
|
||||
str(row.get("order") or ""),
|
||||
_planet(str(row.get("main_lord") or ""), language),
|
||||
_planet(str(row.get("sub_lord") or ""), language),
|
||||
str(row.get("start") or ""),
|
||||
]
|
||||
if key == "patyayini":
|
||||
base.append(str(end))
|
||||
base.append(str(row.get("duration") or ""))
|
||||
rendered.append(base)
|
||||
lines.append(_table(headers, rendered))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_annual_method_reference(packet: dict[str, Any], language: str) -> str:
|
||||
reference = _copy(packet if isinstance(packet, dict) else {})
|
||||
years = [year for year in reference.get("years") or [] if isinstance(year, dict)]
|
||||
if not years:
|
||||
return ""
|
||||
if language == "zh":
|
||||
intro = "## 独立年度方法参考\n\n本节与前面的原生周期分开使用。数字不替换、不合并前面的年运。"
|
||||
else:
|
||||
intro = "## Independent Annual Method Reference\n\nUse this section separately from the native cycles above. These figures do not replace the annual cycles earlier in the report."
|
||||
body = "\n\n".join(_one_year(year, "en" if language == "en" else "zh") for year in years)
|
||||
return intro + "\n\n" + body + "\n"
|
||||
@@ -0,0 +1,288 @@
|
||||
"""PyJHora annual-method worker. Run only as ``python -m``.
|
||||
|
||||
Product modules must not import this file. Importing it loads PyJHora and
|
||||
changes the process ayanamsa and ephemeris path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger("jyotisha.pyjhora_annual_reference_worker")
|
||||
|
||||
AYANAMSA_MAP = {
|
||||
"lahiri": "LAHIRI",
|
||||
"raman": "RAMAN",
|
||||
"kp": "KP",
|
||||
"krishnamurti": "KP",
|
||||
"fagan_bradley": "FAGAN",
|
||||
"true_citra": "TRUE_CITRA",
|
||||
"true_pushya": "TRUE_PUSHYA",
|
||||
}
|
||||
YOGA_CALLABLES = (
|
||||
("ithasala", "get_ithasala_yoga_planet_pairs"),
|
||||
("eesarpha", "get_eesarpha_yoga_planet_pairs"),
|
||||
("manahoo", "get_manahoo_yoga_planet_pairs"),
|
||||
("kamboola", "get_kamboola_yoga_planet_pairs"),
|
||||
("gairi_kamboola", "get_gairi_kamboola_yoga_planet_pairs"),
|
||||
("khallasara", "get_khallasara_yoga_planet_pairs"),
|
||||
("radda", "get_radda_yoga_planet_pairs"),
|
||||
("nakta", "get_nakta_yoga_planet_triples"),
|
||||
("yamaya", "get_yamaya_yoga_planet_triples"),
|
||||
)
|
||||
|
||||
|
||||
def _jsonify(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _jsonify(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_jsonify(item) for item in value]
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _anchor(chart_payload: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(chart_payload, (list, tuple)) or len(chart_payload) < 2:
|
||||
return None
|
||||
marker = chart_payload[1]
|
||||
if not isinstance(marker, (list, tuple)) or len(marker) < 2:
|
||||
return None
|
||||
date_value = marker[0]
|
||||
if not isinstance(date_value, (list, tuple)) or len(date_value) != 3:
|
||||
return None
|
||||
return {"date": [int(part) for part in date_value], "time": str(marker[1])}
|
||||
|
||||
|
||||
def _jd_from_anchor(utils: Any, anchor: dict[str, Any]) -> float:
|
||||
hour, minute, second = (int(part) for part in str(anchor["time"]).split(":"))
|
||||
return float(utils.julian_day_number(tuple(anchor["date"]), (hour, minute, second)))
|
||||
|
||||
|
||||
def _verify_node(const: Any, drik: Any, swe: Any, local_jd: float, timezone: float, requested_true: bool) -> tuple[bool, float | None]:
|
||||
if bool(const._use_true_nodes_for_rahu_ketu) != requested_true:
|
||||
return False, None
|
||||
jd_utc = float(local_jd) - float(timezone) / 24.0
|
||||
observed = float(drik.sidereal_longitude(jd_utc, const._RAHU)) % 360.0
|
||||
flags = swe.FLG_SWIEPH | swe.FLG_SIDEREAL
|
||||
swe_lon = float(swe.calc_ut(jd_utc, int(const._RAHU), flags)[0][0]) % 360.0
|
||||
delta = abs((observed - swe_lon + 180.0) % 360.0 - 180.0)
|
||||
return delta < 1.0, round(observed, 6)
|
||||
|
||||
|
||||
def _one_mode(
|
||||
*,
|
||||
mudda: Any,
|
||||
patyayini: Any,
|
||||
tajaka: Any,
|
||||
yoga_module: Any,
|
||||
utils: Any,
|
||||
place: Any,
|
||||
birth_jd: float,
|
||||
age: int,
|
||||
annual_years: int,
|
||||
) -> dict[str, Any]:
|
||||
year: dict[str, Any] = {"mudda_birth": None, "mudda_annual": None, "patyayini": None, "tajika": None}
|
||||
try:
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
periods = mudda.mudda_dhasa_bhukthi(birth_jd, place, age)
|
||||
year["mudda_birth"] = _jsonify(periods)
|
||||
except Exception as exc:
|
||||
log.warning("mudda birth-moon failed: %s", exc.__class__.__name__)
|
||||
year["mudda_birth_error"] = exc.__class__.__name__
|
||||
|
||||
chart_payload = None
|
||||
try:
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
chart_payload = tajaka.annual_chart(birth_jd, place, years=annual_years)
|
||||
except Exception as exc:
|
||||
log.warning("annual_chart failed: %s", exc.__class__.__name__)
|
||||
year["annual_chart_error"] = exc.__class__.__name__
|
||||
|
||||
anchor = _anchor(chart_payload)
|
||||
positions = chart_payload[0] if isinstance(chart_payload, (list, tuple)) and chart_payload else None
|
||||
year["anchor"] = anchor
|
||||
if anchor is not None:
|
||||
try:
|
||||
annual_jd = _jd_from_anchor(utils, anchor)
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
annual_periods = mudda.mudda_dhasa_bhukthi(annual_jd, place, 0)
|
||||
year["mudda_annual"] = _jsonify(annual_periods)
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
patyayini_periods = patyayini.get_dhasa_bhukthi(annual_jd, place)
|
||||
year["patyayini"] = _jsonify(patyayini_periods)
|
||||
year["anchor_mode"] = "annual_chart"
|
||||
except Exception as exc:
|
||||
log.warning("annual-return tables failed: %s", exc.__class__.__name__)
|
||||
year["annual_return_error"] = exc.__class__.__name__
|
||||
if year.get("patyayini") is None:
|
||||
try:
|
||||
fallback_jd = float(birth_jd) + float(age) * 365.256364
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
year["patyayini"] = _jsonify(patyayini.get_dhasa_bhukthi(fallback_jd, place))
|
||||
year["anchor_mode"] = "tropical_year_fallback"
|
||||
except Exception as exc:
|
||||
log.warning("patyayini fallback failed: %s", exc.__class__.__name__)
|
||||
year["patyayini_error"] = exc.__class__.__name__
|
||||
|
||||
yogas: dict[str, Any] = {}
|
||||
failures: dict[str, str] = {}
|
||||
if isinstance(positions, (list, tuple)):
|
||||
for yoga_name, callable_name in YOGA_CALLABLES:
|
||||
candidate = getattr(yoga_module, callable_name, None)
|
||||
if not callable(candidate):
|
||||
failures[yoga_name] = "missing"
|
||||
continue
|
||||
try:
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
yogas[yoga_name] = _jsonify(candidate(positions))
|
||||
except Exception as exc:
|
||||
log.warning("tajika %s failed: %s", yoga_name, exc.__class__.__name__)
|
||||
failures[yoga_name] = exc.__class__.__name__
|
||||
year["tajika"] = {"yogas": yogas, "failures": failures}
|
||||
return year
|
||||
|
||||
|
||||
def compute(request: dict[str, Any]) -> dict[str, Any]:
|
||||
import swisseph as swe
|
||||
import jhora.const as const
|
||||
|
||||
birth = request.get("birth") if isinstance(request.get("birth"), dict) else {}
|
||||
timezone = float(birth.get("tz") or 0)
|
||||
clock = float(birth.get("hour") or 0) + float(birth.get("minute") or 0) / 60.0 + float(birth.get("second") or 0) / 3600.0
|
||||
local_jd = float(swe.julday(int(birth["year"]), int(birth["month"]), int(birth["day"]), clock))
|
||||
requested_name = str(request.get("ayanamsa") or "").strip().lower()
|
||||
mode = AYANAMSA_MAP.get(requested_name)
|
||||
node_mode = str(request.get("node_mode") or "mean").strip().lower()
|
||||
requested_true = node_mode == "true"
|
||||
target_year = int(request["target_year"])
|
||||
birth_year = int(birth["year"])
|
||||
result: dict[str, Any] = {
|
||||
"ok": True,
|
||||
"ayanamsa_requested": mode,
|
||||
"ayanamsa_readback": None,
|
||||
"ayanamsa_ok": False,
|
||||
"node_applied": False,
|
||||
"node_header": "default_true",
|
||||
"years": [],
|
||||
}
|
||||
if mode is None:
|
||||
log.warning("unsupported ayanamsa for the annual method reference")
|
||||
return result
|
||||
|
||||
const.set_node_mode(requested_true)
|
||||
from jhora.panchanga import drik
|
||||
from jhora.horoscope.dhasa.annual import mudda, patyayini
|
||||
from jhora.horoscope.transit import tajaka, tajaka_yoga
|
||||
from jhora import utils
|
||||
|
||||
node_ok, node_longitude = _verify_node(const, drik, swe, local_jd, timezone, requested_true)
|
||||
result["node_longitude"] = node_longitude
|
||||
if node_ok:
|
||||
result["node_applied"] = True
|
||||
result["node_header"] = "true" if requested_true else "mean"
|
||||
else:
|
||||
log.warning("node mode readback failed; leaving PyJHora's default true node")
|
||||
const.set_node_mode(True)
|
||||
result["node_header"] = "default_true"
|
||||
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
drik.set_ayanamsa_mode(mode)
|
||||
readback = str(const._DEFAULT_AYANAMSA_MODE).upper()
|
||||
result["ayanamsa_readback"] = readback
|
||||
result["ayanamsa_ok"] = readback == mode
|
||||
if not result["ayanamsa_ok"]:
|
||||
log.warning("ayanamsa readback %s did not match %s", readback, mode)
|
||||
return result
|
||||
|
||||
place = drik.Place(str(birth.get("name") or "request"), float(birth["lat"]), float(birth["lon"]), timezone, 0.0)
|
||||
prove_reuse = bool(request.get("prove_reuse"))
|
||||
include_upstream = bool(request.get("include_upstream_diff"))
|
||||
ut_jd = local_jd - timezone / 24.0
|
||||
for offset in range(3):
|
||||
year = target_year + offset
|
||||
age = year - birth_year
|
||||
documented = _one_mode(
|
||||
mudda=mudda,
|
||||
patyayini=patyayini,
|
||||
tajaka=tajaka,
|
||||
yoga_module=tajaka_yoga,
|
||||
utils=utils,
|
||||
place=place,
|
||||
birth_jd=local_jd,
|
||||
age=age,
|
||||
annual_years=age + 1,
|
||||
)
|
||||
documented["requested_year"] = year
|
||||
documented["age"] = age
|
||||
if prove_reuse:
|
||||
try:
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
again = tajaka.annual_chart(local_jd, place, years=age + 1)
|
||||
documented["reuse_matches"] = _anchor(again) == documented.get("anchor")
|
||||
except Exception as exc:
|
||||
log.warning("reuse check failed: %s", exc.__class__.__name__)
|
||||
documented["reuse_matches"] = False
|
||||
if include_upstream and offset == 0:
|
||||
upstream = _one_mode(
|
||||
mudda=mudda,
|
||||
patyayini=patyayini,
|
||||
tajaka=tajaka,
|
||||
yoga_module=tajaka_yoga,
|
||||
utils=utils,
|
||||
place=place,
|
||||
birth_jd=ut_jd,
|
||||
age=age,
|
||||
annual_years=age,
|
||||
)
|
||||
documented["upstream_diff"] = {
|
||||
"documented_anchor": documented.get("anchor"),
|
||||
"upstream_anchor": upstream.get("anchor"),
|
||||
"documented_mudda_start": _first_start(documented.get("mudda_birth")),
|
||||
"upstream_mudda_start": _first_start(upstream.get("mudda_birth")),
|
||||
}
|
||||
result["years"].append(documented)
|
||||
return result
|
||||
|
||||
|
||||
def _first_start(periods: Any) -> Any:
|
||||
if not isinstance(periods, list) or not periods:
|
||||
return None
|
||||
first = periods[0]
|
||||
if isinstance(first, list) and len(first) >= 2:
|
||||
return first[1]
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
logging.basicConfig(stream=sys.stderr, level=logging.WARNING)
|
||||
try:
|
||||
request = json.loads(sys.stdin.read() or "{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
log.warning("worker stdin was not json: %s", exc.__class__.__name__)
|
||||
sys.stdout.write(json.dumps({"ok": False, "reason": "invalid_json"}))
|
||||
return 0
|
||||
if not isinstance(request, dict):
|
||||
sys.stdout.write(json.dumps({"ok": False, "reason": "invalid_request"}))
|
||||
return 0
|
||||
buffer = io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stdout(buffer):
|
||||
payload = compute(request)
|
||||
except Exception as exc:
|
||||
log.warning("worker failed: %s", exc.__class__.__name__)
|
||||
payload = {"ok": False, "reason": exc.__class__.__name__}
|
||||
leaked = buffer.getvalue()
|
||||
if leaked:
|
||||
print(leaked, file=sys.stderr)
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -41,9 +41,9 @@ def _formal_divisions() -> tuple[int, ...]:
|
||||
FORMAL_DIVISIONS = _formal_divisions()
|
||||
|
||||
try:
|
||||
from diagnose_pyjhora_adapter import build_report as build_pyjhora_adapter_report
|
||||
from diagnose_pyjhora_adapter import build_product_report as build_pyjhora_adapter_report
|
||||
except Exception: # pragma: no cover - import path varies in tests/CLI
|
||||
from scripts.diagnose_pyjhora_adapter import build_report as build_pyjhora_adapter_report
|
||||
from scripts.diagnose_pyjhora_adapter import build_product_report as build_pyjhora_adapter_report
|
||||
|
||||
try:
|
||||
from diagnose_jyotishganit_adapter import build_report as build_jyotishganit_adapter_report
|
||||
|
||||
Reference in New Issue
Block a user