"""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"], }