Files
Jyotisha/scripts/gulika.py
ClaudeandClaude Opus 5.5 1ae97ce727 fix(upagraha): BPHS eighths as the main part-ruler profile per astrologer Q8 (BUG-1721)
8th part lordless; night starts from the lord of the 5th weekday; Gulika at
the start of Saturn's part; weekday from the period start. PVR Table 10 /
PyJHora order kept as a named alternative profile; Maandi midpoint marked a
separate method not approved by the ruling. Kaala / Mrityu / Artha Praharaka /
Yama Ghantaka move on 12 of 14 rows; Saturn's part unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-07 23:46:48 +08:00

309 lines
14 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},
}
# Planet indices follow the local seven-graha ordering: Sun (0) through
# Saturn (6); -1 is the lordless share. Python weekday: Monday=0.
#
# BUG-1721 (astrologer r40 review item 3 and her ruling Q8, 2026-11-07): one
# rule produces every table below, so the ruler rows and Saturn's share can no
# longer disagree. Main profile = BPHS day / night eighths:
# * Day: local sunrise -> sunset in 8 equal parts; the first 7 belong to the
# weekday lord and then the next lords in weekday order (Sun..Saturn).
# * Night: local sunset -> next sunrise in 8 parts; the first belongs to the
# lord of the 5th weekday counted from the day's weekday (Monday -> Friday
# -> Venus), then weekday order.
# * The 8th part has no lord. A lordless part is NOT inserted each time
# Saturn is reached.
# * Gulika = ascendant at the START of Saturn's part.
# * Midnight -> sunrise belongs to the previous day's night: the weekday is
# taken from the date of the period start (sunset), never the civil date.
# Ruling source (her Q8): Suresh Chandra Mishra, "Significance of Gulika",
# PDF pp. 2-3 (https://argala.ru/pdf-files/significanceofgulika.pdf), quoting
# BPHS; the BPHS verse number has not been page-checked and is not given here.
# Same rule in her references: references/book_corpus/books/BPHS读书笔记.md
# ("把这一个白天的时间等分成八段,其中第八段无主星,第一段的主星是这一天按星期几算的主星
# ……然后按日月火水木金土的顺序排列。土星主管的时间段就是 Gulika。……夜晚第一段的主星是从当天起
# 往后数到第五天的主星(比如当天是星期一,则取星期五的主星)"), and the Portuguese
# BPHS (Kerr 2019) ch. 3 in the same folder ("A 8ª porção será o regente
# menor ... iniciando pelo regente da 5ª casa contada à partir do Varesha").
# Saturn's part, 1-based day/night: Sun 7/3, Mon 6/2, Tue 5/1, Wed 4/7,
# Thu 3/6, Fri 2/5, Sat 1/4.
# Separate named profile, not the main one: PVR_Integrated_Approach.md §4.3
# Table 10 / PyJHora const.day_rulers / night_rulers put the lordless part
# right after Saturn's and continue with the Sun. Saturn's part is the same;
# Kaala, Mrityu, Artha Praharaka and Yama Ghantaka move on 12 of 14 rows.
# 原值(BUG-1565 起):产品默认 = PVR / PyJHora 口径;新值:默认 = BPHS 第八段无主;
# 原因:占星师 2026-11-07 Q8 裁定。
# Her scripts/gulika.py NIGHT_RULERS Wednesday-Saturday rows start one share
# late (Wednesday night opens with the lordless share), which puts Saturn in
# the 8th part - against BPHS, PVR, PyJHora and her own SATURN_PART_START.
LORDLESS_EIGHTH = "eighth" # BPHS (main)
LORDLESS_AFTER_SATURN = "after_saturn" # PVR Table 10, PyJHora
SHARE_RULER_PROFILES = {
"bphs_eighth_lordless": LORDLESS_EIGHTH,
"pvr_table10_pyjhora_lordless_after_saturn": LORDLESS_AFTER_SATURN,
}
DEFAULT_SHARE_RULER_PROFILE = "bphs_eighth_lordless"
SHARE_RULER_SOURCE = (
"BPHS day/night eighths as ruled by the astrologer (Q8, 2026-11-07), citing Suresh Chandra Mishra, "
"'Significance of Gulika', PDF pp. 2-3, quoting BPHS (verse number not page-checked)"
)
_RULER_CYCLE = (0, 1, 2, 3, 4, 5, 6, -1)
def weekday_lord(weekday: int) -> int:
"""Python weekday (Monday=0) -> graha index of the day lord (Monday -> Moon)."""
return (int(weekday) + 1) % 7
def share_rulers(weekday: int, period: str, lordless: str = LORDLESS_EIGHTH) -> tuple[int, ...]:
"""The lords of the eight day or night parts of ``weekday`` (Monday=0)."""
if period not in ("day", "night"):
raise ValueError("period must be day or night")
first = weekday_lord(weekday) if period == "day" else (weekday_lord(weekday) + 4) % 7
if lordless == LORDLESS_EIGHTH:
return tuple((first + offset) % 7 for offset in range(7)) + (-1,)
if lordless != LORDLESS_AFTER_SATURN:
raise ValueError("lordless must be eighth or after_saturn")
start = _RULER_CYCLE.index(first)
return tuple(_RULER_CYCLE[(start + offset) % 8] for offset in range(8))
def ruler_tables(profile: str = DEFAULT_SHARE_RULER_PROFILE) -> tuple[tuple, tuple]:
lordless = SHARE_RULER_PROFILES[profile]
return (tuple(share_rulers(w, "day", lordless) for w in range(7)),
tuple(share_rulers(w, "night", lordless) for w in range(7)))
DAY_RULERS, NIGHT_RULERS = ruler_tables()
DAY_RULERS_PVR, NIGHT_RULERS_PVR = ruler_tables("pvr_table10_pyjhora_lordless_after_saturn")
# Saturn's zero-based part, read off the same rows.
SATURN_PART_START = {
weekday: {"day": DAY_RULERS[weekday].index(6), "night": NIGHT_RULERS[weekday].index(6)}
for weekday in range(7)
}
# #166 Q15 (BUG-1594, astrologer 2026-11-04): the two Saturn-part points are the
# raw data; which one is called Gulika and which Maandi depends on the named
# method. This product keeps the PyJHora-compatible mapping (Gulika = start,
# Maandi = midpoint). PVR's textbook §4.3 maps them the other way round, so the
# current mapping must never be labelled "PVR".
GULIKA_MAANDI_METHOD = "pyjhora_compatible_gulika_start_maandi_midpoint"
GULIKA_MAANDI_METHODS = {
GULIKA_MAANDI_METHOD: {"Gulika": "saturn_segment_start", "Maandi": "saturn_segment_midpoint"},
"pvr_textbook_4_3": {"Gulika": "saturn_segment_midpoint", "Maandi": "saturn_segment_start"},
}
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,
profile: str = DEFAULT_SHARE_RULER_PROFILE,
) -> 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()
day_table, night_table = ruler_tables(profile)
ruler_table = day_table if is_day else night_table
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,
"share_ruler_profile": profile,
}
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()
}
# The PVR / PyJHora profile, kept as a separate named method; Saturn's part is identical,
# so only the four other time-segment points are listed.
alternative = {
name: _temporal_upagraha(
moment, lat=lat, lon=lon, tz=tz, planet_index=planet_index, segment_part=segment_part,
ayanamsa=ayanamsa_name, profile="pvr_table10_pyjhora_lordless_after_saturn",
)
for name, (planet_index, segment_part) in TEMPORAL_UPAGRAHAS.items() if planet_index != 6
}
saturn_points = {
"saturn_segment_start": {k: temporal["Gulika"][k] for k in ("longitude", "sign_idx", "degree_in_sign", "segment_fraction", "segment_jd_ut")},
"saturn_segment_midpoint": {k: temporal["Maandi"][k] for k in ("longitude", "sign_idx", "degree_in_sign", "segment_fraction", "segment_jd_ut")},
}
return {
"status": "computed_pending_validation",
"raw": {**temporal, **_solar_upagrahas(sun_longitude)},
"saturn_segment_points": saturn_points,
"gulika_maandi_method": GULIKA_MAANDI_METHOD,
"gulika_maandi_mapping": GULIKA_MAANDI_METHODS[GULIKA_MAANDI_METHOD],
"gulika_maandi_note": "Not the PVR textbook mapping: PVR §4.3 takes Gulika at the midpoint and Maandi at the start.",
# Her Q8: the ruling's source treats Maandi as a synonym of Gulika (start of Saturn's part).
# The separate Maandi midpoint is a distinct, PyJHora-compatible method with no named source
# in that ruling; it is not approved by it.
"maandi_midpoint_status": "separate_method_not_approved_by_q8_ruling",
"share_ruler_profile": DEFAULT_SHARE_RULER_PROFILE,
"share_ruler_source": SHARE_RULER_SOURCE,
"alternative_share_ruler_profiles": {"pvr_table10_pyjhora_lordless_after_saturn": alternative},
"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."
),
}