Files
Jyotisha/scripts/native_special_lagnas.py
T
jesse-ux 795b8845c7
Independent Staging Quality Gate / validate (push) Successful in 19m4s
Independent Staging Quality Gate / publish (push) Successful in 3m51s
fix(special-lagnas): compute eight birth points without PyJHora
Production images do not install PyJHora, so those eight points were blocked.
Swiss Ephemeris now computes them in-process. Rectification keeps the old
Hora and Ghati formulas.

BUG-1273.
2026-10-08 15:01:44 +08:00

291 lines
11 KiB
Python

#!/usr/bin/env python3
"""Birth special lagnas from Swiss Ephemeris. No PyJHora import.
Numeric rates are the ones named by P.V.R. Narasimha Rao, *Vedic Astrology:
An Integrated Approach*, section 5.2, and by the B.V. Raman methods below.
``references/`` does not contain the BPHS chapter 4 verse text, so a point is
not given a rate that those named sources do not state.
- Bhava: section 5.2 step (2) divides the sunrise-to-birth minutes by 4
(0.25 degree per minute) and adds that to the sidereal Sun at sunrise.
- Hora: 0.5 degree per minute, one rasi per hour, from that same sunrise Sun.
- Ghati: 1.25 degrees per minute, one rasi per 24-minute ghati.
- Vighati: 15 degrees per minute. A literal "one rasi per vighati" (24 seconds)
would be 75 degrees per minute; section 5.2's special-ascendant family, as
recorded for this rate, uses 15. That is the frozen comparison rate.
- Sree: ascendant plus the Moon's leftover degrees inside its nakshatra,
times 27 (classical Sri Lagna).
- Indu: B.V. Raman. Kala factors of the 9th lords from Lagna and from the Moon
are 30, 16, 6, 8, 10, 12, 1 for Sun through Saturn. The degree is the Moon's
degree inside its own sign.
- Pranapada: ishta ghatis from sunrise (previous sunrise when birth is earlier),
times 4 signs, plus the birth Sun, plus 8 signs for a fixed Sun sign, 4 for
a dual Sun sign, and 0 for a movable Sun sign. The clock is truncated to
whole seconds before the ghati count, matching the frozen comparison.
- Varnada: B.V. Raman method 1. Odd signs are counted forward from Aries, even
signs backward from Pisces. The returned degree is the ascendant's degree
inside its own sign, placed in the Varnada sign.
Sunrise is the centre of the solar disc, Hindu rising, no refraction
(Swiss Ephemeris). Sidereal mode is the caller's ayanamsa and is restored.
"""
from __future__ import annotations
from contextlib import nullcontext
from datetime import datetime
SIGNS = [
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
]
SOURCE = "native_special_lagna"
POINT_NAMES = (
("Bhava_Lagna", "Bhava Lagna"),
("Hora_Lagna", "Hora Lagna"),
("Ghati_Lagna", "Ghati Lagna"),
("ViGhati_Lagna", "Vighati Lagna"),
("Sree_Lagna", "Sree Lagna"),
("Indu_Lagna", "Indu Lagna"),
("Pranapada_Lagna", "Pranapada Lagna"),
("Varnada_Lagna", "Varnada Lagna"),
)
# Sun..Saturn lords of Aries..Pisces. Planet index 0 is the Sun.
HOUSE_LORDS = (2, 5, 3, 1, 0, 3, 5, 2, 4, 6, 6, 4)
INDU_KALAS = (30, 16, 6, 8, 10, 12, 1)
ODD_SIGNS = frozenset({0, 2, 4, 6, 8, 10})
FIXED_SIGNS = frozenset({1, 4, 7, 10})
DUAL_SIGNS = frozenset({2, 5, 8, 11})
ONE_ARC_SECOND = 1.0 / 3600.0
RATES = {
"Bhava_Lagna": 0.25,
"Hora_Lagna": 0.5,
"Ghati_Lagna": 1.25,
"ViGhati_Lagna": 15.0,
}
def native_special_lagna_rows(
birth_time: datetime,
lat: float | None,
lon: float | None,
tz_offset: float | None,
asc_degree: float,
ayanamsa: str | None = None,
) -> dict[str, dict]:
"""Eight birth points. Missing coordinates block the rows and invent nothing."""
if lat is None or lon is None or tz_offset is None:
return _blocked("birth_coordinates_or_timezone_missing")
try:
import swisseph as swe
except Exception as exc:
return _blocked(f"swisseph_unavailable:{exc}")
try:
from ayanamsa_utils import temporary_ayanamsa
except ImportError: # package import
from scripts.ayanamsa_utils import temporary_ayanamsa
context = temporary_ayanamsa(ayanamsa) if ayanamsa else nullcontext()
try:
with context:
rows = _compute(swe, birth_time, float(lat), float(lon), float(tz_offset), float(asc_degree))
except Exception as exc:
reason = exc.__class__.__name__
if exc.__class__.__name__ == "UnsupportedAyanamsaError":
reason = f"native_ayanamsa_unsupported:{ayanamsa}"
else:
reason = f"native_special_lagna_failed:{exc}"
return _blocked(reason)
if ayanamsa is not None:
for row in rows.values():
row["ayanamsa"] = ayanamsa
return rows
def _blocked(reason: str) -> dict[str, dict]:
return {
key: {"full_name": full_name, "status": "blocked", "source": SOURCE, "reason": reason}
for key, full_name in POINT_NAMES
}
def _compute(swe, birth_time: datetime, lat: float, lon: float, tz_offset: float, asc_degree: float) -> dict[str, dict]:
planet_flags = (
swe.FLG_SWIEPH | swe.FLG_SIDEREAL | swe.FLG_TRUEPOS
| swe.FLG_NOGDEFL | swe.FLG_NONUT | swe.FLG_SPEED
)
rise_flags = swe.BIT_HINDU_RISING | swe.BIT_NO_REFRACTION | swe.BIT_DISC_CENTER | swe.CALC_RISE
asc_flags = swe.FLG_SWIEPH | swe.FLG_SIDEREAL | swe.BIT_HINDU_RISING | swe.FLG_TRUEPOS | swe.FLG_SPEED
local_hour = (
birth_time.hour
+ birth_time.minute / 60.0
+ birth_time.second / 3600.0
+ birth_time.microsecond / 3_600_000_000.0
)
jd_local = swe.julday(birth_time.year, birth_time.month, birth_time.day, local_hour)
birth_hours = swe.revjul(jd_local, swe.GREG_CAL)[3]
sun_at_sunrise, sunrise_hours = _sun_at_sunrise(swe, jd_local, lat, lon, tz_offset, planet_flags, rise_flags)
minutes = (birth_hours - sunrise_hours) * 60.0
jd_birth_ut = jd_local - tz_offset / 24.0
sun_birth = _sidereal(swe, jd_birth_ut, swe.SUN, planet_flags)
moon_birth = _sidereal(swe, jd_birth_ut, swe.MOON, planet_flags)
asc_sign, asc_in_sign = _ascendant(swe, jd_local, lat, lon, tz_offset, asc_flags)
asc_long = (asc_sign * 30.0 + asc_in_sign) % 360.0
longitudes = {
key: (sun_at_sunrise + minutes * rate) % 360.0
for key, rate in RATES.items()
}
longitudes["Sree_Lagna"] = (asc_long + (moon_birth % (360.0 / 27.0)) * 27.0) % 360.0
longitudes["Indu_Lagna"] = _indu_longitude(asc_sign, moon_birth)
longitudes["Pranapada_Lagna"] = _pranapada_longitude(
swe, jd_local, birth_hours, lat, lon, tz_offset, sun_birth, planet_flags, rise_flags,
)
varnada_sign = _varnada_sign(asc_sign, _sign_index(longitudes["Hora_Lagna"]))
rows = {}
for key, full_name in POINT_NAMES:
if key == "Varnada_Lagna":
longitude = varnada_sign * 30.0 + (asc_in_sign % 30.0)
elif key == "Indu_Lagna":
longitude = longitudes[key]
else:
longitude = longitudes[key]
rows[key] = _payload(full_name, longitude, asc_degree)
return rows
def _sun_at_sunrise(swe, jd_local: float, lat: float, lon: float, tz_offset: float, planet_flags: int, rise_flags: int):
year, month, day, _hour = swe.revjul(jd_local, swe.GREG_CAL)
jd_midnight = swe.julday(int(year), int(month), int(day), 0.0)
_status, times = swe.rise_trans(
jd_midnight - tz_offset / 24.0,
swe.SUN,
rise_flags,
(lon, lat, 0.0),
0.0,
0.0,
planet_flags,
)
rise_jd_ut = times[0]
rise_local = (rise_jd_ut - jd_midnight) * 24.0 + tz_offset
hour, minute, second = _clock_hms(rise_local)
truncated = swe.julday(int(year), int(month), int(day), hour + minute / 60.0 + second / 3600.0)
# Chart helpers treat the sunrise JD as local and subtract the timezone.
sun = _sidereal(swe, truncated, swe.SUN, planet_flags)
return sun, rise_local
def _pranapada_longitude(swe, jd_local, birth_hours, lat, lon, tz_offset, sun_birth, planet_flags, rise_flags):
sunrise_hours = _sun_at_sunrise(swe, jd_local, lat, lon, tz_offset, planet_flags, rise_flags)[1]
if birth_hours < sunrise_hours:
sunrise_hours = _sun_at_sunrise(swe, jd_local - 1.0, lat, lon, tz_offset, planet_flags, rise_flags)[1]
elapsed = 24.0 + birth_hours - sunrise_hours
else:
elapsed = birth_hours - sunrise_hours
hours, minutes, seconds = _clock_hms(elapsed)
tharparai = int(hours) * 9000 + int(minutes) * 150 + int(seconds)
birth_signs = ((tharparai / 3600.0) * 4.0) % 12.0
sun_sign = _sign_index(sun_birth)
if sun_sign in FIXED_SIGNS:
extra = 240.0
elif sun_sign in DUAL_SIGNS:
extra = 120.0
else:
extra = 0.0
return (birth_signs * 30.0 + sun_birth + extra) % 360.0
def _indu_longitude(asc_sign: int, moon_long: float) -> float:
moon_sign = _sign_index(moon_long)
ninth = HOUSE_LORDS[(asc_sign + 8) % 12]
ninth_from_moon = HOUSE_LORDS[(moon_sign + 8) % 12]
kalas = (INDU_KALAS[ninth] + INDU_KALAS[ninth_from_moon]) % 12
if kalas == 0:
kalas = 12
indu_sign = (moon_sign + kalas - 1) % 12
return indu_sign * 30.0 + (moon_long % 30.0)
def _varnada_sign(lagna: int, hora_sign: int) -> int:
lagna_odd = lagna in ODD_SIGNS
hora_sign = hora_sign % 12
hora_odd = hora_sign in ODD_SIGNS
count1 = _count_rasis(0, lagna, 1) if lagna_odd else _count_rasis(11, lagna, -1)
count2 = _count_rasis(0, hora_sign, 1) if hora_odd else _count_rasis(11, hora_sign, -1)
same_parity = (count1 + count2) % 12
opposite_parity = (max(count1, count2) - min(count1, count2)) % 12
count = same_parity if hora_odd == lagna_odd else opposite_parity
counted = _count_rasis(1, count, 1) if lagna_odd else _count_rasis(12, count, -1)
return (counted - 1) % 12
def _count_rasis(start: int, end: int, direction: int, total: int = 12) -> int:
return ((total + direction * (end - start)) % total) + 1
def _ascendant(swe, jd_local: float, lat: float, lon: float, tz_offset: float, flags: int):
jd_ut = jd_local - tz_offset / 24.0
_cusps, ascmc = swe.houses_ex(jd_ut, lat, lon, b"P", flags)
longitude = ascmc[0] % 360.0
sign = int(longitude // 30) % 12
return sign, longitude - sign * 30.0
def _sidereal(swe, jd_ut: float, planet: int, flags: int) -> float:
position, _speed = swe.calc_ut(jd_ut, planet, flags)
return position[0] % 360.0
def _sign_index(longitude: float) -> int:
sign, _degree = _dasavarga(longitude)
return sign
def _dasavarga(longitude: float) -> tuple[int, float]:
one_sign = 360.0
fraction = (longitude / one_sign) % 1.0
sign = int(fraction * 12.0)
degree = (longitude - sign * 30.0) % 30.0
if int(degree + ONE_ARC_SECOND) == 30:
degree = 0.0
sign = (sign + 1) % 12
return sign, degree
def _clock_hms(hours: float) -> tuple[int, int, int]:
"""Whole hours, minutes, and rounded seconds, matching the frozen oracle clock."""
day_part = int(hours)
minutes_float = (hours - day_part) * 60.0
minute = int(minutes_float)
second = round((minutes_float - minute) * 60.0)
if day_part > 23:
day_part = day_part % 24
elif day_part < 0:
day_part = abs(day_part) % 24
minute = abs(minute)
second = abs(second)
if second == 60:
minute += 1
second = 0
if minute == 60:
day_part += 1
minute = 0
return int(day_part), int(minute), int(second)
def _payload(full_name: str, longitude: float, asc_degree: float) -> dict:
longitude = longitude % 360.0
sign, degree = _dasavarga(longitude)
full = sign * 30.0 + degree
return {
"full_name": full_name,
"degree": round(full, 4),
"longitude": round(full, 4),
"sign": SIGNS[sign],
"sign_degree": round(degree, 4),
"house": ((sign - int(asc_degree // 30)) % 12) + 1,
"method": "swiss_ephemeris_special_lagna",
"source": SOURCE,
"status": "computed",
}