248 lines
9.9 KiB
Python
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,
|
|
}
|