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
309 lines
14 KiB
Python
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."
|
|
),
|
|
}
|