Files
Jyotisha/scripts/research_timing/families.py
T
jesse-uxandGrok (agent loop) d45e65a9ec 完整数据版补上研究口径的推运、次小运和细分盘(#92)。
研究章在渲染时计算,不写入资料包。四种星座大运次小运和 Kalachakra 第三层不改原来的主运、子运。D81、D108、D144 只留在研究章。

自测:test_research_timing、星座大运、域读取、卫生预算、真恒星、jhora 扫描、API 增长、六章标题、英文版。三张虚构盘中英出稿都低于 180 秒。PyJHora 已导入但未做逐层对照。

Co-Authored-By: Grok (agent loop)
2026-10-23 18:20:44 +08:00

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": "日出日落没有框住出生时刻,时段上升星座这里不列表。",
}