研究章在渲染时计算,不写入资料包。四种星座大运次小运和 Kalachakra 第三层不改原来的主运、子运。D81、D108、D144 只留在研究章。 自测:test_research_timing、星座大运、域读取、卫生预算、真恒星、jhora 扫描、API 增长、六章标题、英文版。三张虚构盘中英出稿都低于 180 秒。PyJHora 已导入但未做逐层对照。 Co-Authored-By: Grok (agent loop)
428 lines
18 KiB
Python
428 lines
18 KiB
Python
"""Research dasha arithmetic that does not touch the ephemeris.
|
|
|
|
Longitude values are supplied by the caller from this repository's chart
|
|
function. Child levels stop where the written source stops.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
try:
|
|
from chaturshitisama_dasha import DASHA_SEQUENCE
|
|
from muhurta import calc_karana
|
|
from narayana_dasha import _NARAYANA_DIGNITY, calc_narayana_mahadasha
|
|
from varga import SIGN_LORDS, SIGNS, calc_varga
|
|
except ImportError: # pragma: no cover - package import
|
|
from scripts.chaturshitisama_dasha import DASHA_SEQUENCE
|
|
from scripts.muhurta import calc_karana
|
|
from scripts.narayana_dasha import _NARAYANA_DIGNITY, calc_narayana_mahadasha
|
|
from scripts.varga import SIGN_LORDS, SIGNS, calc_varga
|
|
|
|
JULENE_LORDS = {
|
|
"Bava": "Sun", "Balava": "Moon", "Kaulava": "Mars", "Taitila": "Mercury",
|
|
"Garija": "Jupiter", "Vanija": "Venus", "Vishti": "Saturn",
|
|
"Shakuni": "Rahu", "Chatushpada": "Ketu", "Naga": "Rahu", "Kimstughna": "Ketu",
|
|
}
|
|
PVR_MOVABLE = {
|
|
"Bava": "Sun", "Balava": "Moon", "Kaulava": "Mars", "Taitila": "Mercury",
|
|
"Garija": "Jupiter", "Vanija": "Venus", "Vishti": "Saturn",
|
|
}
|
|
PVR_FIXED_HOUSES = {"Shakuni": 1, "Chatushpada": 4, "Naga": 7, "Kimstughna": 10}
|
|
SAPTAVARGA = (1, 2, 3, 7, 9, 12, 30)
|
|
SAPTAVARGA_BODIES = ("Lagna", "Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
|
|
RESEARCH_DIVISIONS = (81, 108, 144)
|
|
CLASSICAL = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
|
|
|
|
|
|
def _aware(epoch: datetime) -> datetime:
|
|
if epoch.utcoffset() is None:
|
|
raise ValueError("epoch requires a UTC offset")
|
|
return epoch.astimezone(timezone.utc)
|
|
|
|
|
|
def _at(epoch: datetime, years: float, year_days: float) -> str:
|
|
return (_aware(epoch) + timedelta(days=float(years) * float(year_days))).isoformat()
|
|
|
|
|
|
def karana_seed(sun: float, moon: float, ascendant: float | None, profile: str) -> dict:
|
|
"""Julene table, or PVR movable lords plus quadrant lords for the four fixed names."""
|
|
if profile not in ("julene", "pvr"):
|
|
raise ValueError("karana lord profile must be julene or pvr")
|
|
found = calc_karana(float(sun), float(moon))
|
|
name = found["karana"]
|
|
phase = (float(moon) - float(sun)) % 360.0
|
|
if profile == "julene":
|
|
lord = JULENE_LORDS.get(name)
|
|
else:
|
|
lord = PVR_MOVABLE.get(name)
|
|
if lord is None and name in PVR_FIXED_HOUSES:
|
|
if ascendant is None:
|
|
return {"status": "omitted", "karana": name, "reason_en": "The quadrant lord needs the ascendant at the return.", "reason_zh": "象限主星需要返照时刻的上升点,这次没有算到。"}
|
|
house = PVR_FIXED_HOUSES[name]
|
|
sign = SIGNS[(int(float(ascendant) // 30) + house - 1) % 12]
|
|
lord = SIGN_LORDS[sign]
|
|
if lord in ("Rahu", "Ketu") or lord is None:
|
|
return {
|
|
"status": "omitted",
|
|
"karana": name,
|
|
"lord": lord,
|
|
"reason_en": "This karana lord is a node, so the seven-planet cycle is not listed.",
|
|
"reason_zh": "这个迦那的主星是交点,七曜周期这里不列表。",
|
|
}
|
|
return {
|
|
"status": "calculated",
|
|
"karana": name,
|
|
"lord": lord,
|
|
"remaining_fraction": 1.0 - (phase % 6.0) / 6.0,
|
|
}
|
|
|
|
|
|
def saptavarga_lords(longitudes: dict[str, float], ascendant: float) -> list[dict]:
|
|
points = {"Lagna": float(ascendant), **{name: float(longitudes[name]) for name in SAPTAVARGA_BODIES if name != "Lagna"}}
|
|
rows = []
|
|
for division in SAPTAVARGA:
|
|
for body, longitude in points.items():
|
|
if division == 1:
|
|
sign = SIGNS[int(longitude // 30) % 12]
|
|
degree = longitude % 30.0
|
|
lord = SIGN_LORDS[sign]
|
|
else:
|
|
found = calc_varga(longitude, division)
|
|
sign = found["sign"]
|
|
degree = found["degree_in_sign"]
|
|
lord = found["lord"]
|
|
rows.append({
|
|
"division": division,
|
|
"body": body,
|
|
"sign": sign,
|
|
"degree": degree,
|
|
"lord": lord,
|
|
})
|
|
return rows
|
|
|
|
|
|
def research_vargas(longitudes: dict[str, float], ascendant: float) -> list[dict]:
|
|
points = {"Lagna": float(ascendant), **{name: float(longitudes[name]) for name in CLASSICAL}}
|
|
rows = []
|
|
for division in RESEARCH_DIVISIONS:
|
|
for body, longitude in points.items():
|
|
found = calc_varga(longitude, division)
|
|
rows.append({
|
|
"division": division,
|
|
"body": body,
|
|
"sign": found["sign"],
|
|
"degree": found["degree_in_sign"],
|
|
"lord": found["lord"],
|
|
})
|
|
try:
|
|
from divisional_charts_extended import DivisionalChartsCalculator
|
|
except ImportError: # pragma: no cover - package import
|
|
from scripts.divisional_charts_extended import DivisionalChartsCalculator
|
|
calculator = DivisionalChartsCalculator()
|
|
for body, longitude in points.items():
|
|
found = calculator.calc_custom_varga(float(longitude), 150)
|
|
rows.append({
|
|
"division": 150,
|
|
"body": body,
|
|
"sign": found.get("sign"),
|
|
"degree": found.get("degree"),
|
|
"lord": SIGN_LORDS.get(found.get("sign"), ""),
|
|
})
|
|
return rows
|
|
|
|
|
|
def buddhi_gati(lagna_sign: int, longitudes: dict[str, float], epoch: datetime, year_days: float) -> dict:
|
|
"""One nine-planet pass. The Atri passage does not name child periods."""
|
|
planets = set(_NARAYANA_DIGNITY) | {"Rahu", "Ketu"}
|
|
if set(longitudes) < planets:
|
|
raise ValueError("Buddhi Gati needs all nine longitudes")
|
|
start = _aware(epoch)
|
|
fourth = (int(lagna_sign) + 3) % 12
|
|
order = sorted(planets, key=lambda name: ((int(longitudes[name] // 30) - fourth) % 12, longitudes[name] % 30))
|
|
periods = []
|
|
elapsed = 0.0
|
|
for ordinal, planet in enumerate(order):
|
|
tied = sorted(name for name in planets if longitudes[name] == longitudes[planet])
|
|
if len(tied) > 1:
|
|
return {
|
|
"status": "omitted",
|
|
"periods": [],
|
|
"reason_en": "Two planets share a longitude, and the passage does not say which one comes first.",
|
|
"reason_zh": "有两颗星黄经相同,材料没有规定先后,所以这里不列表。",
|
|
}
|
|
sign = int(longitudes[planet] // 30)
|
|
target = (int(lagna_sign) + ordinal) % 12
|
|
base = (target - sign) % 12
|
|
dignity = _NARAYANA_DIGNITY.get(planet)
|
|
correction = 0 if dignity is None else int(sign in dignity["exalted"]) - int(sign in dignity["debilitated"])
|
|
years = base + correction
|
|
if years <= 0:
|
|
return {
|
|
"status": "omitted",
|
|
"periods": [],
|
|
"reason_en": "A duration is not positive, and the passage does not say how to continue.",
|
|
"reason_zh": "有一段年数不是正数,材料没有规定怎么接下去,所以这里不列表。",
|
|
}
|
|
periods.append({
|
|
"lord": planet,
|
|
"level": "MD",
|
|
"start": _at(start, elapsed, year_days),
|
|
"end": _at(start, elapsed + years, year_days),
|
|
})
|
|
elapsed += years
|
|
return {
|
|
"status": "calculated",
|
|
"periods": periods,
|
|
"reason_en": "The Atri passage does not specify child periods.",
|
|
"reason_zh": "Atri 原文没有规定子运和次小运,所以这里只列主运。",
|
|
"pd_status": "omitted",
|
|
}
|
|
|
|
|
|
def kaala_from_events(epoch: datetime, events: list[dict], year_days: float) -> dict:
|
|
"""Two weighted cycles inside the solar sixth that contains birth. No children."""
|
|
start = _aware(epoch)
|
|
parsed = []
|
|
for event in events:
|
|
if event.get("event") not in ("sunrise", "sunset"):
|
|
continue
|
|
parsed.append((event["event"], datetime.fromisoformat(event["datetime_utc"])))
|
|
selected = None
|
|
for index in range(len(parsed) - 3):
|
|
group = parsed[index:index + 4]
|
|
if [item[0] for item in group] != ["sunset", "sunrise", "sunset", "sunrise"]:
|
|
continue
|
|
previous_set, rise, setting, next_rise = [item[1] for item in group]
|
|
if any((right - left).total_seconds() >= 86400 or right <= left for left, right in zip(
|
|
(previous_set, rise, setting), (rise, setting, next_rise),
|
|
)):
|
|
continue
|
|
day = setting - rise
|
|
before_night = rise - previous_set
|
|
after_night = next_rise - setting
|
|
boundaries = [
|
|
rise - before_night / 6,
|
|
rise + day / 6,
|
|
setting - day / 6,
|
|
setting + after_night / 6,
|
|
next_rise - after_night / 6,
|
|
]
|
|
for name, left, right in zip(("dawn", "daytime", "dusk", "nighttime"), boundaries, boundaries[1:]):
|
|
if left <= start < right:
|
|
selected = (name, left, right)
|
|
break
|
|
if selected is not None:
|
|
break
|
|
if selected is None:
|
|
return {
|
|
"status": "omitted",
|
|
"periods": [],
|
|
"reason_en": "Sunrise and sunset around birth do not bracket a solar sixth.",
|
|
"reason_zh": "出生前后的日出日落没有框住一个太阳六分时段,所以这里不列表。",
|
|
}
|
|
try:
|
|
from research_timing.cycles import kaala_from_segment
|
|
except ImportError: # pragma: no cover - package import
|
|
from scripts.research_timing.cycles import kaala_from_segment
|
|
name, left, right = selected
|
|
built = kaala_from_segment(start, left, right, year_days)
|
|
built["segment"] = name
|
|
built["reason_en"] = "The Kaala note does not prescribe child periods."
|
|
built["reason_zh"] = "Kaala 的材料没有规定子运和次小运,所以这里只列主运。"
|
|
built["pd_status"] = "omitted"
|
|
return built
|
|
|
|
|
|
def _sudasa_direction(sign: int, saturn_sign: int, ketu_sign: int) -> tuple[int, str]:
|
|
if saturn_sign == ketu_sign == sign:
|
|
raise ValueError("Saturn and Ketu exceptions meet on the same sign")
|
|
direction = 1 if sign % 2 == 0 else -1
|
|
if saturn_sign == sign:
|
|
return 1, "Saturn keeps the forward order"
|
|
if ketu_sign == sign:
|
|
return -direction, "Ketu reverses the order"
|
|
return direction, ""
|
|
|
|
|
|
def _sudasa_children(sign: int, full_years: float, saturn_sign: int, ketu_sign: int, remaining: float) -> list[dict]:
|
|
direction, _note = _sudasa_direction(sign, saturn_sign, ketu_sign)
|
|
offsets = [group + quadrant * 3 for group in range(3) for quadrant in range(4)]
|
|
full = []
|
|
for index, offset in enumerate(offsets):
|
|
full.append({
|
|
"sign_index": (sign + direction * offset) % 12,
|
|
"start_offset": full_years * index / 12.0,
|
|
"end_offset": full_years * (index + 1) / 12.0,
|
|
})
|
|
if remaining >= 1:
|
|
return full
|
|
elapsed = full_years * (1.0 - remaining)
|
|
clipped = []
|
|
for row in full:
|
|
if row["end_offset"] <= elapsed:
|
|
continue
|
|
clipped.append({
|
|
"sign_index": row["sign_index"],
|
|
"start_offset": max(row["start_offset"], elapsed) - elapsed,
|
|
"end_offset": row["end_offset"] - elapsed,
|
|
})
|
|
return clipped
|
|
|
|
|
|
def sudasa_birth(ascendant: float, longitudes: dict[str, float], epoch: datetime, year_days: float) -> dict:
|
|
"""Sree lagna from PVR 5.7, SJVC15 order, birth children clipped inside the full major."""
|
|
moon = float(longitudes["Moon"])
|
|
sree = (float(ascendant) + (moon * 27.0) % 360.0) % 360.0
|
|
durations = calc_narayana_mahadasha(
|
|
0,
|
|
{name: float(longitudes[name]) for name in CLASSICAL},
|
|
duration_profile="jaimini_odd_footed_dignity_v2",
|
|
dual_lord_profile="legacy_primary_lord_v0",
|
|
)
|
|
by_sign = {int(row["sign_idx"]): row for row in durations}
|
|
sign = int(sree // 30)
|
|
saturn = int(longitudes["Saturn"] // 30)
|
|
ketu = int(longitudes["Ketu"] // 30)
|
|
direction, note = _sudasa_direction(sign, saturn, ketu)
|
|
offsets = [group + quadrant * 3 for group in range(3) for quadrant in range(4)]
|
|
fraction = (30.0 - (sree % 30.0)) / 30.0
|
|
periods = []
|
|
elapsed = 0.0
|
|
start = _aware(epoch)
|
|
for index, offset in enumerate(offsets):
|
|
current = (sign + direction * offset) % 12
|
|
full_years = float(by_sign[current]["years"])
|
|
years = full_years * fraction if index == 0 else full_years
|
|
row = {
|
|
"lord": by_sign[current].get("lord"),
|
|
"sign": SIGNS[current],
|
|
"level": "MD",
|
|
"start": _at(start, elapsed, year_days),
|
|
"end": _at(start, elapsed + years, year_days),
|
|
"antardashas": [],
|
|
}
|
|
try:
|
|
children = _sudasa_children(current, full_years, saturn, ketu, fraction if index == 0 else 1.0)
|
|
except ValueError:
|
|
row["note_en"] = "Saturn and Ketu both occupy this sign, so its child periods are not listed."
|
|
row["note_zh"] = "土星和计都同时落在这个星座,子运这里不列表。"
|
|
children = []
|
|
for child in children:
|
|
child_sign = int(child["sign_index"])
|
|
row["antardashas"].append({
|
|
"sign": SIGNS[child_sign],
|
|
"lord": SIGN_LORDS[SIGNS[child_sign]],
|
|
"level": "AD",
|
|
"start": _at(start, elapsed + child["start_offset"], year_days),
|
|
"end": _at(start, elapsed + child["end_offset"], year_days),
|
|
})
|
|
periods.append(row)
|
|
elapsed += years
|
|
return {
|
|
"status": "calculated",
|
|
"periods": periods,
|
|
"sree_longitude": sree,
|
|
"note_en": note,
|
|
"reason_en": "No third level is provided for this Sudasa reading.",
|
|
"reason_zh": "这一路 Sudasa 没有次小运。",
|
|
"pd_status": "omitted",
|
|
}
|
|
|
|
|
|
def sudasa_confirmed_anchor() -> dict:
|
|
"""No confirmed full major start is stored, so this branch has no table."""
|
|
return {
|
|
"status": "omitted",
|
|
"periods": [],
|
|
"reason_en": "The confirmed-start branch needs a written full major-period start. This report does not have that source, so no table is printed.",
|
|
"reason_zh": "已确认起点这一支需要书面确认的完整主运起点。这次没有这份依据,所以不列表。",
|
|
}
|
|
|
|
|
|
def _varnada_one(lagna: int, hora: int, seed: int, longitudes: dict[str, float], epoch: datetime, year_days: float) -> dict:
|
|
"""Guru Rajesh inclusive count. Child thirds are not established."""
|
|
count_direction = -1 if lagna % 2 else 1
|
|
origin = 11 if count_direction == -1 else 0
|
|
lagna_count = ((lagna - origin) * count_direction) % 12 + 1
|
|
hora_count = ((hora - origin) * count_direction) % 12 + 1
|
|
same = lagna_count % 2 == hora_count % 2
|
|
number = lagna_count + hora_count if same else abs(lagna_count - hora_count)
|
|
varnada = (origin + count_direction * (number - 1)) % 12
|
|
shift = ((varnada - lagna) * (1 if lagna % 2 == 0 else -1)) % 12 + 1
|
|
signs = {name: int(float(longitudes[name]) // 30) for name in CLASSICAL}
|
|
direction = 1 if seed % 2 == 0 else -1
|
|
if any(signs[name] == varnada for name in ("Rahu", "Ketu")):
|
|
direction *= -1
|
|
start = _aware(epoch)
|
|
age = 0.0
|
|
periods = []
|
|
for index in range(12):
|
|
sign = (seed + direction * index) % 12
|
|
step = 1 if sign % 2 == 0 else -1
|
|
target = (sign + step * (shift - 1)) % 12
|
|
lord = SIGN_LORDS[SIGNS[target]]
|
|
duration_direction = 1 if target % 2 == 0 else -1
|
|
distance = ((signs[lord] - target) * duration_direction) % 12
|
|
years = 12 if distance == 0 else 10 if distance == 6 else distance + 1
|
|
children = []
|
|
for child in range(12):
|
|
child_sign = (sign + step * child) % 12
|
|
child_start = age + years * child / 12.0
|
|
child_end = age + years * (child + 1) / 12.0
|
|
children.append({
|
|
"sign": SIGNS[child_sign],
|
|
"lord": SIGN_LORDS[SIGNS[child_sign]],
|
|
"level": "AD",
|
|
"start": _at(start, child_start, year_days),
|
|
"end": _at(start, child_end, year_days),
|
|
})
|
|
periods.append({
|
|
"sign": SIGNS[sign],
|
|
"lord": SIGN_LORDS[SIGNS[sign]],
|
|
"level": "MD",
|
|
"start": _at(start, age, year_days),
|
|
"end": _at(start, age + years, year_days),
|
|
"antardashas": children,
|
|
})
|
|
age += years
|
|
return {"status": "calculated", "periods": periods, "seed_sign": SIGNS[seed]}
|
|
|
|
|
|
def varnada_jss(lagna: int, hora: int, longitudes: dict[str, float], epoch: datetime, year_days: float) -> list[dict]:
|
|
seeds = [lagna] if lagna == hora else [lagna, hora]
|
|
rows = []
|
|
for seed in seeds:
|
|
built = _varnada_one(lagna, hora, seed, longitudes, epoch, year_days)
|
|
built["reason_en"] = "The translation does not establish a third level."
|
|
built["reason_zh"] = "这份译文没有建立次小运,所以这里只到子运。"
|
|
built["pd_status"] = "omitted"
|
|
rows.append(built)
|
|
return rows
|
|
|
|
|
|
def hora_sign_from_events(epoch: datetime, lagna_sign: int, events: list[dict]) -> dict:
|
|
"""Day and night each have twelve horas. The sign origin is the lagna sign, not the exact degree."""
|
|
start = _aware(epoch)
|
|
parsed = []
|
|
for event in events:
|
|
if event.get("event") not in ("sunrise", "sunset"):
|
|
continue
|
|
parsed.append((event["event"], datetime.fromisoformat(event["datetime_utc"])))
|
|
for (name, left), (_, right) in zip(parsed, parsed[1:]):
|
|
if left >= right or not left <= start < right or (right - left).total_seconds() >= 86400:
|
|
continue
|
|
duration = (right - left) // timedelta(microseconds=1)
|
|
elapsed = (start - left) // timedelta(microseconds=1)
|
|
if duration <= 0:
|
|
continue
|
|
quotient, remainder = divmod(elapsed * 12, duration)
|
|
direction = 1 if lagna_sign % 2 == 0 else -1
|
|
sign = (lagna_sign + direction * int(quotient)) % 12
|
|
return {"status": "calculated", "sign_index": sign, "segment": "day" if name == "sunrise" else "night"}
|
|
return {
|
|
"status": "omitted",
|
|
"reason_en": "Sunrise and sunset do not bracket birth, so the hora sign is not listed.",
|
|
"reason_zh": "日出日落没有框住出生时刻,时段上升星座这里不列表。",
|
|
}
|