"""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()