研究章在渲染时计算,不写入资料包。四种星座大运次小运和 Kalachakra 第三层不改原来的主运、子运。D81、D108、D144 只留在研究章。 自测:test_research_timing、星座大运、域读取、卫生预算、真恒星、jhora 扫描、API 增长、六章标题、英文版。三张虚构盘中英出稿都低于 180 秒。PyJHora 已导入但未做逐层对照。 Co-Authored-By: Grok (agent loop)
316 lines
13 KiB
Python
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()
|