Files
Jyotisha/scripts/annual_native/tithi_pravesh.py
T

173 lines
7.0 KiB
Python

"""Tithi Pravesha instants from our sidereal Sun and Moon.
A missing body raises. Import errors are not stored as status=blocked.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
try:
from annual_native.sidereal_bodies import sun_moon_longitudes
except ImportError: # pragma: no cover
from scripts.annual_native.sidereal_bodies import sun_moon_longitudes
SCHEMA = "jyotish.tithi_pravesh.v1"
_NAMES = (
"Pratipada", "Dvitiya", "Tritiya", "Chaturthi", "Panchami",
"Shashthi", "Saptami", "Ashtami", "Navami", "Dashami",
"Ekadashi", "Dvadashi", "Trayodashi", "Chaturdashi", "Purnima/Amavasya",
)
def build_tithi_pravesh(payload: dict[str, Any]) -> dict[str, Any]:
birth = dict(payload["birth"])
settings = dict(payload.get("settings") or {})
target_year = int(payload["target_year"])
year, month, day = [int(value) for value in str(birth["date"]).split("-")]
time_parts = [int(value) for value in str(birth.get("time") or "00:00:00").split(":")]
while len(time_parts) < 3:
time_parts.append(0)
tz = _offset_hours(birth.get("utc_offset"))
ayanamsa = str(settings.get("ayanamsa") or "lahiri")
node_mode = str(settings.get("node_mode") or "mean")
birth_jd = _jd_local(year, month, day, time_parts[0] + time_parts[1] / 60 + time_parts[2] / 3600, tz)
birth_sun, birth_moon = sun_moon_longitudes(birth_jd, node_mode=node_mode, ayanamsa_name=ayanamsa)
target_phase = (birth_moon - birth_sun) % 360
target_solar_sign = int(birth_sun // 30)
candidate_jds = _find_candidates(
target_year, month, day, tz, target_phase, target_solar_sign, ayanamsa, node_mode,
)
birth_place = _place(birth, tz)
birth_rows = [
_chart_row(jd, birth_place, node_mode, target_phase, ayanamsa) for jd in candidate_jds
]
return {
"schema": SCHEMA,
"status": "partial_verified" if birth_rows else "blocked",
"reason": None if birth_rows else "no_tithi_pravesh_candidate",
"target_year": target_year,
"natal_phase_degrees": target_phase,
"natal_sun_longitude": birth_sun,
"natal_moon_longitude": birth_moon,
"rows": birth_rows,
}
def _find_candidates(target_year, birth_month, birth_day, tz, target_phase, target_solar_sign, ayanamsa, node_mode) -> list[float]:
try:
anchor = datetime(target_year, birth_month, birth_day)
except ValueError:
anchor = datetime(target_year, birth_month, 1)
start_jd = _datetime_to_jd(anchor - timedelta(days=42), tz)
end_jd = _datetime_to_jd(anchor + timedelta(days=82), tz)
roots: list[float] = []
previous_jd = start_jd
previous_value = _phase_delta(previous_jd, target_phase, ayanamsa, node_mode)
while previous_jd < end_jd:
current_jd = min(previous_jd + 0.125, end_jd)
current_value = _phase_delta(current_jd, target_phase, ayanamsa, node_mode)
crossed = previous_value * current_value <= 0 and abs(previous_value) < 170 and abs(current_value) < 170
if crossed:
root = _bisect(previous_jd, current_jd, target_phase, ayanamsa, node_mode)
if root is not None:
sun, _moon = sun_moon_longitudes(root, node_mode=node_mode, ayanamsa_name=ayanamsa)
if int(sun // 30) == target_solar_sign and all(abs(root - existing) > 0.02 for existing in roots):
roots.append(root)
previous_jd, previous_value = current_jd, current_value
return roots
def _bisect(low, high, target_phase, ayanamsa, node_mode) -> float | None:
low_value = _phase_delta(low, target_phase, ayanamsa, node_mode)
for _ in range(40):
mid = (low + high) / 2
mid_value = _phase_delta(mid, target_phase, ayanamsa, node_mode)
if abs(mid_value) < 1e-7:
return mid
if low_value * mid_value <= 0:
high = mid
else:
low, low_value = mid, mid_value
root = (low + high) / 2
if abs(_phase_delta(root, target_phase, ayanamsa, node_mode)) >= 1e-4:
return None
return root
def _phase_delta(jd, target_phase, ayanamsa, node_mode) -> float:
sun, moon = sun_moon_longitudes(jd, node_mode=node_mode, ayanamsa_name=ayanamsa)
return ((moon - sun - target_phase + 180.0) % 360.0) - 180.0
def _chart_row(jd, place, node_mode, target_phase, ayanamsa) -> dict[str, Any]:
try:
from jyotish_engine import compute_chart_data
except ImportError:
from scripts.jyotish_engine import compute_chart_data
local = datetime(2000, 1, 1) + timedelta(days=jd - 2451544.5, hours=float(place["tz"]))
chart, *_rest = compute_chart_data(
local.year, local.month, local.day, local.hour, local.minute,
float(place["latitude"]), float(place["longitude"]), float(place["tz"]),
node_mode, second=local.second, ayanamsa_name=ayanamsa,
)
if not isinstance(chart, dict):
raise RuntimeError(f"compute_chart_data returned no chart for tithi jd {jd}")
planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {}
sun = float(planets["Sun"]["degree_raw"]) % 360.0
moon = float(planets["Moon"]["degree_raw"]) % 360.0
phase = (moon - sun) % 360.0
return {
"jd_ut": jd,
"local_datetime": local.strftime("%Y-%m-%d %H:%M:%S"),
"tithi": _tithi_payload(sun, moon),
"phase_degrees": phase,
"phase_delta_degrees": ((phase - target_phase + 180.0) % 360.0) - 180.0,
"planet_longitudes": {
name: planets[name].get("degree_raw")
for name in ("Sun", "Moon")
if isinstance(planets.get(name), dict)
},
"status": "partial_verified",
}
def _tithi_payload(sun: float, moon: float) -> dict[str, Any]:
phase = (moon - sun) % 360.0
number = min(30, int(phase // 12) + 1)
paksha = "Shukla" if number <= 15 else "Krishna"
within = number if number <= 15 else number - 15
return {
"tithi_num": number,
"paksha": paksha,
"full_name": f"{paksha} {_NAMES[within - 1]}",
"moon_sun_diff": phase,
}
def _place(data: dict, fallback_tz: float) -> dict[str, Any]:
return {
"latitude": float(data["latitude"]),
"longitude": float(data["longitude"]),
"tz": _offset_hours(data.get("utc_offset")) if data.get("utc_offset") is not None else fallback_tz,
}
def _offset_hours(value: Any) -> float:
if isinstance(value, (int, float)) and not isinstance(value, bool):
return float(value)
text = str(value or "+00:00").strip()
sign = -1 if text.startswith("-") else 1
hours, minutes = [int(item) for item in text.lstrip("+-").split(":")[:2]]
return sign * (hours + minutes / 60.0)
def _jd_local(year: int, month: int, day: int, local_hours: float, tz: float) -> float:
import swisseph as swe
return float(swe.julday(year, month, day, local_hours - tz))
def _datetime_to_jd(value: datetime, tz: float) -> float:
return _jd_local(value.year, value.month, value.day, value.hour + value.minute / 60 + value.second / 3600, tz)