Files
Jyotisha/scripts/gulika.py
T
ClaudeandClaude Opus 5.5 5f234c315b fix(jyotish): Chara co-lord and antardasha, night time-segment points, pre-sunrise weekday; re-freeze as audit_h (BUG-1565..1568)
Data audit wave 2 (H) against PyJHora 4.8.6 and Swiss Ephemeris, 19 charts,
Raman and Lahiri passed explicitly to both sides.

- BUG-1566 (jaimini.py): the K.N. Rao Chara Scorpio / Aquarius lord read both
  co-lords' dignity in the dasha sign itself, so Scorpio was always Ketu and
  Aquarius always Saturn. Now one lord in the sign gives way to the other
  (K.N. Rao; Rath p.43 (c); PyJHora); otherwise compared where each stands
  (companions, Jupiter / Mercury / dispositor, dignity, modality, degrees).
  MD years vs PyJHora 212/228 -> 225/228 (Raman), 226/228 (Lahiri).
- BUG-1567 (jaimini.py): K.N. Rao antardashas now run each MD's own
  direction with the MD sign last (pp.35-36); book order 19/228 -> 228/228.
- BUG-1565 (gulika.py): Wednesday-Saturday night share table started one lord
  early; Maandi and the four other time-segment points 79/114 -> 114/114
  against an independent Swiss computation. Gulika unchanged (19/19).
- BUG-1568 (jyotish_engine.py): natal Panchanga vara / hora used the civil
  date; a birth before sunrise belongs to the previous weekday (15/19 -> 19/19
  vs PyJHora drik.vaara).

Narayana (legacy and Rath), transits, sunrise and day/night: no defect; the
second-cycle rule matches PyJHora's. Variants are listed in BUG_HISTORY.

jaimini.py is in the frozen identity: per ERR-110, new records frozen and
replayed as audit_h_2026_11_04; contract, freshness test and frontend gate
test rotated. Versus moon_paksha_2026_11_04: sealed 20/20 and reported offset
900/900 identical, metrics and summary unchanged. Chara sections of two
frontend fixtures and the rashi-dasha PyJHora fixture's native rows updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-04 16:53:31 +08:00

