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

255 lines
9.5 KiB
Python

"""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
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 = datetime.fromtimestamp((times[0] - 2440587.5) * 86400, timezone.utc)
events.append({"event": name, "datetime_utc": stamp.isoformat()})
events.sort(key=lambda row: row["datetime_utc"])
return events