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