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

475 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Research timing for one chart. Nothing here is written onto the packet.
The in-process cache is keyed by birth inputs so the two report languages
share one ephemeris pass. A failed family keeps its own reason and does not
drop the chapter.
"""
from __future__ import annotations
import logging
import time
from datetime import datetime, timedelta, timezone
import swisseph as swe
try:
from research_timing.cycles import (
TITHI_FORWARD,
TITHI_REVERSE,
karana_year,
nakshatra_tribhagi,
remap_angular,
tithi_ashtottari,
yoga_vimshottari,
)
from research_timing.families import (
buddhi_gati,
hora_sign_from_events,
kaala_from_events,
karana_seed,
research_vargas,
saptavarga_lords,
sudasa_birth,
sudasa_confirmed_anchor,
varnada_jss,
)
from research_timing.frame import SiderealFrame, birth_jd
from research_timing.search import (
ElongationClock,
containing_tithi,
find_returns,
hindu_solar_events,
jd_to_utc,
measure_elongation,
)
except ImportError: # pragma: no cover - package import
from scripts.research_timing.cycles import (
TITHI_FORWARD,
TITHI_REVERSE,
karana_year,
nakshatra_tribhagi,
remap_angular,
tithi_ashtottari,
yoga_vimshottari,
)
from scripts.research_timing.families import (
buddhi_gati,
hora_sign_from_events,
kaala_from_events,
karana_seed,
research_vargas,
saptavarga_lords,
sudasa_birth,
sudasa_confirmed_anchor,
varnada_jss,
)
from scripts.research_timing.frame import SiderealFrame, birth_jd
from scripts.research_timing.search import (
ElongationClock,
containing_tithi,
find_returns,
hindu_solar_events,
jd_to_utc,
measure_elongation,
)
logger = logging.getLogger(__name__)
_CACHE: dict[tuple, dict] = {}
_PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
def research_bundle(chart: dict) -> dict:
"""Compute every research family for one birth. ``chart`` is not retained."""
key = _fingerprint(chart)
cached = _CACHE.get(key)
if cached is not None:
return cached
started = time.perf_counter()
built = _build(chart)
built["elapsed_seconds"] = time.perf_counter() - started
_CACHE[key] = built
return built
def _build(chart: dict) -> dict:
frame = SiderealFrame(chart["ayanamsa"], chart["node_mode"])
natal_jd = birth_jd(chart["year"], chart["month"], chart["day"], chart["hour_decimal"], chart["tz"])
sun, moon = frame.sun_moon(natal_jd)
longitudes = dict(chart["longitudes"])
ascendant = float(chart["ascendant"])
epoch = chart["epoch"]
year_days = float(chart["year_days"])
query = chart["query"]
families: dict[str, dict] = {}
def keep(name: str, producer) -> None:
try:
families[name] = producer()
except (ValueError, RuntimeError, OverflowError, KeyError, TypeError) as exc:
logger.warning("research timing %s failed: %s: %s", name, type(exc).__name__, exc)
families[name] = {
"status": "omitted",
"periods": [],
"reason_en": "This branch did not finish. The runtime log has the cause.",
"reason_zh": "这一支没有算成,原因记在运行日志里。",
}
keep("tithi-natal-forward", lambda: _mark(tithi_ashtottari(
epoch, _tithi_number(sun, moon), _arc_remaining(sun, moon), 108 * year_days, TITHI_FORWARD, natal=True,
), sun, moon))
keep("tithi-natal-reverse", lambda: _mark(tithi_ashtottari(
epoch, _tithi_number(sun, moon), _arc_remaining(sun, moon), 108 * year_days, TITHI_REVERSE, natal=True,
), sun, moon))
keep("yoga-natal", lambda: _mark(yoga_vimshottari(
epoch, (sun + moon) % 360.0, 120 * year_days, natal=True, depth=3,
), sun, moon))
annual = _annual_returns(frame, natal_jd, chart["target_year"])
families["annual-returns"] = annual
_attach_tithi_pairs(keep, frame, natal_jd, annual["returns"]["tithi"], sun, moon)
_attach_pravesha(keep, frame, natal_jd, "yoga", annual["returns"]["yoga"], sun, moon)
_attach_pravesha(keep, frame, natal_jd, "nakshatra", annual["returns"]["nakshatra"], sun, moon)
keep("karana", lambda: _mark(_karana(frame, annual, chart, year_days), sun, moon))
keep("saptavarga", lambda: {"status": "calculated", "rows": saptavarga_lords(longitudes, ascendant), "birth_sun": sun, "birth_moon": moon})
keep("buddhi-gati", lambda: _mark(buddhi_gati(int(ascendant // 30), longitudes, epoch, year_days), sun, moon))
events = hindu_solar_events(epoch, chart["lat"], chart["lon"])
keep("kaala", lambda: _mark(kaala_from_events(epoch, events, year_days), sun, moon))
keep("sudasa-confirmed", lambda: _mark(sudasa_confirmed_anchor(), sun, moon))
keep("sudasa-birth", lambda: _mark(sudasa_birth(ascendant, longitudes, epoch, year_days), sun, moon))
hora = hora_sign_from_events(epoch, int(ascendant // 30), events)
if hora.get("status") != "calculated":
families["varnada"] = _mark(hora, sun, moon)
else:
built = varnada_jss(int(ascendant // 30), int(hora["sign_index"]), longitudes, epoch, year_days)
families["varnada"] = {"status": "calculated", "variants": [_mark(item, sun, moon) for item in built], "birth_sun": sun, "birth_moon": moon}
keep("research-vargas", lambda: {"status": "calculated", "rows": research_vargas(longitudes, ascendant), "birth_sun": sun, "birth_moon": moon})
return {
"birth_sun": sun,
"birth_moon": moon,
"birth_jd": natal_jd,
"query": query.isoformat(),
"target_year": chart["target_year"],
"families": families,
"reference_note_en": (
"The reference instant is noon on the first day of the first listed report month. "
"The packet does not keep a separate today field. "
"Yoga and Nakshatra returns use the report target year. "
"Tithi pairs use that year and the next two years."
),
"reference_note_zh": (
"参考时刻取报告月历第一行的月份,当月 1 日正午。资料包没有另存阅读当天的时刻。"
" Yoga 与 Nakshatra 的回归用这个目标年。 Tithi 配对用该年及后两年。"
),
}
def _attach_tithi_pairs(keep, frame, natal_jd: float, rows: list[dict], sun: float, moon: float) -> None:
"""Every 300–400 day pair. Three target years. Depth stays at two."""
for index, row in enumerate(rows, start=1):
year = int(row["year"])
label = f"tithi-{year}-{index}"
try:
partners = _later_returns(frame, natal_jd, "tithi_pravesha", row["jd_ut"], 0.0, 400.0)
partners = [item for item in partners if 300 <= item["jd_ut"] - row["jd_ut"] <= 400]
except (ValueError, RuntimeError, OverflowError) as exc:
logger.warning("research timing tithi pair failed: %s: %s", type(exc).__name__, exc)
keep(label, lambda: _failed())
continue
if not partners:
keep(label, lambda: {
"status": "omitted",
"periods": [],
"reason_en": "No return falls between 300 and 400 days later, so this year is not listed.",
"reason_zh": "之后 300 到 400 天里没有下一次回归,这一年不列表。",
})
continue
stamp = jd_to_utc(row["jd_ut"])
arc = _arc_remaining(row["sun_longitude"], row["moon_longitude"])
number = _tithi_number(row["sun_longitude"], row["moon_longitude"])
for pair_index, partner in enumerate(partners, start=1):
span = float(partner["jd_ut"] - row["jd_ut"])
for direction, name in ((TITHI_FORWARD, "forward"), (TITHI_REVERSE, "reverse")):
keep(
f"{label}-pair-{pair_index}-arc-{name}",
lambda direction=direction, span=span, stamp=stamp, arc=arc, number=number: _mark(
tithi_ashtottari(stamp, number, arc, span, direction, natal=False), sun, moon,
),
)
keep(
f"{label}-pair-{pair_index}-time-{name}",
lambda direction=direction, span=span, stamp=stamp, number=number, jd=row["jd_ut"]: _mark(
_tithi_elapsed(frame, jd, stamp, number, span, direction), sun, moon,
),
)
def _attach_pravesha(keep, frame, natal_jd: float, technique: str, rows: list[dict], sun: float, moon: float) -> None:
"""Reference-year returns. Each successor in the next 300–400 days stays separate."""
finder = "yoga_pravesha" if technique == "yoga" else "nakshatra_pravesha"
for index, row in enumerate(rows, start=1):
stamp = jd_to_utc(row["jd_ut"])
try:
successors = _later_returns(frame, natal_jd, finder, row["jd_ut"], 300.0, 400.0)
except (ValueError, RuntimeError, OverflowError) as exc:
logger.warning("research timing %s successor failed: %s: %s", technique, type(exc).__name__, exc)
keep(f"{technique}-{index}-successor", lambda: _failed())
keep(f"{technique}-{index}-tithi-year", lambda stamp=stamp, jd=row["jd_ut"]: _mark(
_remap_year(frame, technique, stamp, jd, None, 4320.0, depth=3), sun, moon,
))
continue
if not successors:
keep(f"{technique}-{index}-successor", lambda: {
"status": "omitted",
"periods": [],
"reason_en": "No later return falls between 300 and 400 days, so the successor year is not listed.",
"reason_zh": "之后 300 到 400 天里没有下一次回归,后继年这里不列表。",
})
for pair_index, partner in enumerate(successors, start=1):
keep(
f"{technique}-{index}-successor-{pair_index}",
lambda stamp=stamp, jd=row["jd_ut"], successor=partner["jd_ut"]: _mark(
_remap_year(frame, technique, stamp, jd, successor, measure_elongation(frame, jd, successor), depth=3),
sun, moon,
),
)
keep(
f"{technique}-{index}-tithi-year",
lambda stamp=stamp, jd=row["jd_ut"]: _mark(
_remap_year(frame, technique, stamp, jd, None, 4320.0, depth=3), sun, moon,
),
)
def _tithi_elapsed(frame, jd: float, stamp: datetime, number: int, span: float, direction: str) -> dict:
held = containing_tithi(frame, jd)
return tithi_ashtottari(stamp, number, held["remaining_time_fraction"], span, direction, natal=False)
def _remap_year(frame, technique: str, stamp: datetime, return_jd: float, successor: float | None, angle: float, depth: int) -> dict:
if technique == "yoga":
sun, moon = frame.sun_moon(return_jd)
template = yoga_vimshottari(stamp, (sun + moon) % 360.0, 360.0, natal=False, depth=depth)
else:
_sun, moon = frame.sun_moon(return_jd)
template = nakshatra_tribhagi(stamp, moon % 360.0, 360.0, depth=depth)
clock = ElongationClock(frame, return_jd, angle, successor)
template["periods"] = remap_angular(template["periods"], clock, return_jd)
return template
def _annual_returns(frame: SiderealFrame, natal_jd: float, target_year: int) -> dict:
"""Tithi roots for three years. Yoga and nakshatra roots for the target year only."""
tithi = []
for year in range(int(target_year), int(target_year) + 3):
begin = float(swe.julday(year, 1, 1, 0.0))
end = float(swe.julday(year + 1, 1, 1, 0.0))
for row in find_returns(frame, "tithi_pravesha", natal_jd, begin, end)["returns"]:
copied = dict(row)
copied["year"] = year
tithi.append(copied)
begin = float(swe.julday(int(target_year), 1, 1, 0.0))
end = float(swe.julday(int(target_year) + 1, 1, 1, 0.0))
return {
"status": "calculated",
"returns": {
"tithi": tithi,
"yoga": list(find_returns(frame, "yoga_pravesha", natal_jd, begin, end)["returns"]),
"nakshatra": list(find_returns(frame, "nakshatra_pravesha", natal_jd, begin, end)["returns"]),
},
}
def _later_returns(frame, natal_jd: float, technique: str, jd: float, start_offset: float, end_offset: float) -> list[dict]:
begin = float(jd) + float(start_offset)
end = float(jd) + float(end_offset)
if start_offset <= 0:
begin = float(jd) - 1e-4
end = begin + 400.0
found = find_returns(frame, technique, natal_jd, begin, end)["returns"]
return [item for item in found if item["jd_ut"] > float(jd) + 1e-6]
def _failed() -> dict:
return {
"status": "omitted",
"periods": [],
"reason_en": "This branch did not finish. The runtime log has the cause.",
"reason_zh": "这一支没有算成,原因记在运行日志里。",
}
def _tithi_number(sun: float, moon: float) -> int:
return int(((moon - sun) % 360.0) // 12) + 1
def _arc_remaining(sun: float, moon: float) -> float:
return 1.0 - (((moon - sun) % 360.0) % 12.0) / 12.0
def _mark(result: dict, sun: float, moon: float) -> dict:
result = dict(result)
result["birth_sun"] = sun
result["birth_moon"] = moon
return result
def _fingerprint(chart: dict) -> tuple:
return (
chart["year"], chart["month"], chart["day"], round(chart["hour_decimal"], 6),
round(chart["lat"], 6), round(chart["lon"], 6), round(chart["tz"], 4),
chart["ayanamsa"], chart["node_mode"], chart["target_year"],
chart["query"].isoformat(), round(chart["year_days"], 6),
round(chart["ascendant"], 8),
tuple(round(float(chart["longitudes"][name]), 8) for name in _PLANETS),
)
def _karana(frame: SiderealFrame, annual: dict, chart: dict, year_days: float) -> dict:
"""Each target-year tithi return, with Julene, PVR, and the 360-tithi PVR year."""
rows = [
row for row in ((annual.get("returns") or {}).get("tithi") or [])
if int(row.get("year", -1)) == int(chart["target_year"])
]
if not rows:
return {
"status": "omitted",
"periods": [],
"reason_en": "No tithi return falls in the target year, so the karana year is not listed.",
"reason_zh": "目标年没有 Tithi 回归,Karana 年这里不列表。",
}
groups = []
for index, row in enumerate(rows, start=1):
groups.append({
"index": index,
"variants": _karana_variants(frame, chart, year_days, row),
})
return {"status": "calculated", "returns": groups}
def _karana_variants(frame: SiderealFrame, chart: dict, year_days: float, row: dict) -> list[dict]:
stamp = jd_to_utc(row["jd_ut"])
sun, moon = row["sun_longitude"], row["moon_longitude"]
ascendant = _return_ascendant(chart, row["jd_ut"])
return_jd = row["jd_ut"]
variants = []
for profile in ("julene", "pvr"):
seed = karana_seed(sun, moon, ascendant if profile == "pvr" else None, profile)
if seed.get("status") != "calculated":
variants.append({"profile": profile, **seed, "periods": []})
continue
built = karana_year(stamp, seed["lord"], seed["remaining_fraction"], float(year_days))
built["profile"] = profile
built["karana"] = seed["karana"]
built["reason_en"] = "The research passage does not supply child periods."
built["reason_zh"] = "这份研究材料没有给出子运,所以这里只列主运。"
variants.append(built)
pvr = next((item for item in variants if item.get("profile") == "pvr" and item.get("status") == "calculated"), None)
if pvr is None:
variants.append({
"profile": "pvr-tithi-year",
"status": "omitted",
"periods": [],
"reason_en": "The quadrant lord is not available, so the tithi-year cycle is not listed.",
"reason_zh": "象限主星没有得到,360 个太阴月的年这里不列表。",
})
else:
try:
template = karana_year(stamp, pvr["seed_lord"], pvr["remaining_fraction"], 360.0)
clock = ElongationClock(frame, return_jd, 4320.0, None)
template["periods"] = remap_angular(template["periods"], clock, return_jd)
template["profile"] = "pvr-tithi-year"
template["karana"] = pvr.get("karana")
template["reason_en"] = "The research passage does not supply child periods."
template["reason_zh"] = "这份研究材料没有给出子运,所以这里只列主运。"
except (ValueError, RuntimeError) as exc:
logger.warning("research timing karana tithi year failed: %s: %s", type(exc).__name__, exc)
template = {
"profile": "pvr-tithi-year",
"status": "omitted",
"periods": [],
"reason_en": "The tithi-year cycle did not finish. The runtime log has the cause.",
"reason_zh": "360 个太阴月的年没有算成,原因记在运行日志里。",
}
variants.append(template)
return variants
def chart_birth_jd(chart: dict) -> float:
return birth_jd(chart["year"], chart["month"], chart["day"], chart["hour_decimal"], chart["tz"])
def _return_ascendant(chart: dict, jd: float) -> float:
try:
from jyotish_engine import compute_chart_data
except ImportError: # pragma: no cover - package import
from scripts.jyotish_engine import compute_chart_data
local = jd_to_utc(jd).astimezone(timezone(timedelta(hours=chart["tz"]))).replace(microsecond=0)
found, _index, chart_jd, ayanamsa_value = compute_chart_data(
local.year, local.month, local.day, local.hour, local.minute,
chart["lat"], chart["lon"], chart["tz"],
node_mode=chart["node_mode"], second=local.second, ayanamsa_name=chart["ayanamsa"],
)
if not isinstance(found, dict):
raise RuntimeError("return ascendant is unavailable")
houses, _ascmc = swe.houses(float(chart_jd), chart["lat"], chart["lon"], b"A")
return (float(houses[0]) - float(ayanamsa_value)) % 360.0
def chart_from_parts(
*,
year: int,
month: int,
day: int,
hour: int,
minute: int,
second: int,
lat: float,
lon: float,
tz: float,
ayanamsa: str,
node_mode: str,
target_year: int,
query: datetime,
year_days: float = 365.25,
) -> dict:
"""Build the calculator input from a chart function result. Positions stay on our frame."""
try:
from jyotish_engine import compute_chart_data
except ImportError: # pragma: no cover - package import
from scripts.jyotish_engine import compute_chart_data
chart, _asc_index, jd, ayanamsa_value = compute_chart_data(
int(year), int(month), int(day), int(hour), int(minute),
float(lat), float(lon), float(tz),
node_mode=node_mode, second=int(second), ayanamsa_name=ayanamsa,
)
if not isinstance(chart, dict):
raise RuntimeError("chart function returned no positions")
houses, _ascmc = swe.houses(float(jd), float(lat), float(lon), b"A")
ascendant = (float(houses[0]) - float(ayanamsa_value)) % 360.0
longitudes = {name: float(chart["planets"][name]["degree_raw"]) for name in _PLANETS}
epoch = datetime(int(year), int(month), int(day), int(hour), int(minute), int(second),
tzinfo=timezone(timedelta(hours=float(tz))))
if query.utcoffset() is None:
query = query.replace(tzinfo=timezone(timedelta(hours=float(tz))))
return {
"year": int(year),
"month": int(month),
"day": int(day),
"hour_decimal": int(hour) + int(minute) / 60.0 + int(second) / 3600.0,
"lat": float(lat),
"lon": float(lon),
"tz": float(tz),
"ayanamsa": str(ayanamsa or "lahiri"),
"node_mode": node_mode if node_mode in ("mean", "true") else "mean",
"target_year": int(target_year),
"query": query,
"year_days": float(year_days),
"epoch": epoch,
"ascendant": ascendant,
"longitudes": longitudes,
"chart_sun": longitudes["Sun"],
"chart_moon": longitudes["Moon"],
}