Files
Jyotisha/scripts/annual_native/yogi_sphuta.py
T

248 lines
9.9 KiB
Python

"""Local Yogi/Ava Yogi Sphuta from a chart that is already computed.
The Sun and Moon longitudes are read from ``chart.planets.*.degree_raw``.
This module does not call an ephemeris of its own.
"""
from __future__ import annotations
import math
from bisect import bisect_right
from copy import deepcopy
from typing import Any
SCHEMA = "jyotish.yogi_sphuta.local.v1"
FORMULA_PROFILE = "pyjhora_yogi_sphuta_v1"
SIGNS = (
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
)
SIGN_LORDS = {
"Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Cancer": "Moon",
"Leo": "Sun", "Virgo": "Mercury", "Libra": "Venus", "Scorpio": "Mars",
"Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn", "Pisces": "Jupiter",
}
NAKSHATRA_NAMES = (
"Ashwini", "Bharani", "Krittika", "Rohini", "Mrigashira", "Ardra", "Punarvasu",
"Pushya", "Ashlesha", "Magha", "Purva Phalguni", "Uttara Phalguni", "Hasta",
"Chitra", "Swati", "Vishakha", "Anuradha", "Jyeshtha", "Mula", "Purva Ashadha",
"Uttara Ashadha", "Shravana", "Dhanishta", "Shatabhisha", "Purva Bhadrapada",
"Uttara Bhadrapada", "Revati",
)
NAKSHATRA_LORDS = (
"Ketu", "Venus", "Sun", "Moon", "Mars", "Rahu", "Jupiter", "Saturn", "Mercury",
"Ketu", "Venus", "Sun", "Moon", "Mars", "Rahu", "Jupiter", "Saturn", "Mercury",
"Ketu", "Venus", "Sun", "Moon", "Mars", "Rahu", "Jupiter", "Saturn", "Mercury",
)
NAKSHATRA_BOUNDARIES = tuple(index * 360.0 / 27.0 for index in range(27))
YOGI_OFFSET = 93.0 + 20.0 / 60.0
AVA_OFFSET = 186.0 + 40.0 / 60.0
def _longitude(value: Any) -> float | None:
try:
if isinstance(value, bool):
return None
number = float(value)
return number % 360.0 if math.isfinite(number) else None
except (TypeError, ValueError):
return None
def _sign_from_longitude(longitude: float) -> str:
return SIGNS[int((float(longitude) % 360.0) // 30.0) % 12]
def _house_from_longitude(longitude: float, ascendant_sign: str | None) -> int | None:
if ascendant_sign not in SIGN_LORDS:
return None
asc_idx = SIGNS.index(ascendant_sign)
sign_idx = int((float(longitude) % 360.0) // 30.0) % 12
return ((sign_idx - asc_idx) % 12) + 1
def _point(longitude: float, ascendant_sign: str | None = None) -> dict[str, Any]:
value = float(longitude) % 360.0
sign = _sign_from_longitude(value)
nakshatra_index = bisect_right(NAKSHATRA_BOUNDARIES, value) - 1
return {
"longitude": round(value, 8),
"sign": sign,
"degree_in_sign": round(value % 30.0, 8),
"nakshatra": NAKSHATRA_NAMES[nakshatra_index],
"nakshatra_index": nakshatra_index,
"nakshatra_lord": NAKSHATRA_LORDS[nakshatra_index],
"sign_lord": SIGN_LORDS[sign],
"house": _house_from_longitude(value, ascendant_sign),
}
def build_yogi_sphuta_local(chart: dict[str, Any]) -> dict[str, Any]:
"""Calculate local Yogi/Ava Yogi points without interpretive claims."""
if not isinstance(chart, dict):
return {
"schema": SCHEMA,
"status": "blocked",
"reason": "chart_payload_missing",
"formula_profile": FORMULA_PROFILE,
}
planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {}
sun = planets.get("Sun") if isinstance(planets.get("Sun"), dict) else {}
moon = planets.get("Moon") if isinstance(planets.get("Moon"), dict) else {}
sun_lon = _longitude(sun.get("degree_raw", sun.get("lon", sun.get("degree"))))
moon_lon = _longitude(moon.get("degree_raw", moon.get("lon", moon.get("degree"))))
if sun_lon is None or moon_lon is None:
return {
"schema": SCHEMA,
"status": "blocked",
"reason": "sun_or_moon_longitude_missing",
"formula_profile": FORMULA_PROFILE,
}
ascendant = chart.get("ascendant") if isinstance(chart.get("ascendant"), dict) else {}
ascendant_sign = ascendant.get("sign") if isinstance(ascendant.get("sign"), str) else None
yogi_longitude = (sun_lon + moon_lon + YOGI_OFFSET) % 360.0
ava_longitude = (yogi_longitude + AVA_OFFSET) % 360.0
return {
"schema": SCHEMA,
"status": "partial_verified",
"formula_profile": FORMULA_PROFILE,
"formula": "sidereal_sun_longitude + sidereal_moon_longitude + 93d20m; Ava Yogi = Yogi + 186d40m",
"claim_boundary": (
"Local Yogi/Ava Yogi Sphuta is a restricted supplementary point producer. "
"It cannot create event, wealth, timing, remedy, or final-judgment claims by itself."
),
"source_fields": ["chart.planets.Sun.degree_raw", "chart.planets.Moon.degree_raw", "chart.ascendant.sign"],
"source_longitudes": {"Sun": sun_lon, "Moon": moon_lon},
"yogi": _point(yogi_longitude, ascendant_sign),
"ava_yogi": _point(ava_longitude, ascendant_sign),
}
def build_luminary_sphuta_report(
*,
sun_longitude: float,
moon_longitude: float,
ayanamsa: str,
ascendant_sign: str | None = None,
) -> dict:
"""Supplied sidereal longitudes; sum/difference plus the existing Yogi profile.
No ephemeris, period or event prediction is inferred from these points.
"""
try:
from scripts.ayanamsa_utils import normalize_ayanamsa_name
except ImportError:
from ayanamsa_utils import normalize_ayanamsa_name
if not isinstance(ayanamsa, str) or not ayanamsa.strip():
raise ValueError("explicit ayanamsa required")
mode = normalize_ayanamsa_name(ayanamsa)
for number in (sun_longitude, moon_longitude):
if (
isinstance(number, bool)
or not isinstance(number, (int, float))
or not math.isfinite(number)
or not 0 <= number < 360
):
raise ValueError("sidereal absolute longitudes must be finite in [0,360)")
if ascendant_sign is not None and ascendant_sign not in SIGNS:
raise ValueError("unknown ascendant sign")
local = build_yogi_sphuta_local({
"planets": {
"Sun": {"degree_raw": sun_longitude},
"Moon": {"degree_raw": moon_longitude},
},
"ascendant": {"sign": ascendant_sign},
})
points = {
"tithi_sphuta": _point((moon_longitude - sun_longitude) % 360, ascendant_sign),
"yoga_sphuta": _point((moon_longitude + sun_longitude) % 360, ascendant_sign),
"yogi": local["yogi"],
"ava_yogi": local["ava_yogi"],
}
evidence = {
"schema": "jyotish.luminary_sphuta.v1",
"status": "calculated",
"variant_profile_id": "jh761_luminary_existing_yogi_v1",
"tradition_profile": "jh761_luminary_pyjhora_yogi",
"ayanamsa": mode,
"points": points,
"house_basis": "whole_sign" if ascendant_sign else "not_supplied",
"duplicate_yogi": points["yogi"]["sign_lord"],
"formulae": {
"tithi_sphuta": "(Moon-Sun)%360",
"yoga_sphuta": "(Moon+Sun)%360",
"yogi": "(Moon+Sun+93d20m)%360",
"ava_yogi": "(Yogi+186d40m)%360",
},
"sources": [
"https://www.vedicastrologer.org/jh/update_7.61.htm",
],
"claim_boundary": (
"Calculated sensitive points only; no Dasha, event dates or standalone outcome verdict. "
"Supplied ayanamsa labels input provenance, not conversion."
),
}
lines = [
"# Luminary sphuta",
"",
evidence["claim_boundary"],
"",
"| Point | Longitude | Sign | Nakshatra | Lord |",
"| --- | --- | --- | --- | --- |",
]
for key, point in points.items():
lines.append(
f"| {key} | {point['longitude']} | {point['sign']} | {point['nakshatra']} | {point['nakshatra_lord']} |"
)
return {
"status": "calculated",
"customer_export_admitted": False,
"evidence_appendix": [evidence],
"exports": {"ai_evidence_bundle": deepcopy(evidence), "markdown": "\n".join(lines)},
}
def compare_yogi_sphuta_to_replay(
local: dict[str, Any],
replay: dict[str, Any],
tolerance_deg: float = 0.05,
) -> dict[str, Any]:
"""Compare local points to an external replay without making multi-engine claims."""
if not isinstance(local, dict) or local.get("status") != "partial_verified":
return {"status": "blocked", "reason": "local_yogi_sphuta_unavailable"}
if not isinstance(replay, dict) or replay.get("status") != "partial_verified":
return {"status": "blocked", "reason": "external_yogi_replay_unavailable"}
rows = []
for field, key in (("Yogi", "yogi"), ("Ava Yogi", "ava_yogi")):
local_point = local.get(key) if isinstance(local.get(key), dict) else {}
replay_point = replay.get(key) if isinstance(replay.get(key), dict) else {}
local_lon = _longitude(local_point.get("longitude"))
replay_lon = _longitude(replay_point.get("longitude"))
if local_lon is None or replay_lon is None:
status = "blocked"
delta = None
else:
delta = abs((local_lon - replay_lon + 180.0) % 360.0 - 180.0)
status = "match" if delta <= tolerance_deg else "mismatch"
rows.append({
"field": field,
"status": status,
"local_longitude": local_lon,
"external_longitude": replay_lon,
"delta_deg": round(delta, 8) if delta is not None else None,
})
if all(row["status"] == "match" for row in rows):
status = "partial_verified"
elif any(row["status"] == "mismatch" for row in rows):
status = "parameter_sensitive"
else:
status = "blocked"
return {
"schema": "jyotish.yogi_sphuta.local_external_comparison.v1",
"status": status,
"claim_boundary": "This is same-formula local-vs-replay alignment, not independent multi-engine parity.",
"tolerance_deg": tolerance_deg,
"rows": rows,
}