230 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""Swiss-Ephemeris Gulika calculator using the Prasna Marga Ghatika table."""
from __future__ import annotations
from datetime import datetime
from typing import Any
import swisseph as swe
try:
from saham_daynight import determine_daytime
except ImportError:
from scripts.saham_daynight import determine_daytime
try:
from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, ayanamsa_display_name, normalize_ayanamsa_name, temporary_ayanamsa
except ImportError:
from scripts.ayanamsa_utils import DEFAULT_AYANAMSA_NAME, ayanamsa_display_name, normalize_ayanamsa_name, temporary_ayanamsa
# Monday=0, matching datetime.weekday(). Values are the end of Saturn's share
# measured in Ghatika from the relevant sunrise/sunset (30 Ghatika per period).
GHATIKA_END = {
0: {"day": 22, "night": 6},
1: {"day": 18, "night": 2},
2: {"day": 14, "night": 26},
3: {"day": 10, "night": 22},
4: {"day": 6, "night": 18},
5: {"day": 2, "night": 14},
6: {"day": 26, "night": 10},
}
# Saturn's zero-based share in eight equal day/night parts. Python weekday:
# Monday=0. This is the variant used by PyJHora's public black-box oracle.
SATURN_PART_START = {
0: {"day": 5, "night": 1},
1: {"day": 4, "night": 0},
2: {"day": 3, "night": 6},
3: {"day": 2, "night": 5},
4: {"day": 1, "night": 4},
5: {"day": 0, "night": 3},
6: {"day": 6, "night": 2},
}
# Planet indices follow the local seven-graha ordering: Sun through Saturn.
# Each row assigns the eight daylight/nighttime shares for a weekday where
# Python's datetime.weekday() is Monday=0. The day starts with the weekday
# lord, the night with the lord of the fifth weekday counted from it; the
# shares then run round the eight-slot cycle Sun..Saturn, lordless (PyJHora
# const.day_rulers / night_rulers). Saturn's share in every row equals
# SATURN_PART_START. The Wednesday-Saturday night rows used to start one lord
# early (Saturday night with Mars instead of Mercury), so Maandi and the four
# other time-segment points sat in the wrong share on those nights (BUG-1565).
DAY_RULERS = (
(1, 2, 3, 4, 5, 6, -1, 0),
(2, 3, 4, 5, 6, -1, 0, 1),
(3, 4, 5, 6, -1, 0, 1, 2),
(4, 5, 6, -1, 0, 1, 2, 3),
(5, 6, -1, 0, 1, 2, 3, 4),
(6, -1, 0, 1, 2, 3, 4, 5),
(0, 1, 2, 3, 4, 5, 6, -1),
)
NIGHT_RULERS = (
(5, 6, -1, 0, 1, 2, 3, 4),
(6, -1, 0, 1, 2, 3, 4, 5),
(0, 1, 2, 3, 4, 5, 6, -1),
(1, 2, 3, 4, 5, 6, -1, 0),
(2, 3, 4, 5, 6, -1, 0, 1),
(3, 4, 5, 6, -1, 0, 1, 2),
(4, 5, 6, -1, 0, 1, 2, 3),
)
TEMPORAL_UPAGRAHAS = {
"Kaala": (0, "middle"),
"Mrityu": (2, "middle"),
"Artha_Praharaka": (3, "middle"),
"Yama_Ghantaka": (4, "middle"),
"Gulika": (6, "begin"),
"Maandi": (6, "middle"),
}
def _sidereal_ascendant(jd_ut: float, lat: float, lon: float, ayanamsa: str = DEFAULT_AYANAMSA_NAME) -> float:
with temporary_ayanamsa(ayanamsa, swe):
_, ascmc = swe.houses_ex(jd_ut, lat, lon, b"P", swe.FLG_SWIEPH | swe.FLG_SIDEREAL)
return float(ascmc[0]) % 360
def calculate_gulika(
moment: datetime,
*,
lat: float,
lon: float,
tz: float,
method: str = "saturn_part_start",
ayanamsa: str = DEFAULT_AYANAMSA_NAME,
) -> dict[str, Any]:
"""Return Gulika from local moment/location using Swiss sunrise and sunset."""
ayanamsa_name = normalize_ayanamsa_name(ayanamsa)
daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz, rising="hindu")
is_day = bool(daynight["is_daytime"])
period = "day" if is_day else "night"
start_jd = daynight["sunrise_jd_ut"] if is_day else daynight["sunset_jd_ut"]
end_jd = daynight["sunset_jd_ut"] if is_day else daynight["next_sunrise_jd_ut"]
start_year, start_month, start_day, _ = swe.revjul(start_jd + float(tz) / 24.0)
period_weekday = datetime(int(start_year), int(start_month), int(start_day)).weekday()
if method == "saturn_part_start":
part_index = SATURN_PART_START[period_weekday][period]
segment_fraction = part_index / 8.0
ghatika_end = None
elif method == "legacy_ghatika_end":
part_index = None
ghatika_end = GHATIKA_END[period_weekday][period]
segment_fraction = ghatika_end / 30.0
else:
raise ValueError("method must be saturn_part_start or legacy_ghatika_end")
segment_jd = start_jd + (end_jd - start_jd) * segment_fraction
longitude = _sidereal_ascendant(segment_jd, float(lat), float(lon), ayanamsa_name)
return {
"scope": "gulika_prasna_marga",
"status": "partial",
"longitude": round(longitude, 6),
"sign_idx": int(longitude / 30) % 12,
"degree_in_sign": round(longitude % 30, 6),
"period": period,
"weekday": period_weekday,
"moment_weekday": moment.weekday(),
"method": method,
"part_index": part_index,
"segment_fraction": segment_fraction,
"ghatika_end": ghatika_end,
"segment_jd_ut": segment_jd,
"daynight_evidence": daynight,
"ayanamsa": ayanamsa_name,
"ayanamsa_display": ayanamsa_display_name(ayanamsa_name),
"rule_source": "references/prashna-complete-guide.md#3.5",
"boundary": "PyJHora-aligned Saturn-part-start variant. Numeric parity is evidence only and does not enable Prashna verdict layers.",
}
def _temporal_upagraha(
moment: datetime,
*,
lat: float,
lon: float,
tz: float,
planet_index: int,
segment_part: str,
ayanamsa: str = DEFAULT_AYANAMSA_NAME,
) -> dict[str, Any]:
ayanamsa_name = normalize_ayanamsa_name(ayanamsa)
daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz, rising="hindu")
is_day = bool(daynight["is_daytime"])
period = "day" if is_day else "night"
start_jd = daynight["sunrise_jd_ut"] if is_day else daynight["sunset_jd_ut"]
end_jd = daynight["sunset_jd_ut"] if is_day else daynight["next_sunrise_jd_ut"]
start_year, start_month, start_day, _ = swe.revjul(start_jd + float(tz) / 24.0)
weekday = datetime(int(start_year), int(start_month), int(start_day)).weekday()
ruler_table = DAY_RULERS if is_day else NIGHT_RULERS
part_index = ruler_table[weekday].index(planet_index)
fraction = (part_index + (0.5 if segment_part == "middle" else 0.0)) / 8.0
segment_jd = start_jd + (end_jd - start_jd) * fraction
longitude = _sidereal_ascendant(segment_jd, float(lat), float(lon), ayanamsa_name)
return {
"longitude": round(longitude, 6),
"sign_idx": int(longitude / 30) % 12,
"degree_in_sign": round(longitude % 30, 6),
"period": period,
"weekday": weekday,
"planet_index": planet_index,
"part_index": part_index,
"segment_part": segment_part,
"segment_fraction": fraction,
"segment_jd_ut": segment_jd,
}
def _solar_upagrahas(sun_longitude: float) -> dict[str, dict[str, Any]]:
sun = float(sun_longitude) % 360.0
longitudes = {
"Dhuma": (sun + 133.0 + 20.0 / 60.0) % 360.0,
"Vyatipata": (360.0 - ((sun + 133.0 + 20.0 / 60.0) % 360.0)) % 360.0,
}
longitudes["Parivesha"] = (longitudes["Vyatipata"] + 180.0) % 360.0
longitudes["Indrachapa"] = (360.0 - longitudes["Parivesha"]) % 360.0
longitudes["Upaketu"] = (sun - 30.0) % 360.0
return {
name: {
"longitude": round(longitude, 6),
"sign_idx": int(longitude / 30) % 12,
"degree_in_sign": round(longitude % 30, 6),
"source": "solar_longitude_formula",
}
for name, longitude in longitudes.items()
}
def calculate_upagrahas(
moment: datetime,
*,
lat: float,
lon: float,
tz: float,
sun_longitude: float,
ayanamsa: str = DEFAULT_AYANAMSA_NAME,
) -> dict[str, Any]:
"""Calculate the D1 Upagraha packet using local Swiss Ephemeris inputs."""
ayanamsa_name = normalize_ayanamsa_name(ayanamsa)
temporal = {
name: _temporal_upagraha(
moment,
lat=lat,
lon=lon,
tz=tz,
planet_index=planet_index,
segment_part=segment_part,
ayanamsa=ayanamsa_name,
)
for name, (planet_index, segment_part) in TEMPORAL_UPAGRAHAS.items()
}
return {
"status": "computed_pending_validation",
"raw": {**temporal, **_solar_upagrahas(sun_longitude)},
"ayanamsa": ayanamsa_name,
"ayanamsa_display": ayanamsa_display_name(ayanamsa_name),
"scope": "d1_only",
"boundary": (
"Local D1 formulas are retained with segment evidence. Public PyJHora observations are "
"used only for calibration; D-N Upagraha mapping and external numeric closure remain pending."
),
}