Files
Jyotisha/scripts/research_timing/cycles.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

316 lines
13 KiB
Python

"""Period arithmetic copied from the named research profiles.
No ephemeris here. Depth stops where the source says there is no child rule.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
try:
from jyotish_engine import DASHA_ORDER, DASHA_YEARS
except ImportError: # pragma: no cover - package import
from scripts.jyotish_engine import DASHA_ORDER, DASHA_YEARS
TITHI_LORDS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu")
TITHI_SEQUENCE = ("Sun", "Moon", "Mars", "Mercury", "Saturn", "Jupiter", "Rahu", "Venus")
TITHI_YEARS = (6, 15, 8, 17, 10, 19, 12, 21)
TITHI_FORWARD = "forward"
TITHI_REVERSE = "reverse"
KARANA_LORDS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
KAALA_LORDS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
SIGNS = (
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
)
SIGN_LORDS = {
"Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Cancer": "Moon",
"Leo": "Sun", "Virgo": "Mercury", "Libra": "Venus", "Scorpio": "Mars",
"Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn", "Pisces": "Jupiter",
}
def _aware(epoch: datetime) -> datetime:
if epoch.utcoffset() is None:
raise ValueError("epoch requires a UTC offset")
return epoch.astimezone(timezone.utc)
def tithi_seed(tithi_num: int) -> str:
if not 1 <= int(tithi_num) <= 30:
raise ValueError("tithi number must be 1..30")
if int(tithi_num) == 30:
return "Rahu"
return TITHI_LORDS[((int(tithi_num) - 1) % 15) % 8]
def tithi_ashtottari(
epoch: datetime,
tithi_num: int,
remaining_fraction: float,
span_days: float,
direction: str,
*,
natal: bool,
) -> dict:
"""108-part cycle. Annual span is 300..400 days. Natal span is one 108-year cycle. No third level."""
if direction not in (TITHI_FORWARD, TITHI_REVERSE):
raise ValueError("tithi direction must be forward or reverse")
if not 0 <= remaining_fraction <= 1:
raise ValueError("remaining fraction must be 0..1")
low, high = (108 * 350, 108 * 370) if natal else (300, 400)
if not low <= span_days <= high:
raise ValueError("span is outside the selected calendar")
stamp = _aware(epoch)
seed = tithi_seed(tithi_num)
seed_index = TITHI_SEQUENCE.index(seed)
unit = span_days / 108.0
offset = -TITHI_YEARS[seed_index] * (1 - remaining_fraction) * unit
window = span_days if not natal else span_days + offset
step = 1 if direction == TITHI_FORWARD else -1
periods = []
count = 8 if natal else 9
for number in range(count):
index = (seed_index + number) % 8
end = offset + TITHI_YEARS[index] * unit
visible_left, visible_right = max(offset, 0.0), min(end, window)
if visible_right - visible_left > 1e-10:
row = _span_row(stamp, TITHI_SEQUENCE[index], f"md:{number + 1}", offset, end, visible_left, visible_right)
children = []
child_start = offset
for child_number in range(8):
child_index = (index + step * (child_number + 1)) % 8
child_end = end if child_number == 7 else child_start + (end - offset) * TITHI_YEARS[child_index] / 108.0
child_left, child_right = max(child_start, visible_left), min(child_end, visible_right)
if child_right - child_left > 1e-10:
children.append(_span_row(
stamp, TITHI_SEQUENCE[child_index], f"md:{number + 1}:ad:{child_number + 1}",
child_start, child_end, child_left, child_right,
))
child_start = child_end
row["antardashas"] = children
periods.append(row)
offset = end
return {
"status": "calculated",
"seed_lord": seed,
"tithi_num": int(tithi_num),
"remaining_fraction": remaining_fraction,
"span_days": span_days,
"direction": direction,
"periods": periods,
"pd_status": "omitted",
"reason_en": "No explicit third-level rule in the admitted tutorial.",
"reason_zh": "收录的教程没有写明第三层。",
}
def yoga_vimshottari(epoch: datetime, sun_moon_sum: float, span_days: float, *, natal: bool, depth: int = 3) -> dict:
if depth not in (1, 2, 3):
raise ValueError("yoga depth must be 1, 2 or 3")
if not 0 <= sun_moon_sum < 360:
raise ValueError("yoga angle must be in [0, 360)")
low, high = (120 * 350, 120 * 370) if natal else (300, 400)
if not low <= span_days <= high:
raise ValueError("yoga span is outside the selected calendar")
stamp = _aware(epoch)
coordinate = sun_moon_sum / (360.0 / 27.0)
yoga_index = int(coordinate)
elapsed_fraction = coordinate - yoga_index
seed_index = (yoga_index + DASHA_ORDER.index("Saturn")) % 9
seed = DASHA_ORDER[seed_index]
unit = span_days / 120.0
full_start_day = -elapsed_fraction * DASHA_YEARS[seed] * unit
# Natal shows one clipped 120-year cycle. Annual adds the returning seed fragment.
count = 9 if natal else 10
periods = []
cursor = full_start_day
window_end = span_days if not natal else span_days + full_start_day
for number in range(count):
lord = DASHA_ORDER[(seed_index + number) % 9]
full_end = cursor + DASHA_YEARS[lord] * unit
left, right = max(cursor, 0.0), min(full_end, window_end if natal else span_days)
if right - left > 1e-8:
row = _day_row(stamp, lord, f"md:{number + 1}", cursor, full_end, left, right)
if depth > 1:
row["antardashas"] = _vimshottari_children(stamp, row, depth, "ad")
periods.append(row)
cursor = full_end
return {
"status": "calculated",
"seed_lord": seed,
"yoga_index": yoga_index + 1,
"remaining_fraction": 1 - elapsed_fraction,
"span_days": span_days,
"depth": depth,
"periods": periods,
}
def nakshatra_tribhagi(epoch: datetime, moon_longitude: float, span_days: float, depth: int = 3) -> dict:
if depth not in (1, 2, 3):
raise ValueError("nakshatra depth must be 1, 2 or 3")
if not 0 <= moon_longitude < 360 or not 300 <= span_days <= 400:
raise ValueError("nakshatra longitude or span is out of range")
stamp = _aware(epoch)
coordinate = moon_longitude / (360.0 / 27.0)
seed_star = int(coordinate)
fraction = coordinate - seed_star
seed_index = seed_star % 9
seed = DASHA_ORDER[seed_index]
unit = span_days / 120.0
cursor = -fraction * (DASHA_YEARS[seed] / 3.0) * unit
periods = []
for number in range(28):
lord = DASHA_ORDER[(seed_index + number) % 9]
full_end = cursor + (DASHA_YEARS[lord] / 3.0) * unit
left, right = max(cursor, 0.0), min(full_end, span_days)
if right - left > 1e-8:
row = _day_row(stamp, lord, f"tribhagi:md:{number + 1}", cursor, full_end, left, right)
row["cycle_index"] = number // 9 + 1
if depth > 1:
row["antardashas"] = _tribhagi_children(stamp, row, depth, "ad")
periods.append(row)
cursor = full_end
return {"status": "calculated", "seed_lord": seed, "span_days": span_days, "depth": depth, "periods": periods}
def _vimshottari_children(stamp: datetime, parent: dict, depth: int, level: str) -> list[dict]:
start = parent["full_start_days"]
span = parent["full_end_days"] - start
first = DASHA_ORDER.index(parent["lord"])
rows = []
for index in range(9):
lord = DASHA_ORDER[(first + index) % 9]
end = parent["full_end_days"] if index == 8 else start + span * DASHA_YEARS[lord] / 120.0
left, right = max(start, parent["start_days"]), min(end, parent["end_days"])
if right - left > 1e-8:
row = _day_row(stamp, lord, f"{parent['key']}:{level}:{index + 1}", start, end, left, right)
if level == "ad" and depth == 3:
row["pratyantardashas"] = _vimshottari_children(stamp, row, depth, "pd")
rows.append(row)
start = end
return rows
def _tribhagi_children(stamp: datetime, parent: dict, depth: int, level: str) -> list[dict]:
start = parent["full_start_days"]
span = parent["full_end_days"] - start
first = DASHA_ORDER.index(parent["lord"])
rows = []
for index in range(9):
lord = DASHA_ORDER[(first + index) % 9]
end = parent["full_end_days"] if index == 8 else start + span * (DASHA_YEARS[lord] / 3.0) / 40.0
left, right = max(start, parent["start_days"]), min(end, parent["end_days"])
if right - left > 1e-8:
row = _day_row(stamp, lord, f"{parent['key']}:{level}:{index + 1}", start, end, left, right)
if level == "ad" and depth == 3:
row["pratyantardashas"] = _tribhagi_children(stamp, row, depth, "pd")
rows.append(row)
start = end
return rows
def karana_year(epoch: datetime, lord: str, remaining_fraction: float, span_days: float) -> dict:
if lord not in KARANA_LORDS:
raise ValueError("karana seed must be Sun through Saturn")
if not 0 <= remaining_fraction <= 1 or not 300 <= span_days <= 400:
raise ValueError("karana fraction or span is out of range")
stamp = _aware(epoch)
seed = KARANA_LORDS.index(lord)
whole = span_days / 7.0
offset = -(1 - remaining_fraction) * whole
periods = []
for index in range(8):
start = offset + index * whole
end = offset + (index + 1) * whole
left, right = max(0.0, start), min(span_days, end)
if right > left:
periods.append(_day_row(stamp, KARANA_LORDS[(seed + index) % 7], f"karana:md:{index + 1}", start, end, left, right))
return {
"status": "calculated",
"seed_lord": lord,
"remaining_fraction": remaining_fraction,
"span_days": span_days,
"periods": periods,
"pd_status": "blocked",
"pd_reason": "The research passage does not supply child periods",
}
def remap_angular(periods: list[dict], clock, return_jd: float) -> list[dict]:
"""Replace coordinate-day offsets with elongation-clock instants. Tree shape stays."""
def convert(row: dict) -> dict:
out = dict(row)
for key in ("start_days", "end_days"):
solved = clock.solve(row[key])
out[key.replace("_days", "_jd")] = solved
out["start"] = _iso(out["start_jd"])
out["end"] = _iso(out["end_jd"])
for child_key in ("antardashas", "pratyantardashas"):
if child_key in row:
out[child_key] = [convert(child) for child in row[child_key]]
return out
del return_jd
return [convert(row) for row in periods]
def kaala_from_segment(epoch: datetime, segment_start: datetime, segment_end: datetime, year_days: float) -> dict:
start = _aware(epoch)
if not segment_start <= start < segment_end:
raise ValueError("birth is outside the selected solar segment")
fraction = (start - segment_start).total_seconds() / (segment_end - segment_start).total_seconds()
cycles = (120.0 * fraction, 120.0 * (1.0 - fraction))
periods = []
elapsed = 0.0
for cycle, years in enumerate(cycles, start=1):
cycle_start = elapsed
for ordinal, lord in enumerate(KAALA_LORDS, start=1):
left = cycle_start + years * (ordinal - 1) * ordinal / 90.0
right = cycle_start + years * ordinal * (ordinal + 1) / 90.0
periods.append({
"lord": lord,
"cycle": cycle,
"years": years * ordinal / 45.0,
"start": (start + timedelta(days=left * year_days)).isoformat(),
"end": (start + timedelta(days=right * year_days)).isoformat(),
"level": "MD",
})
elapsed = cycle_start + years
return {
"status": "calculated",
"elapsed_fraction": fraction,
"periods": periods,
"pd_status": "blocked",
"pd_reason": "The Kaala note does not prescribe child periods",
}
def _span_row(stamp, lord, key, full_start, full_end, left, right) -> dict:
row = _day_row(stamp, lord, key, full_start, full_end, left, right)
return row
def _day_row(stamp: datetime, lord: str, key: str, full_start: float, full_end: float, left: float, right: float) -> dict:
return {
"key": key,
"lord": lord,
"level": "PD" if ":pd:" in key else "AD" if ":ad:" in key else "MD",
"full_start_days": full_start,
"full_end_days": full_end,
"start_days": left,
"end_days": right,
"start": (stamp + timedelta(days=left)).isoformat(),
"end": (stamp + timedelta(days=right)).isoformat(),
}
def _iso(jd: float) -> str:
try:
from research_timing.search import jd_to_utc
except ImportError: # pragma: no cover - package import
from scripts.research_timing.search import jd_to_utc
return jd_to_utc(jd).isoformat()