bab0718700
Web export now calls the same full pack as the long skill report and caches an owner-only Markdown download. Appendix failure stays unavailable and does not change the main report status. Co-authored-by: Cursor <cursoragent@cursor.com>
987 lines
40 KiB
Python
Executable File
987 lines
40 KiB
Python
Executable File
"""PyJHora annual replay boundary.
|
|
|
|
PyJHora/JHora is treated as an external reference engine. This wrapper imports
|
|
it only inside `replay_pyjhora_annual`, preserves raw return shapes, and never
|
|
vendors or normalizes it into the native engine as a hard dependency.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import hashlib
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
from importlib.metadata import PackageNotFoundError, version as _distribution_version
|
|
from importlib import import_module as _import_module
|
|
from inspect import signature
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
|
|
ENGINE = "PyJHora/JHora"
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
_TAJAKA_YEAR_LORD_MODULE = "jhora.horoscope.transit.tajaka"
|
|
_YEAR_LORD_MODULES = (
|
|
"jhora.horoscope.dhasa.annual.mudda",
|
|
"jhora.horoscope.dhasa.annual.patyayini",
|
|
)
|
|
_YEAR_LORD_CALLABLES = (
|
|
"panchadhikari_year_lord",
|
|
"get_panchadhikari_year_lord",
|
|
"get_year_lord",
|
|
"year_lord",
|
|
)
|
|
_ANNUAL_SURFACE_KEYS = (
|
|
"annual_chart_snapshot",
|
|
"annual_chart_speed_snapshot",
|
|
"mudda_dasha",
|
|
"patyayini_dasha",
|
|
"sahams",
|
|
"solar_return_boundary",
|
|
"tajika_yogas",
|
|
)
|
|
_PYJHORA_SAHAM_CALLABLES = (
|
|
"apamrithyu_saham", "artha_saham", "asha_saham", "bandhana_saham", "bandhu_saham",
|
|
"bhratri_saham", "gaurava_saham", "jadya_saham", "jalapatna_saham", "jeeva_saham",
|
|
"kali_saham", "karma_saham", "karyasiddhi_saham", "laabha_saham", "maathri_saham",
|
|
"mahatmaya_saham", "mitra_saham", "mrithyu_saham", "paradara_saham", "paradesa_saham",
|
|
"pithri_saham", "preethi_saham", "punya_saham", "puthra_saham", "rajya_saham",
|
|
"roga_sagam_1", "roga_saham", "samartha_saham", "santapa_saham", "sastra_saham",
|
|
"sathru_saham", "sraddha_saham", "vanika_saham", "vidya_saham", "vivaha_saham",
|
|
"vyaapaara_saham", "yasas_saham",
|
|
)
|
|
|
|
|
|
def probe_pyjhora_year_lord(
|
|
profile: dict[str, Any],
|
|
*,
|
|
birth_julian_day: float,
|
|
place: Any,
|
|
age: int | float,
|
|
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,
|
|
) -> dict[str, Any]:
|
|
"""Probe PyJHora for an annual Tajaka/Panchadhikari Year Lord API.
|
|
|
|
This is deliberately an external-engine observation. PyJHora's annual
|
|
modules are not assumed to implement Panchadhikari simply because they
|
|
expose other Varshaphala calculations. The public Tajaka callable is
|
|
preferred when present; annual-dasha modules remain a legacy fallback.
|
|
"""
|
|
|
|
replay = {
|
|
"status": "blocked",
|
|
"reason": "pyjhora_panchadhikari_year_lord_api_missing",
|
|
"missing_api": "Panchadhikari/Year Lord callable",
|
|
"probed_modules": [_TAJAKA_YEAR_LORD_MODULE, *_YEAR_LORD_MODULES],
|
|
"available_annual_callables": [],
|
|
"raw": None,
|
|
"normalized_candidates": [],
|
|
}
|
|
result = {
|
|
"engine": ENGINE,
|
|
"input_profile_id": profile.get("profile_id"),
|
|
"pyjhora_version": _pyjhora_version(distribution_version),
|
|
"license_boundary": {
|
|
"mode": "external_reference_only",
|
|
"dependency_required": False,
|
|
},
|
|
"evidence_scope": "pyjhora_behavior_only",
|
|
"parity_status": "not_multiengine_parity",
|
|
"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.get("reason") != "pyjhora_tajaka_api_missing":
|
|
replay.update(isolated)
|
|
replay["normalized_candidates"] = _normalize_tajaka_candidates(replay.get("raw_candidates"))
|
|
if isolated.get("pyjhora_version"):
|
|
result["pyjhora_version"] = isolated["pyjhora_version"]
|
|
return result
|
|
|
|
modules: list[Any] = []
|
|
for module_name in _YEAR_LORD_MODULES:
|
|
try:
|
|
modules.append(import_module(module_name))
|
|
except Exception as exc:
|
|
replay["status"] = "blocked"
|
|
replay["reason"] = f"pyjhora_annual_module_import_failed:{module_name}:{exc.__class__.__name__}"
|
|
replay["missing_api"] = module_name
|
|
return result
|
|
|
|
for module in modules:
|
|
replay["available_annual_callables"].extend(
|
|
sorted(name for name in _YEAR_LORD_CALLABLES if callable(getattr(module, name, None)))
|
|
)
|
|
for name in _YEAR_LORD_CALLABLES:
|
|
candidate = getattr(module, name, None)
|
|
if not callable(candidate):
|
|
continue
|
|
return _call_year_lord_candidate(result, replay, candidate, name, birth_julian_day, place, age)
|
|
return result
|
|
|
|
|
|
def _run_isolated_tajaka_year_lord(
|
|
profile: dict[str, Any],
|
|
birth_julian_day: float,
|
|
place: Any,
|
|
age: int | float,
|
|
) -> dict[str, Any]:
|
|
settings = _replay_settings(profile)
|
|
request = {
|
|
"birth_julian_day": birth_julian_day,
|
|
"place": _place_payload(place),
|
|
"age": age,
|
|
"settings": settings,
|
|
}
|
|
request_hash = _request_hash(request)
|
|
command = [
|
|
sys.executable,
|
|
"-c",
|
|
(
|
|
"import json, sys; "
|
|
"from scripts.annual_pyjhora_replay import _isolated_tajaka_worker; "
|
|
"print(json.dumps(_isolated_tajaka_worker(json.load(sys.stdin)), sort_keys=True))"
|
|
),
|
|
]
|
|
try:
|
|
completed = subprocess.run(
|
|
command,
|
|
input=json.dumps(request, sort_keys=True),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=False,
|
|
cwd=str(REPO_ROOT),
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return _isolated_blocked(
|
|
"pyjhora_isolated_replay_timeout", request_hash, settings
|
|
)
|
|
except Exception as exc:
|
|
return _isolated_blocked(
|
|
f"pyjhora_isolated_replay_start_failed:{exc.__class__.__name__}", request_hash, settings
|
|
)
|
|
if completed.returncode != 0:
|
|
return _isolated_blocked(
|
|
f"pyjhora_isolated_replay_failed:exit_{completed.returncode}", request_hash, settings
|
|
)
|
|
try:
|
|
replay = json.loads(completed.stdout)
|
|
except json.JSONDecodeError:
|
|
return _isolated_blocked("pyjhora_isolated_replay_invalid_json", request_hash, settings)
|
|
if not isinstance(replay, dict):
|
|
return _isolated_blocked("pyjhora_isolated_replay_invalid_payload", request_hash, settings)
|
|
replay["request_hash"] = request_hash
|
|
return replay
|
|
|
|
|
|
def _isolated_tajaka_worker(request: dict[str, Any]) -> dict[str, Any]:
|
|
"""Run in a child process so PyJHora global Ayanamsa state cannot leak."""
|
|
|
|
settings = dict(request.get("settings") or {})
|
|
node_mode = {
|
|
"requested": settings.get("node_mode"),
|
|
"status": "unsupported_by_replay_adapter",
|
|
}
|
|
ayanamsa = settings.get("ayanamsa")
|
|
if not isinstance(ayanamsa, str) or not ayanamsa.strip():
|
|
return {
|
|
"status": "blocked",
|
|
"reason": "pyjhora_ayanamsa_configuration_failed:missing_ayanamsa",
|
|
"settings": settings,
|
|
"node_mode": node_mode,
|
|
}
|
|
try:
|
|
# PyJHora can print import diagnostics; reserve stdout for the JSON reply.
|
|
with contextlib.redirect_stdout(sys.stderr):
|
|
from jhora import const
|
|
from jhora.panchanga import drik
|
|
from jhora.horoscope.transit import tajaka
|
|
|
|
drik.set_ayanamsa_mode(ayanamsa.upper())
|
|
effective_ayanamsa = const._DEFAULT_AYANAMSA_MODE
|
|
if str(effective_ayanamsa).upper() != ayanamsa.upper():
|
|
return {
|
|
"status": "blocked",
|
|
"reason": "pyjhora_ayanamsa_configuration_failed:effective_mode_mismatch",
|
|
"settings": settings,
|
|
"effective_ayanamsa": effective_ayanamsa,
|
|
"node_mode": node_mode,
|
|
}
|
|
except Exception as exc:
|
|
return {
|
|
"status": "blocked",
|
|
"reason": f"pyjhora_ayanamsa_configuration_failed:{exc.__class__.__name__}",
|
|
"settings": settings,
|
|
"node_mode": node_mode,
|
|
}
|
|
|
|
try:
|
|
place_data = dict(request["place"])
|
|
place = drik.Place(
|
|
place_data["name"],
|
|
float(place_data["latitude"]),
|
|
float(place_data["longitude"]),
|
|
float(place_data["timezone"]),
|
|
place_data.get("elevation"),
|
|
)
|
|
raw_candidates, candidate_trace_status = _tajaka_candidate_trace(
|
|
tajaka, float(request["birth_julian_day"]), place, request["age"]
|
|
)
|
|
raw = tajaka.lord_of_the_year(float(request["birth_julian_day"]), place, request["age"])
|
|
except Exception as exc:
|
|
return {
|
|
"status": "blocked",
|
|
"reason": f"pyjhora_tajaka_year_lord_failed:{exc.__class__.__name__}",
|
|
"callable": f"{_TAJAKA_YEAR_LORD_MODULE}.lord_of_the_year",
|
|
"settings": settings,
|
|
"effective_ayanamsa": effective_ayanamsa,
|
|
"node_mode": node_mode,
|
|
"raw": None,
|
|
"raw_candidates": None,
|
|
}
|
|
return {
|
|
"status": "partial_verified",
|
|
"reason": "pyjhora_tajaka_year_lord_callable_observed",
|
|
"callable": f"{_TAJAKA_YEAR_LORD_MODULE}.lord_of_the_year",
|
|
"pyjhora_version": _pyjhora_version(_distribution_version),
|
|
"settings": settings,
|
|
"effective_ayanamsa": effective_ayanamsa,
|
|
"node_mode": node_mode,
|
|
"year_lord": raw,
|
|
"raw": raw,
|
|
"raw_candidates": raw_candidates,
|
|
"candidate_trace_status": candidate_trace_status,
|
|
}
|
|
|
|
|
|
def _run_isolated_annual_replay(
|
|
profile: dict[str, Any],
|
|
birth_julian_day: float,
|
|
place: Any,
|
|
age: int | float,
|
|
) -> dict[str, Any]:
|
|
"""Call optional PyJHora annual surfaces with a child-process Place."""
|
|
|
|
settings = _replay_settings(profile)
|
|
request = {
|
|
"birth_julian_day": birth_julian_day,
|
|
"place": _place_payload(place),
|
|
"age": age,
|
|
"settings": settings,
|
|
}
|
|
request_hash = _request_hash(request)
|
|
command = [
|
|
sys.executable,
|
|
"-c",
|
|
(
|
|
"import json, sys; "
|
|
"from scripts.annual_pyjhora_replay import _isolated_annual_worker; "
|
|
"print(json.dumps(_isolated_annual_worker(json.load(sys.stdin)), sort_keys=True, default=str))"
|
|
),
|
|
]
|
|
try:
|
|
completed = subprocess.run(
|
|
command,
|
|
input=json.dumps(request, sort_keys=True),
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=False,
|
|
cwd=str(REPO_ROOT),
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return _annual_replay_blocked("pyjhora_isolated_annual_replay_timeout", request_hash, settings)
|
|
except Exception as exc:
|
|
return _annual_replay_blocked(
|
|
f"pyjhora_isolated_annual_replay_start_failed:{exc.__class__.__name__}", request_hash, settings
|
|
)
|
|
if completed.returncode != 0:
|
|
return _annual_replay_blocked(
|
|
f"pyjhora_isolated_annual_replay_failed:exit_{completed.returncode}", request_hash, settings
|
|
)
|
|
try:
|
|
replay = json.loads(completed.stdout)
|
|
except json.JSONDecodeError:
|
|
return _annual_replay_blocked("pyjhora_isolated_annual_replay_invalid_json", request_hash, settings)
|
|
if not isinstance(replay, dict):
|
|
return _annual_replay_blocked("pyjhora_isolated_annual_replay_invalid_payload", request_hash, settings)
|
|
replay["request_hash"] = request_hash
|
|
return replay
|
|
|
|
|
|
def _isolated_annual_worker(request: dict[str, Any]) -> dict[str, Any]:
|
|
"""Construct ``drik.Place`` after setting Ayanamsa in the child process."""
|
|
|
|
settings = dict(request.get("settings") or {})
|
|
node_mode = {"requested": settings.get("node_mode"), "status": "unsupported_by_replay_adapter"}
|
|
ayanamsa = settings.get("ayanamsa")
|
|
if not isinstance(ayanamsa, str) or not ayanamsa.strip():
|
|
return _annual_worker_result(
|
|
{key: {"status": "blocked", "reason": "pyjhora_ayanamsa_configuration_failed:missing_ayanamsa"} for key in _ANNUAL_SURFACE_KEYS},
|
|
settings=settings,
|
|
node_mode=node_mode,
|
|
)
|
|
try:
|
|
with contextlib.redirect_stdout(sys.stderr):
|
|
from jhora import const
|
|
from jhora.panchanga import drik
|
|
from jhora.horoscope.dhasa.annual import mudda, patyayini
|
|
from jhora.horoscope.transit import saham
|
|
|
|
drik.set_ayanamsa_mode(ayanamsa.upper())
|
|
effective_ayanamsa = const._DEFAULT_AYANAMSA_MODE
|
|
if str(effective_ayanamsa).upper() != ayanamsa.upper():
|
|
return _annual_worker_result(
|
|
{key: {"status": "blocked", "reason": "pyjhora_ayanamsa_configuration_failed:effective_mode_mismatch"} for key in _ANNUAL_SURFACE_KEYS},
|
|
settings=settings,
|
|
node_mode=node_mode,
|
|
effective_ayanamsa=effective_ayanamsa,
|
|
)
|
|
place_data = dict(request["place"])
|
|
pyjhora_place = drik.Place(
|
|
place_data["name"], float(place_data["latitude"]), float(place_data["longitude"]),
|
|
float(place_data["timezone"]), place_data.get("elevation"),
|
|
)
|
|
except Exception as exc:
|
|
return _annual_worker_result(
|
|
{key: {"status": "blocked", "reason": f"pyjhora_annual_adapter_setup_failed:{exc.__class__.__name__}"} for key in _ANNUAL_SURFACE_KEYS},
|
|
settings=settings,
|
|
node_mode=node_mode,
|
|
)
|
|
|
|
jd = float(request["birth_julian_day"])
|
|
years = request["age"]
|
|
surfaces = {
|
|
"annual_chart_snapshot": _call_annual_chart_snapshot(jd, pyjhora_place, years),
|
|
"annual_chart_speed_snapshot": _call_annual_chart_speed_snapshot(jd, pyjhora_place, years),
|
|
"mudda_dasha": _call_mudda(mudda, jd, pyjhora_place, years),
|
|
"patyayini_dasha": _call_patyayini(patyayini, jd, pyjhora_place, years),
|
|
"sahams": _call_sahams(saham, jd, pyjhora_place, years),
|
|
"solar_return_boundary": _call_solar_return_boundary(saham, jd, pyjhora_place, years),
|
|
"tajika_yogas": _probe_tajika_yogas(jd, pyjhora_place, years),
|
|
}
|
|
return _annual_worker_result(
|
|
surfaces,
|
|
settings=settings,
|
|
node_mode=node_mode,
|
|
effective_ayanamsa=effective_ayanamsa,
|
|
)
|
|
|
|
|
|
def _annual_worker_result(
|
|
surfaces: dict[str, dict[str, Any]],
|
|
*,
|
|
settings: dict[str, Any],
|
|
node_mode: dict[str, Any],
|
|
effective_ayanamsa: Any = None,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
**{key: surfaces.get(key, {"status": "blocked", "reason": "not_executed"}) for key in _ANNUAL_SURFACE_KEYS},
|
|
"settings": settings,
|
|
"effective_ayanamsa": effective_ayanamsa,
|
|
"node_mode": node_mode,
|
|
"evidence_scope": "pyjhora_behavior_only",
|
|
"parity_status": "not_multiengine_parity",
|
|
}
|
|
|
|
|
|
def _annual_replay_blocked(reason: str, request_hash: str, settings: dict[str, Any]) -> dict[str, Any]:
|
|
return _annual_worker_result(
|
|
{key: {"status": "blocked", "reason": reason} for key in _ANNUAL_SURFACE_KEYS},
|
|
settings=settings,
|
|
node_mode={"requested": settings.get("node_mode"), "status": "unsupported_by_replay_adapter"},
|
|
) | {"request_hash": request_hash}
|
|
|
|
|
|
def _place_payload(place: Any) -> dict[str, Any]:
|
|
return {
|
|
"name": getattr(place, "name", "External replay place"),
|
|
"latitude": getattr(place, "latitude"),
|
|
"longitude": getattr(place, "longitude"),
|
|
"timezone": getattr(place, "timezone"),
|
|
"elevation": getattr(place, "elevation", None),
|
|
}
|
|
|
|
|
|
def _request_hash(request: dict[str, Any]) -> str:
|
|
payload = json.dumps(request, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _isolated_blocked(reason: str, request_hash: str, settings: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"status": "blocked",
|
|
"reason": reason,
|
|
"request_hash": request_hash,
|
|
"settings": settings,
|
|
"node_mode": {
|
|
"requested": settings.get("node_mode"),
|
|
"status": "unsupported_by_replay_adapter",
|
|
},
|
|
}
|
|
|
|
|
|
def _replay_settings(profile: dict[str, Any]) -> dict[str, Any]:
|
|
settings = profile.get("settings")
|
|
source = settings if isinstance(settings, dict) else profile
|
|
return {
|
|
key: source[key]
|
|
for key in ("ayanamsa", "node_mode")
|
|
if source.get(key) is not None
|
|
}
|
|
|
|
|
|
def _call_tajaka_year_lord(
|
|
result: dict[str, Any],
|
|
replay: dict[str, Any],
|
|
tajaka: Any,
|
|
*,
|
|
birth_julian_day: float,
|
|
place: Any,
|
|
age: int | float,
|
|
settings: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
callable_name = f"{_TAJAKA_YEAR_LORD_MODULE}.lord_of_the_year"
|
|
try:
|
|
raw_candidates, candidate_trace_status = _tajaka_candidate_trace(
|
|
tajaka, birth_julian_day, place, age
|
|
)
|
|
raw = tajaka.lord_of_the_year(birth_julian_day, place, age)
|
|
except Exception as exc:
|
|
replay.update(
|
|
{
|
|
"status": "blocked",
|
|
"reason": f"pyjhora_tajaka_year_lord_failed:{exc.__class__.__name__}",
|
|
"missing_api": None,
|
|
"callable": callable_name,
|
|
"settings": settings,
|
|
"raw": None,
|
|
"raw_candidates": None,
|
|
}
|
|
)
|
|
return result
|
|
|
|
replay.update(
|
|
{
|
|
"status": "partial_verified",
|
|
"reason": "pyjhora_tajaka_year_lord_callable_observed",
|
|
"missing_api": None,
|
|
"callable": callable_name,
|
|
"settings": settings,
|
|
"year_lord": raw,
|
|
"raw": raw,
|
|
"raw_candidates": raw_candidates,
|
|
"candidate_trace_status": candidate_trace_status,
|
|
"normalized_candidates": _normalize_tajaka_candidates(raw_candidates),
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def _tajaka_candidate_trace(
|
|
tajaka: Any,
|
|
birth_julian_day: float,
|
|
place: Any,
|
|
age: int | float,
|
|
) -> tuple[Any, str]:
|
|
"""Collect the source module's candidate list without reimplementing its rule."""
|
|
|
|
candidate_function = getattr(tajaka, "_get_lord_candidates", None)
|
|
if not callable(candidate_function):
|
|
return None, "unavailable"
|
|
try:
|
|
# Lightweight injected test modules can expose a direct trace helper.
|
|
return candidate_function(), "observed"
|
|
except TypeError:
|
|
pass
|
|
try:
|
|
rasi_chart = tajaka.charts.divisional_chart(
|
|
birth_julian_day, place, divisional_chart_factor=1
|
|
)
|
|
natal_planet_to_house = tajaka.utils.get_planet_house_dictionary_from_planet_positions(rasi_chart)
|
|
natal_lagna_house = natal_planet_to_house[tajaka.const._ascendant_symbol]
|
|
annual_jd = birth_julian_day + float(age) * tajaka.year_value
|
|
annual_hour = tajaka.drik.jd_to_gregorian(annual_jd)[3]
|
|
sunrise = tajaka.utils.from_dms_str_to_dms(tajaka.drik.sunrise(annual_jd, place)[1])
|
|
sunset = tajaka.utils.from_dms_str_to_dms(tajaka.drik.sunset(annual_jd, place)[1])
|
|
sunrise_hour = sunrise[0] + sunrise[1] / 60 + sunrise[2] / 3600
|
|
sunset_hour = sunset[0] + sunset[1] / 60 + sunset[2] / 3600
|
|
night_time_birth = annual_hour > sunset_hour or annual_hour < sunrise_hour
|
|
annual_chart = tajaka.charts.divisional_chart(annual_jd, place, divisional_chart_factor=1)
|
|
return candidate_function(annual_chart, age, natal_lagna_house, night_time_birth), "observed"
|
|
except Exception as exc:
|
|
return None, f"blocked:{exc.__class__.__name__}"
|
|
|
|
|
|
def _normalize_tajaka_candidates(raw_candidates: Any) -> list[dict[str, Any]]:
|
|
if not isinstance(raw_candidates, (list, tuple)):
|
|
return []
|
|
return [
|
|
{"planet_index": candidate, "selection_state": "raw_tajaka_candidate"}
|
|
for candidate in raw_candidates
|
|
if isinstance(candidate, int)
|
|
]
|
|
|
|
|
|
def _pyjhora_version(distribution_version: Callable[[str], str]) -> str | None:
|
|
try:
|
|
return distribution_version("PyJHora")
|
|
except PackageNotFoundError:
|
|
return None
|
|
|
|
|
|
def _call_year_lord_candidate(
|
|
result: dict[str, Any],
|
|
replay: dict[str, Any],
|
|
candidate: Callable[..., Any],
|
|
callable_name: str,
|
|
birth_julian_day: float,
|
|
place: Any,
|
|
age: int | float,
|
|
) -> dict[str, Any]:
|
|
values = {
|
|
"jd": birth_julian_day,
|
|
"birth_julian_day": birth_julian_day,
|
|
"place": place,
|
|
"age": age,
|
|
"years": age,
|
|
}
|
|
try:
|
|
params = signature(candidate).parameters
|
|
kwargs = {name: values[name] for name in params if name in values}
|
|
required = [
|
|
name for name, parameter in params.items()
|
|
if parameter.default is parameter.empty
|
|
and parameter.kind in (parameter.POSITIONAL_ONLY, parameter.POSITIONAL_OR_KEYWORD, parameter.KEYWORD_ONLY)
|
|
and name not in kwargs
|
|
]
|
|
if required:
|
|
replay.update(
|
|
{
|
|
"reason": "pyjhora_year_lord_callable_signature_unsupported",
|
|
"missing_api": ", ".join(required),
|
|
"callable": callable_name,
|
|
}
|
|
)
|
|
return result
|
|
raw = candidate(**kwargs)
|
|
except Exception as exc:
|
|
replay.update(
|
|
{
|
|
"reason": f"pyjhora_year_lord_callable_failed:{callable_name}:{exc.__class__.__name__}",
|
|
"missing_api": None,
|
|
"callable": callable_name,
|
|
}
|
|
)
|
|
return result
|
|
|
|
replay.update(
|
|
{
|
|
"status": "partial_verified",
|
|
"reason": "pyjhora_year_lord_callable_observed",
|
|
"missing_api": None,
|
|
"callable": callable_name,
|
|
"year_lord": raw.get("year_lord") if isinstance(raw, dict) else None,
|
|
"raw": raw,
|
|
"normalized_candidates": _normalize_year_lord_candidates(raw),
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def _normalize_year_lord_candidates(raw: Any) -> list[dict[str, Any]]:
|
|
if not isinstance(raw, dict):
|
|
return []
|
|
candidates = raw.get("candidates") or raw.get("candidate_planets")
|
|
if not isinstance(candidates, list):
|
|
return []
|
|
return [candidate for candidate in candidates if isinstance(candidate, dict)]
|
|
|
|
|
|
def replay_pyjhora_annual(
|
|
profile: dict[str, Any],
|
|
*,
|
|
birth_julian_day: float,
|
|
place: Any,
|
|
age: int | float,
|
|
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,
|
|
) -> 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,
|
|
)
|
|
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 {
|
|
"status": "blocked",
|
|
"reason": "pyjhora_year_lord_probe_invalid_payload",
|
|
}
|
|
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 not isinstance(observed, dict):
|
|
observed = _annual_replay_blocked(
|
|
"pyjhora_isolated_annual_replay_invalid_payload",
|
|
_request_hash({"birth_julian_day": birth_julian_day, "place": _place_payload(place), "age": age}),
|
|
_replay_settings(profile),
|
|
)
|
|
result = _base(profile)
|
|
for key in _ANNUAL_SURFACE_KEYS:
|
|
value = observed.get(key)
|
|
result[key] = value if isinstance(value, dict) else {"status": "blocked", "reason": f"{key}_missing_from_isolated_replay"}
|
|
result["year_lord_replay"] = blocked["year_lord_replay"]
|
|
for key in ("effective_ayanamsa", "node_mode", "request_hash"):
|
|
if key in observed:
|
|
result[key] = observed[key]
|
|
result["evidence_scope"] = "pyjhora_behavior_only"
|
|
result["parity_status"] = "not_multiengine_parity"
|
|
result["status"] = _aggregate_status(result)
|
|
return result
|
|
try:
|
|
mudda = import_module("jhora.horoscope.dhasa.annual.mudda")
|
|
patyayini = import_module("jhora.horoscope.dhasa.annual.patyayini")
|
|
saham = import_module("jhora.horoscope.transit.saham")
|
|
except Exception as exc:
|
|
reason = f"pyjhora_import_failed:{exc.__class__.__name__}:{exc}"
|
|
for key in _ANNUAL_SURFACE_KEYS:
|
|
blocked[key] = {"status": "blocked", "reason": reason}
|
|
blocked["status"] = "blocked"
|
|
return blocked
|
|
|
|
result = _base(profile)
|
|
result["annual_chart_snapshot"] = _call_annual_chart_snapshot(birth_julian_day, place, age)
|
|
result["mudda_dasha"] = _call_mudda(mudda, birth_julian_day, place, age)
|
|
result["patyayini_dasha"] = _call_patyayini(patyayini, birth_julian_day, place, age)
|
|
result["sahams"] = _call_sahams(saham, birth_julian_day, place, age)
|
|
result["solar_return_boundary"] = _call_solar_return_boundary(saham, birth_julian_day, place, age)
|
|
result["tajika_yogas"] = {"status": "blocked", "reason": "pyjhora_tajika_yogas_requires_isolated_replay"}
|
|
result["year_lord_replay"] = year_lord_replay if isinstance(year_lord_replay, dict) else {
|
|
"status": "blocked",
|
|
"reason": "pyjhora_year_lord_probe_invalid_payload",
|
|
}
|
|
result["evidence_scope"] = "pyjhora_behavior_only"
|
|
result["parity_status"] = "not_multiengine_parity"
|
|
result["status"] = _aggregate_status(result)
|
|
return result
|
|
|
|
|
|
def _base(profile: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"engine": ENGINE,
|
|
"license_boundary": {
|
|
"mode": "external_reference_only",
|
|
"dependency_required": False,
|
|
"notes": "PyJHora/JHora remains an optional external oracle/replay boundary.",
|
|
},
|
|
"input_profile_id": profile.get("profile_id"),
|
|
"annual_chart_snapshot": {"status": "blocked", "reason": "not_executed"},
|
|
"annual_chart_speed_snapshot": {"status": "blocked", "reason": "not_executed"},
|
|
"mudda_dasha": {"status": "blocked", "reason": "not_executed"},
|
|
"patyayini_dasha": {"status": "blocked", "reason": "not_executed"},
|
|
"sahams": {"status": "blocked", "reason": "not_executed"},
|
|
"solar_return_boundary": {"status": "blocked", "reason": "not_executed"},
|
|
"tajika_yogas": {"status": "blocked", "reason": "not_executed"},
|
|
"year_lord_replay": {"status": "blocked", "reason": "not_executed"},
|
|
"evidence_scope": "pyjhora_behavior_only",
|
|
"parity_status": "not_multiengine_parity",
|
|
"status": "blocked",
|
|
}
|
|
|
|
|
|
def _call_mudda(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {}
|
|
try:
|
|
if hasattr(module, "varsha_vimsottari_dasha_start_date"):
|
|
payload["varsha_vimsottari_start"] = module.varsha_vimsottari_dasha_start_date(
|
|
birth_julian_day, place, age
|
|
)
|
|
if hasattr(module, "mudda_dhasa_bhukthi"):
|
|
payload["periods"] = module.mudda_dhasa_bhukthi(birth_julian_day, place, age)
|
|
return {"status": "partial_verified", "raw": payload}
|
|
except Exception as exc:
|
|
return {"status": "blocked", "reason": f"pyjhora_mudda_failed:{exc}"}
|
|
|
|
|
|
def _call_patyayini(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
|
|
try:
|
|
jd_for_year = birth_julian_day + float(age) * 365.256364
|
|
return {"status": "partial_verified", "raw": {"periods": module.get_dhasa_bhukthi(jd_for_year, place)}}
|
|
except Exception as exc:
|
|
return {"status": "blocked", "reason": f"pyjhora_patyayini_failed:{exc}"}
|
|
|
|
|
|
def _call_sahams(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
|
|
"""Replay the current PyJHora per-Saham API against its annual chart.
|
|
|
|
Older PyJHora releases exposed an aggregate ``sahams`` function. Current
|
|
releases expose individual functions which all consume annual-chart planet
|
|
positions. Keep both paths external-reference-only and preserve the exact
|
|
source API used for audit.
|
|
"""
|
|
try:
|
|
individual = [name for name in _PYJHORA_SAHAM_CALLABLES if callable(getattr(module, name, None))]
|
|
if not individual:
|
|
if hasattr(module, "sahams"):
|
|
return {
|
|
"status": "partial_verified",
|
|
"callable": "jhora.horoscope.transit.saham.sahams",
|
|
"raw": module.sahams(birth_julian_day, place, age),
|
|
"reason": "legacy_pyjhora_aggregate_sahams_api",
|
|
}
|
|
return {"status": "blocked", "reason": "pyjhora_sahams_callable_missing"}
|
|
|
|
tajaka = _import_module("jhora.horoscope.transit.tajaka")
|
|
annual_chart = getattr(tajaka, "annual_chart", None)
|
|
if not callable(annual_chart):
|
|
return {"status": "blocked", "reason": "pyjhora_annual_chart_callable_missing"}
|
|
chart_payload = annual_chart(birth_julian_day, place, years=age)
|
|
if not isinstance(chart_payload, tuple) or len(chart_payload) < 2 or not isinstance(chart_payload[0], list):
|
|
return {"status": "blocked", "reason": "pyjhora_annual_chart_payload_invalid"}
|
|
positions, return_marker = chart_payload[0], chart_payload[1]
|
|
annual_moment = _annual_return_datetime(return_marker)
|
|
if annual_moment is None:
|
|
return {"status": "blocked", "reason": "pyjhora_annual_chart_return_time_unavailable"}
|
|
try:
|
|
try:
|
|
from scripts.saham_daynight import determine_daytime
|
|
except ImportError: # pragma: no cover - direct script execution
|
|
from saham_daynight import determine_daytime
|
|
daynight = determine_daytime(
|
|
annual_moment,
|
|
lat=float(place.latitude),
|
|
lon=float(place.longitude),
|
|
tz=float(place.timezone),
|
|
)
|
|
except Exception as exc:
|
|
return {"status": "blocked", "reason": f"pyjhora_sahams_daynight_failed:{exc.__class__.__name__}"}
|
|
|
|
values: dict[str, Any] = {}
|
|
failures: dict[str, str] = {}
|
|
for name in individual:
|
|
candidate = getattr(module, name)
|
|
try:
|
|
parameters = signature(candidate).parameters
|
|
if "night_time_birth" in parameters:
|
|
values[name] = candidate(positions, night_time_birth=not bool(daynight["is_daytime"]))
|
|
else:
|
|
values[name] = candidate(positions)
|
|
except Exception as exc: # External API variations must remain visible.
|
|
failures[name] = exc.__class__.__name__
|
|
if not values:
|
|
return {
|
|
"status": "blocked",
|
|
"reason": "pyjhora_individual_sahams_all_failed",
|
|
"failed_callables": failures,
|
|
}
|
|
return {
|
|
"status": "partial_verified",
|
|
"callable": "jhora.horoscope.transit.tajaka.annual_chart",
|
|
"reason": "pyjhora_individual_sahams_from_annual_chart",
|
|
"daynight": daynight,
|
|
"raw": {
|
|
"annual_return": _annual_return_marker(return_marker),
|
|
"sahams": values,
|
|
"failed_callables": failures,
|
|
"available_callables": individual,
|
|
},
|
|
}
|
|
except Exception as exc:
|
|
return {"status": "blocked", "reason": f"pyjhora_sahams_failed:{exc}"}
|
|
|
|
|
|
def _call_annual_chart_snapshot(birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
|
|
"""Capture PyJHora's annual chart without deriving a local interpretation.
|
|
|
|
This is an external-reference snapshot for profile diagnosis. It exists so
|
|
annual Tajika comparisons can distinguish a chart-input difference from a
|
|
candidate-rule difference. It is not a report authority or a Yoga result.
|
|
"""
|
|
try:
|
|
tajaka = _import_module("jhora.horoscope.transit.tajaka")
|
|
annual_chart = getattr(tajaka, "annual_chart", None)
|
|
if not callable(annual_chart):
|
|
return {"status": "blocked", "reason": "pyjhora_annual_chart_callable_missing"}
|
|
payload = annual_chart(birth_julian_day, place, years=age)
|
|
if not isinstance(payload, tuple) or len(payload) < 2 or not isinstance(payload[0], list):
|
|
return {"status": "blocked", "reason": "pyjhora_annual_chart_payload_invalid"}
|
|
positions, return_marker = payload[0], payload[1]
|
|
return {
|
|
"status": "partial_verified",
|
|
"callable": "jhora.horoscope.transit.tajaka.annual_chart",
|
|
"reason": "pyjhora_annual_chart_snapshot_for_profile_comparison",
|
|
"raw": {
|
|
"annual_return": _annual_return_marker(return_marker),
|
|
"positions": positions,
|
|
},
|
|
"claim_boundary": "external_annual_chart_snapshot_is_not_tajika_yoga_or_report_authority",
|
|
}
|
|
except Exception as exc:
|
|
return {"status": "blocked", "reason": f"pyjhora_annual_chart_snapshot_failed:{exc.__class__.__name__}"}
|
|
|
|
|
|
def _signed_longitude_delta(previous: float, following: float) -> float:
|
|
return (float(following) - float(previous) + 180.0) % 360.0 - 180.0
|
|
|
|
|
|
def _planet_longitudes_from_positions(positions: Any) -> dict[int, float]:
|
|
values: dict[int, float] = {}
|
|
for row in positions if isinstance(positions, list) else []:
|
|
if not isinstance(row, (list, tuple)) or len(row) < 2 or not isinstance(row[0], int):
|
|
continue
|
|
planet, position = row[0], row[1]
|
|
if planet not in range(7) or not isinstance(position, (list, tuple)) or len(position) < 2:
|
|
continue
|
|
sign, degree = position[0], position[1]
|
|
if isinstance(sign, (int, float)) and isinstance(degree, (int, float)):
|
|
values[planet] = (float(sign) * 30.0 + float(degree)) % 360.0
|
|
return values
|
|
|
|
|
|
def _call_annual_chart_speed_snapshot(birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
|
|
"""Observe PyJHora annual-chart motion by finite difference.
|
|
|
|
The upstream annual-chart snapshot exposes longitudes but not speeds. This
|
|
child-process-only probe samples one minute on either side of the external
|
|
return marker so a later diagnostic can distinguish chart coordinates from
|
|
applying/separating semantics. It is not a native speed source or Tajika
|
|
Yoga authority.
|
|
"""
|
|
|
|
try:
|
|
from jhora import utils
|
|
from jhora.panchanga import drik
|
|
tajaka = _import_module("jhora.horoscope.transit.tajaka")
|
|
annual_chart = getattr(tajaka, "annual_chart", None)
|
|
if not callable(annual_chart):
|
|
return {"status": "blocked", "reason": "pyjhora_annual_chart_callable_missing"}
|
|
payload = annual_chart(birth_julian_day, place, years=age)
|
|
if not isinstance(payload, tuple) or len(payload) < 2:
|
|
return {"status": "blocked", "reason": "pyjhora_annual_chart_payload_invalid"}
|
|
annual_moment = _annual_return_datetime(payload[1])
|
|
if annual_moment is None:
|
|
return {"status": "blocked", "reason": "pyjhora_annual_chart_return_time_unavailable"}
|
|
|
|
offset_seconds = 60
|
|
before = annual_moment - timedelta(seconds=offset_seconds)
|
|
after = annual_moment + timedelta(seconds=offset_seconds)
|
|
before_jd = utils.julian_day_number(
|
|
(before.year, before.month, before.day), (before.hour, before.minute, before.second)
|
|
)
|
|
after_jd = utils.julian_day_number(
|
|
(after.year, after.month, after.day), (after.hour, after.minute, after.second)
|
|
)
|
|
before_positions = _planet_longitudes_from_positions(drik.dhasavarga(before_jd, place))
|
|
after_positions = _planet_longitudes_from_positions(drik.dhasavarga(after_jd, place))
|
|
if set(before_positions) != set(range(7)) or set(after_positions) != set(range(7)):
|
|
return {
|
|
"status": "blocked",
|
|
"reason": "pyjhora_annual_chart_speed_positions_incomplete",
|
|
"before_planets": sorted(before_positions),
|
|
"after_planets": sorted(after_positions),
|
|
}
|
|
interval_days = (2.0 * offset_seconds) / 86400.0
|
|
rates = {
|
|
planet: round(_signed_longitude_delta(before_positions[planet], after_positions[planet]) / interval_days, 8)
|
|
for planet in range(7)
|
|
}
|
|
return {
|
|
"status": "partial_verified",
|
|
"callable": "jhora.panchanga.drik.dhasavarga",
|
|
"reason": "pyjhora_annual_chart_finite_difference_speed_snapshot",
|
|
"raw": {
|
|
"annual_return": _annual_return_marker(payload[1]),
|
|
"sample_offset_seconds": offset_seconds,
|
|
"planet_rates_deg_per_day": rates,
|
|
},
|
|
"claim_boundary": "external_annual_speed_snapshot_is_comparison_only_not_tajika_yoga_or_report_authority",
|
|
}
|
|
except Exception as exc:
|
|
return {"status": "blocked", "reason": f"pyjhora_annual_chart_speed_snapshot_failed:{exc.__class__.__name__}"}
|
|
|
|
|
|
def _annual_return_marker(value: Any) -> dict[str, Any]:
|
|
if not isinstance(value, (list, tuple)) or len(value) < 2:
|
|
return {"raw": value}
|
|
date_value, time_value = value[0], value[1]
|
|
return {
|
|
"date": list(date_value) if isinstance(date_value, (list, tuple)) else date_value,
|
|
"time": str(time_value),
|
|
}
|
|
|
|
|
|
def _annual_return_datetime(value: Any) -> datetime | None:
|
|
marker = _annual_return_marker(value)
|
|
date_value = marker.get("date")
|
|
time_value = marker.get("time")
|
|
if not isinstance(date_value, list) or len(date_value) != 3 or not isinstance(time_value, str):
|
|
return None
|
|
try:
|
|
hour, minute, second = [int(part) for part in time_value.split(":")]
|
|
return datetime(int(date_value[0]), int(date_value[1]), int(date_value[2]), hour, minute, second)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _call_solar_return_boundary(module: Any, birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
|
|
try:
|
|
if not hasattr(module, "solar_return_chart"):
|
|
return {"status": "blocked", "reason": "pyjhora_solar_return_chart_missing"}
|
|
return {
|
|
"status": "partial_verified",
|
|
"raw": module.solar_return_chart(birth_julian_day, place, age),
|
|
}
|
|
except Exception as exc:
|
|
return {"status": "blocked", "reason": f"pyjhora_solar_return_failed:{exc}"}
|
|
|
|
|
|
def _probe_tajika_yogas(birth_julian_day: float, place: Any, age: int | float) -> dict[str, Any]:
|
|
"""Probe callable availability without recreating PyJHora yoga rules."""
|
|
|
|
try:
|
|
module = _import_module("jhora.horoscope.transit.tajaka_yoga")
|
|
except Exception as exc:
|
|
return {"status": "blocked", "reason": f"pyjhora_tajika_yogas_module_missing:{exc.__class__.__name__}"}
|
|
for name in ("tajaka_yoga", "tajika_yoga", "get_tajaka_yogas", "get_tajika_yogas"):
|
|
candidate = getattr(module, name, None)
|
|
if not callable(candidate):
|
|
continue
|
|
values = {"jd": birth_julian_day, "birth_julian_day": birth_julian_day, "place": place, "age": age, "years": age}
|
|
try:
|
|
params = signature(candidate).parameters
|
|
kwargs = {key: values[key] for key in params if key in values}
|
|
missing = [key for key, parameter in params.items() if parameter.default is parameter.empty and key not in kwargs]
|
|
if missing:
|
|
return {"status": "blocked", "reason": "pyjhora_tajika_yogas_callable_signature_unsupported", "callable": name, "missing": missing}
|
|
return {"status": "partial_verified", "callable": name, "raw": candidate(**kwargs)}
|
|
except Exception as exc:
|
|
return {"status": "blocked", "reason": f"pyjhora_tajika_yogas_callable_failed:{exc.__class__.__name__}", "callable": name}
|
|
return {"status": "blocked", "reason": "pyjhora_tajika_yogas_callable_missing"}
|
|
|
|
|
|
def _aggregate_status(result: dict[str, Any]) -> str:
|
|
statuses = {
|
|
result[key].get("status")
|
|
for key in (*_ANNUAL_SURFACE_KEYS, "year_lord_replay")
|
|
if isinstance(result.get(key), dict)
|
|
}
|
|
if statuses == {"partial_verified"}:
|
|
return "partial_verified"
|
|
if "partial_verified" in statuses:
|
|
return "partial_verified"
|
|
return "blocked"
|