"""Return roots and elongation clocks on SiderealFrame. The step and bisection follow the research return search. The longitude call is ours. Windows stay at most 400 days. """ from __future__ import annotations import bisect from datetime import datetime, timedelta, timezone import swisseph as swe try: from research_timing.frame import SiderealFrame except ImportError: # pragma: no cover - package import from scripts.research_timing.frame import SiderealFrame try: from muhurta import _find_next_boundary except ImportError: # pragma: no cover - package import from scripts.muhurta import _find_next_boundary _STEP = 0.125 _BISECTIONS = 45 _TOLERANCE = 1e-6 _UNIX_EPOCH_JD = 2440587.5 def _utc_from_jd(jd: float) -> datetime: """UTC from a Julian day. Windows ``fromtimestamp`` rejects instants before 1970.""" unix = (float(jd) - _UNIX_EPOCH_JD) * 86400.0 seconds = int(unix) micros = int(round((unix - seconds) * 1_000_000)) if micros >= 1_000_000: seconds += 1 micros -= 1_000_000 elif micros < 0: seconds -= 1 micros += 1_000_000 return datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=seconds, microseconds=micros) def find_returns( frame: SiderealFrame, technique: str, birth_jd: float, start_jd: float, end_jd: float, ) -> dict: if technique not in ("yoga_pravesha", "nakshatra_pravesha", "tithi_pravesha"): raise ValueError(f"unsupported return technique: {technique}") if not start_jd < end_jd or end_jd - start_jd > 400: raise ValueError("search window must be positive and no longer than 400 days") sun, moon = frame.sun_moon(birth_jd) solar_sign = int(sun // 30) target = _coordinate(technique, sun, moon) def residual(jd: float) -> float: left, right = frame.sun_moon(jd) value = _coordinate(technique, left, right) return (value - target + 180.0) % 360.0 - 180.0 rows: list[dict] = [] left = float(start_jd) left_value = residual(left) while left < end_jd: right = min(left + _STEP, float(end_jd)) right_value = residual(right) if left_value * right_value <= 0 and abs(right_value - left_value) < 180: root = left if abs(left_value) < 1e-8 else _bisect(left, right, left_value, residual) body_sun, body_moon = frame.sun_moon(root) error = residual(root) if ( start_jd <= root < end_jd and abs(error) < _TOLERANCE and int(body_sun // 30) == solar_sign and all(abs(root - row["jd_ut"]) > _TOLERANCE for row in rows) ): rows.append({ "jd_ut": root, "sun_longitude": body_sun, "moon_longitude": body_moon, "residual_degrees": error, }) left, left_value = right, right_value return { "technique": technique, "target_degrees": target, "natal_solar_sign_index": solar_sign, "natal_sun": sun, "natal_moon": moon, "returns": rows, "frame": "swiss_ephemeris_true_precession", } def _coordinate(technique: str, sun: float, moon: float) -> float: if technique == "tithi_pravesha": return (moon - sun) % 360.0 if technique == "yoga_pravesha": return (sun + moon) % 360.0 return moon % 360.0 def _bisect(left: float, right: float, left_value: float, residual) -> float: low, high, low_value = left, right, left_value for _ in range(_BISECTIONS): mid = (low + high) / 2.0 value = residual(mid) if low_value * value <= 0: high = mid else: low, low_value = mid, value return (low + high) / 2.0 def jd_to_utc(jd: float) -> datetime: year, month, day, hour = swe.revjul(float(jd), swe.GREG_CAL) return datetime(int(year), int(month), int(day), tzinfo=timezone.utc) + timedelta(hours=float(hour)) def containing_tithi(frame: SiderealFrame, jd: float) -> dict: """Time fraction left in the 12-degree tithi that contains this instant.""" def phase(at: float) -> float: sun, moon = frame.sun_moon(at) return (moon - sun) % 360.0 angle = phase(jd) cursor = jd - 1.6 previous = None following = None for _ in range(4): boundary = _find_next_boundary(cursor, phase, 12.0, 1.6) if boundary is None or boundary <= cursor: raise RuntimeError("containing tithi boundaries unavailable") if boundary > jd: following = boundary break previous, cursor = boundary, boundary if previous is None or following is None or not previous <= jd < following: raise RuntimeError("containing tithi boundaries unavailable") return { "tithi_num": int(angle // 12) + 1, "elongation": angle, "remaining_time_fraction": (following - jd) / (following - previous), "remaining_angular_fraction": 1.0 - (angle % 12.0) / 12.0, "start_jd_ut": previous, "end_jd_ut": following, } def measure_elongation(frame: SiderealFrame, start_jd: float, end_jd: float) -> float: """Unwrapped sidereal Moon-minus-Sun over at most 400 days.""" if not 0 < end_jd - start_jd <= 400: raise ValueError("motion interval must span more than 0 and at most 400 days") cursor = float(start_jd) sun, moon = frame.sun_moon(cursor) previous = (moon - sun) % 360.0 total = 0.0 while cursor < end_jd: cursor = min(cursor + 1.0, float(end_jd)) sun, moon = frame.sun_moon(cursor) current = (moon - sun) % 360.0 total += (current - previous + 180.0) % 360.0 - 180.0 previous = current return total class ElongationClock: """Map a 360-unit year onto accumulated sidereal elongation.""" def __init__(self, frame: SiderealFrame, return_jd: float, total_angle: float, known_end_jd: float | None = None) -> None: if not 0 < total_angle <= 6000: raise ValueError("positive total angle within the annual grid is required") self.frame = frame self.return_jd = float(return_jd) self.total_angle = float(total_angle) self._days = [self.return_jd + index for index in range(-100, 501)] self._phases = [self._phase(day) for day in self._days] self._angles = [0.0] for left, right in zip(self._phases, self._phases[1:]): step = (right - left + 180.0) % 360.0 - 180.0 if not 0 < step < 180: raise ValueError("elongation clock requires forward daily motion") self._angles.append(self._angles[-1] + step) self._origin = self._angles[100] self._cache = {0.0: self.return_jd} if known_end_jd is not None: if not self.return_jd < known_end_jd <= self.return_jd + 500: raise ValueError("known endpoint must lie in the forward annual grid") self._cache[360.0] = float(known_end_jd) def _phase(self, jd: float) -> float: sun, moon = self.frame.sun_moon(jd) return (moon - sun) % 360.0 def solve(self, offset: float) -> float: if abs(offset) < 1e-8: offset = 0.0 if abs(offset - 360.0) < 1e-8: offset = 360.0 cached = self._cache.get(offset) if cached is not None: return cached target = self._origin + offset / 360.0 * self.total_angle index = bisect.bisect_right(self._angles, target) - 1 if not 0 <= index < len(self._days) - 1: raise ValueError("angular boundary outside the bounded search grid") found = self._solve_day(index, target) self._cache[offset] = found return found def _solve_day(self, index: int, target: float) -> float: """Day bracket, then a few slope steps. Residual stays within 1e-6 degree.""" left, right = self._days[index], self._days[index + 1] left_angle = self._angles[index] span = self._angles[index + 1] - left_angle if span <= 0 or right <= left: raise ValueError("elongation clock day is not forward") origin_phase = self._phases[index] def motion(jd: float) -> float: return left_angle + (self._phase(jd) - origin_phase + 180.0) % 360.0 - 180.0 slope = span / (right - left) guess = left + (target - left_angle) / span * (right - left) for _ in range(6): value = motion(guess) error = value - target if abs(error) <= 1e-6: return guess guess = min(right, max(left, guess - error / slope)) low, high = left, right for _ in range(40): mid = (low + high) / 2.0 error = motion(mid) - target if abs(error) <= 1e-6 or high - low < 1e-9: return mid if error < 0: low = mid else: high = mid return (low + high) / 2.0 def hindu_solar_events(moment: datetime, latitude: float, longitude: float) -> list[dict]: """Sunrise and sunset from the Swiss ephemeris file. No Moshier and no true-position flag.""" if moment.utcoffset() is None: raise ValueError("solar events need a timezone") midnight = moment.astimezone(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) flags = swe.FLG_SWIEPH events = [] for offset in range(-3, 4): jd = (midnight + timedelta(days=offset)).timestamp() / 86400.0 + 2440587.5 for name, mode in (("sunrise", swe.CALC_RISE), ("sunset", swe.CALC_SET)): status, times = swe.rise_trans( jd, swe.SUN, mode | swe.BIT_HINDU_RISING, (float(longitude), float(latitude), 0.0), flags=flags, ) if status == 0 and jd <= times[0] < jd + 1: stamp = _utc_from_jd(times[0]) events.append({"event": name, "datetime_utc": stamp.isoformat()}) events.sort(key=lambda row: row["datetime_utc"]) return events