173 lines
7.0 KiB
Python
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)
|