Files
Jyotisha/scripts/unified_western_reading.py
T
jesse-uxGrok (agent loop)Cursor
900581068e feat(reading): fill the five full-reading modules missing from upstream
The western comparison layer was invoked without its files, so it stayed blocked. Navamsha and Niryaana Shoola are filled from the native rashi dashas.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:32:38 +08:00

269 lines
11 KiB
Python

"""Auditable orchestration for the native tropical Western calculation layers."""
from __future__ import annotations
try: # reached as a script: this directory is on the path, the root is not
import _script_root # noqa: F401
except ModuleNotFoundError: # pragma: no cover - reached as scripts.x instead
try:
from scripts import _script_root # noqa: F401
except ModuleNotFoundError:
pass
import argparse
import json
from datetime import date
from typing import Any, Callable
try:
import swisseph as swe
except ImportError: # pragma: no cover - optional runtime dependency
swe = None
try:
from scripts.classical_astrology import build_classical_astrology_packet, build_classical_usable_loop_audit_packet
from scripts.western_chart_engine import build_tropical_natal_chart
from scripts.western_chart_engine import build_draconic_chart, build_relocation_chart
from scripts.western_timing_engine import (
calculate_annual_profection,
calculate_converse_secondary_progressions,
calculate_converse_solar_arc_directions,
calculate_lunar_return,
calculate_lunar_return_series,
calculate_midpoints,
calculate_parans_status,
calculate_secondary_progressions,
calculate_solar_arc_directions,
calculate_solar_return,
calculate_tertiary_progressed_moon,
calculate_transit_duration_scan,
calculate_transit_to_natal,
calculate_zodiacal_release,
)
except ModuleNotFoundError: # pragma: no cover - direct script execution
from classical_astrology import build_classical_astrology_packet, build_classical_usable_loop_audit_packet
from western_chart_engine import build_tropical_natal_chart
from western_chart_engine import build_draconic_chart, build_relocation_chart
from western_timing_engine import (
calculate_annual_profection,
calculate_converse_secondary_progressions,
calculate_converse_solar_arc_directions,
calculate_lunar_return,
calculate_lunar_return_series,
calculate_midpoints,
calculate_parans_status,
calculate_secondary_progressions,
calculate_solar_arc_directions,
calculate_solar_return,
calculate_tertiary_progressed_moon,
calculate_transit_duration_scan,
calculate_transit_to_natal,
calculate_zodiacal_release,
)
def _executed(inputs: dict[str, Any], calculation: Callable[[], dict[str, Any]]) -> dict[str, Any]:
try:
return {"status": "executed", "input": inputs, "raw_result": calculation()}
except Exception as exc: # pragma: no cover - defensive orchestration boundary
return {
"status": "blocked",
"input": inputs,
"reason": f"{exc.__class__.__name__}: {exc}",
}
def _blocked(reason: str, inputs: dict[str, Any] | None = None) -> dict[str, Any]:
result: dict[str, Any] = {"status": "blocked", "reason": reason}
if inputs:
result["input"] = inputs
return result
def _reference_layer(
reference_date: str | None,
birth: dict[str, Any],
calculation: Callable[[str], dict[str, Any]],
) -> dict[str, Any]:
if not reference_date:
return _blocked("reference_date is required")
return _executed({"reference_date": reference_date}, lambda: calculation(reference_date))
def _overall_status(layers: dict[str, dict[str, Any]]) -> str:
return "complete" if all(layer["status"] == "executed" for layer in layers.values()) else "partial"
def _eclipse_events(reference_date: str) -> dict[str, Any]:
if swe is None:
raise RuntimeError("swisseph is unavailable")
start = date.fromisoformat(reference_date)
jd = swe.julday(start.year, start.month, start.day, 0.0)
events = []
for kind, finder in (("solar", swe.sol_eclipse_when_glob), ("lunar", swe.lun_eclipse_when)):
flags, times = finder(jd)
year, month, day, hour = swe.revjul(times[0])
events.append({"kind": kind, "maximum_utc": f"{year:04d}-{month:02d}-{day:02d}T{int(hour):02d}:{int((hour % 1) * 60):02d}:00Z", "swiss_event_flags": int(flags)})
return {"technique": "eclipse_triggers", "reference_date": reference_date, "events": events, "boundary": "Geometric eclipse triggers only; no predictive interpretation is inferred."}
def build_unified_western_reading(
*,
reference_date: str | None = None,
duration_scan_end_date: str | None = None,
lunar_return_months: int = 1,
relocation_latitude: float | None = None,
relocation_longitude: float | None = None,
relocation_timezone: str | float | int | None = None,
zodiacal_release_lot: str = "spirit",
zodiacal_release_levels: int = 2,
include_classical_usable_loop: bool = False,
classical_event_window_hours: int = 4,
classical_event_step_hours: int = 2,
classical_blind_scoring_manifest: str | None = None,
classical_reviewer_manifest: str | None = None,
**birth: Any,
) -> dict[str, Any]:
"""Run every available native Western layer and preserve raw evidence.
``reference_date`` drives the one-day timing techniques. A duration scan
additionally needs an explicit end date, so a caller cannot unknowingly
request an unbounded scan. Eclipse geometry uses the local Swiss Ephemeris
calculation and does not infer an event interpretation.
"""
layers: dict[str, dict[str, Any]] = {
"natal": _executed(dict(birth), lambda: build_tropical_natal_chart(**birth)),
"classical": _executed(
{"reference_date": reference_date},
lambda: build_classical_astrology_packet(reference_date=reference_date, **birth),
),
"draconic": _executed(dict(birth), lambda: build_draconic_chart(**birth)),
"transits": _reference_layer(
reference_date, birth, lambda target: calculate_transit_to_natal(target_date=target, **birth)
),
"solar_return": _reference_layer(
reference_date,
birth,
lambda target: calculate_solar_return(target_year=date.fromisoformat(target).year, **birth),
),
"secondary_progressions": _reference_layer(
reference_date, birth, lambda target: calculate_secondary_progressions(target_date=target, **birth)
),
"tertiary_moon": _reference_layer(
reference_date, birth, lambda target: calculate_tertiary_progressed_moon(target_date=target, **birth)
),
"solar_arc": _reference_layer(
reference_date, birth, lambda target: calculate_solar_arc_directions(target_date=target, **birth)
),
"midpoints": _reference_layer(
reference_date, birth, lambda target: calculate_midpoints(target_date=target, **birth)
),
"lunar_return": _reference_layer(
reference_date, birth, lambda target: calculate_lunar_return_series(
target_date=target, months=lunar_return_months, **birth
)
),
"parans": _reference_layer(
reference_date, birth, lambda target: calculate_parans_status(target_date=target, **birth)
),
"profection": _reference_layer(
reference_date, birth, lambda target: calculate_annual_profection(target_date=target, **birth)
),
"zodiacal_release": _reference_layer(
reference_date,
birth,
lambda target: calculate_zodiacal_release(
target_date=target,
lot=zodiacal_release_lot,
levels=int(zodiacal_release_levels),
**birth,
),
),
}
if relocation_latitude is None or relocation_longitude is None or relocation_timezone is None:
layers["relocation"] = _blocked(
"relocation_latitude, relocation_longitude, and relocation_timezone are required"
)
else:
layers["relocation"] = _executed(
{
"relocation_latitude": relocation_latitude,
"relocation_longitude": relocation_longitude,
"relocation_timezone": relocation_timezone,
},
lambda: build_relocation_chart(
relocation_latitude=float(relocation_latitude),
relocation_longitude=float(relocation_longitude),
relocation_timezone=relocation_timezone,
**birth,
),
)
if reference_date:
layers["converse"] = _executed(
{"reference_date": reference_date},
lambda: {
"secondary_progressions": calculate_converse_secondary_progressions(
target_date=reference_date, **birth
),
"solar_arc_directions": calculate_converse_solar_arc_directions(
target_date=reference_date, **birth
),
},
)
else:
layers["converse"] = _blocked("reference_date is required")
if not reference_date:
layers["transit_duration"] = _blocked("reference_date is required")
elif not duration_scan_end_date:
layers["transit_duration"] = _blocked("duration_scan_end_date is required")
else:
layers["transit_duration"] = _executed(
{"start_date": reference_date, "end_date": duration_scan_end_date},
lambda: calculate_transit_duration_scan(
start_date=reference_date, end_date=duration_scan_end_date, **birth
),
)
layers["eclipses"] = _reference_layer(reference_date, birth, _eclipse_events)
if include_classical_usable_loop:
layers["classical_usable_loop"] = _executed(
{
"reference_date": reference_date,
"event_window_hours": classical_event_window_hours,
"event_step_hours": classical_event_step_hours,
"blind_scoring_manifest": classical_blind_scoring_manifest,
"reviewer_manifest": classical_reviewer_manifest,
},
lambda: build_classical_usable_loop_audit_packet(
reference_date=reference_date,
event_window_hours=int(classical_event_window_hours),
event_step_hours=int(classical_event_step_hours),
blind_scoring_manifest_path=classical_blind_scoring_manifest,
reviewer_manifest_path=classical_reviewer_manifest,
**birth,
),
)
return {
"system": "western_astrology",
"status": _overall_status(layers),
"reference_date": reference_date,
"layers": layers,
"boundary": "Raw native calculations only; this entrypoint makes no predictive interpretation.",
}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Run the unified native Western calculation layers.")
parser.add_argument("--input-json", required=True, help="Structured birth and timing input as JSON.")
args = parser.parse_args(argv)
payload = json.loads(args.input_json)
if not isinstance(payload, dict):
raise ValueError("--input-json must decode to an object")
print(json.dumps(build_unified_western_reading(**payload), ensure_ascii=False, default=str))
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())